From 8ec94c1e5d1af5590998ad1bcb9e850d7c14e251 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 2 Aug 2026 19:01:28 +0200 Subject: [PATCH 001/123] perf: Improve error handling --- .../src/app/[locale]/(docs)/docs/error.tsx | 10 +++ .../src/app/[locale]/(main)/(home)/page.tsx | 3 +- .../login/sso/[providerId]/loading.tsx | 10 +++ apps/docs/src/app/[locale]/(main)/error.tsx | 10 +++ .../src/app/[locale]/admin/(auth)/error.tsx | 10 +++ apps/docs/src/app/[locale]/error.tsx | 10 +++ apps/docs/src/app/global-error.tsx | 16 ++++- .../animated-beam-home-skeleton.tsx | 24 +++++++ apps/docs/src/locales/@vitnode/core/pl.json | 6 +- .../root/src/app/global-error copy.tsx | 16 ++++- .../root/src/app/global-error.tsx | 16 ++++- packages/vitnode/src/locales/en.json | 6 +- .../main/login/sso/[providerId]/loading.tsx | 10 +++ .../core/dashboard/grid/board-provider.tsx | 1 - .../dashboard/grid/widget-settings-dialog.tsx | 8 --- .../src/views/error/global-error-view.tsx | 63 +++++++++++++++++-- .../src/views/error/route-error-view.tsx | 62 ++++++++++++++++++ 17 files changed, 251 insertions(+), 30 deletions(-) create mode 100644 apps/docs/src/app/[locale]/(docs)/docs/error.tsx create mode 100644 apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/loading.tsx create mode 100644 apps/docs/src/app/[locale]/(main)/error.tsx create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/error.tsx create mode 100644 apps/docs/src/app/[locale]/error.tsx create mode 100644 apps/docs/src/components/animated-beam/animated-beam-home-skeleton.tsx create mode 100644 packages/vitnode/src/routes/main/login/sso/[providerId]/loading.tsx create mode 100644 packages/vitnode/src/views/error/route-error-view.tsx diff --git a/apps/docs/src/app/[locale]/(docs)/docs/error.tsx b/apps/docs/src/app/[locale]/(docs)/docs/error.tsx new file mode 100644 index 000000000..f56b7d54c --- /dev/null +++ b/apps/docs/src/app/[locale]/(docs)/docs/error.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { + RouteErrorView, + type RouteErrorViewProps, +} from "@vitnode/core/views/error/route-error-view"; + +export default function ErrorBoundary({ error, retry }: RouteErrorViewProps) { + return ; +} diff --git a/apps/docs/src/app/[locale]/(main)/(home)/page.tsx b/apps/docs/src/app/[locale]/(main)/(home)/page.tsx index fdbcec642..0794e36b2 100644 --- a/apps/docs/src/app/[locale]/(main)/(home)/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(home)/page.tsx @@ -7,6 +7,7 @@ import { ChevronRight } from "lucide-react"; import { Suspense } from "react"; import { AnimatedBeamHome } from "../../../../components/animated-beam/animated-beam-home"; +import { AnimatedBeamHomeSkeleton } from "../../../../components/animated-beam/animated-beam-home-skeleton"; import { AdminSection } from "./sections/admin/admin"; import { CallToActionSection } from "./sections/call-to-action"; import { PoweringBySection } from "./sections/powering-by/powering-by"; @@ -78,7 +79,7 @@ export default function HomePage() { - + }> diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/loading.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/loading.tsx new file mode 100644 index 000000000..9e5f25c4c --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/loading.tsx @@ -0,0 +1,10 @@ +import { Loader } from "@vitnode/core/components/ui/loader"; + +export default function Loading() { + return ( +
+ + Loading +
+ ); +} diff --git a/apps/docs/src/app/[locale]/(main)/error.tsx b/apps/docs/src/app/[locale]/(main)/error.tsx new file mode 100644 index 000000000..f56b7d54c --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/error.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { + RouteErrorView, + type RouteErrorViewProps, +} from "@vitnode/core/views/error/route-error-view"; + +export default function ErrorBoundary({ error, retry }: RouteErrorViewProps) { + return ; +} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/error.tsx b/apps/docs/src/app/[locale]/admin/(auth)/error.tsx new file mode 100644 index 000000000..f56b7d54c --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/error.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { + RouteErrorView, + type RouteErrorViewProps, +} from "@vitnode/core/views/error/route-error-view"; + +export default function ErrorBoundary({ error, retry }: RouteErrorViewProps) { + return ; +} diff --git a/apps/docs/src/app/[locale]/error.tsx b/apps/docs/src/app/[locale]/error.tsx new file mode 100644 index 000000000..f56b7d54c --- /dev/null +++ b/apps/docs/src/app/[locale]/error.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { + RouteErrorView, + type RouteErrorViewProps, +} from "@vitnode/core/views/error/route-error-view"; + +export default function ErrorBoundary({ error, retry }: RouteErrorViewProps) { + return ; +} diff --git a/apps/docs/src/app/global-error.tsx b/apps/docs/src/app/global-error.tsx index 786d47b66..ccb9d5ed2 100644 --- a/apps/docs/src/app/global-error.tsx +++ b/apps/docs/src/app/global-error.tsx @@ -9,6 +9,18 @@ const geist = Geist({ subsets: ["latin"], }); -export default function GlobalError() { - return ; +export default function GlobalError({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + return ( + + ); } diff --git a/apps/docs/src/components/animated-beam/animated-beam-home-skeleton.tsx b/apps/docs/src/components/animated-beam/animated-beam-home-skeleton.tsx new file mode 100644 index 000000000..1d806011a --- /dev/null +++ b/apps/docs/src/components/animated-beam/animated-beam-home-skeleton.tsx @@ -0,0 +1,24 @@ +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; + +const Row = ({ center }: { center?: boolean }) => ( +
+ + + +
+); + +export const AnimatedBeamHomeSkeleton = () => ( +
+
+ + + +
+ + Loading +
+); diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json index f87acb19a..6c1886d04 100644 --- a/apps/docs/src/locales/@vitnode/core/pl.json +++ b/apps/docs/src/locales/@vitnode/core/pl.json @@ -220,6 +220,8 @@ "desc": "Przepraszamy, występują problemy techniczne po stronie serwera." }, "title": "Ups! Coś poszło nie tak.", + "try_again": "Spróbuj ponownie", + "reference": "Identyfikator błędu: {digest}", "internal_server_error": "Wewnętrzny błąd serwera.", "field_required": "To pole jest wymagane.", "field_min_length": "To pole musi mieć co najmniej {min} znaków.", @@ -409,8 +411,6 @@ "drop_here": "Upuść tutaj widżet", "empty_title": "Twój pulpit jest pusty", "empty_desc": "Dodaj widżety, aby mieć ważne dane zawsze pod ręką.", - "saved_title": "Pulpit zapisany", - "saved_desc": "Ten układ należy tylko do Ciebie - inni administratorzy mają swój własny.", "error_title": "Nie udało się zapisać pulpitu", "error_desc": "Coś poszło nie tak po drodze do serwera. Spróbuj ponownie.", "refresh_error": "Nie udało się odświeżyć tego widżetu. Odśwież stronę, aby zobaczyć zmiany.", @@ -433,8 +433,6 @@ "open": "Skonfiguruj {title}", "title": "Skonfiguruj {title}", "desc": "Te ustawienia dotyczą tylko tej karty - inni administratorzy mają swoje własne.", - "saved_title": "Ustawienia zapisane", - "saved_desc": "Karta użyje ich, gdy skończysz układać pulpit.", "error_title": "Nie udało się zapisać ustawień", "error_desc": "Coś poszło nie tak po drodze do serwera. Spróbuj ponownie.", "load_error": "Nie udało się wczytać tych ustawień. Zamknij i spróbuj ponownie." diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error copy.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error copy.tsx index 786d47b66..ccb9d5ed2 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error copy.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error copy.tsx @@ -9,6 +9,18 @@ const geist = Geist({ subsets: ["latin"], }); -export default function GlobalError() { - return ; +export default function GlobalError({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + return ( + + ); } diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx index 786d47b66..ccb9d5ed2 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx @@ -9,6 +9,18 @@ const geist = Geist({ subsets: ["latin"], }); -export default function GlobalError() { - return ; +export default function GlobalError({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + return ( + + ); } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 1d8bff561..b99b1795e 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -220,6 +220,8 @@ "desc": "Sorry, we're experiencing technical difficulties on our server." }, "title": "Oops! Something went wrong.", + "try_again": "Try again", + "reference": "Error reference: {digest}", "internal_server_error": "Internal server error.", "field_required": "This field is required.", "field_min_length": "This field must be at least {min} characters.", @@ -398,8 +400,6 @@ "drop_here": "Drop a widget here", "empty_title": "Your dashboard is empty", "empty_desc": "Add widgets to keep the numbers you care about one glance away.", - "saved_title": "Dashboard saved", - "saved_desc": "Your layout is yours alone - other admins keep theirs.", "error_title": "Could not save your dashboard", "error_desc": "Something went wrong on the way to the server. Please try again.", "refresh_error": "Could not reload this widget. Reload the page to see your changes.", @@ -422,8 +422,6 @@ "open": "Configure {title}", "title": "Configure {title}", "desc": "These settings apply to this card only - other admins keep their own.", - "saved_title": "Settings saved", - "saved_desc": "The card picks them up once you are done arranging the board.", "error_title": "Could not save the settings", "error_desc": "Something went wrong on the way to the server. Please try again.", "load_error": "Could not load these settings. Close this and try again." diff --git a/packages/vitnode/src/routes/main/login/sso/[providerId]/loading.tsx b/packages/vitnode/src/routes/main/login/sso/[providerId]/loading.tsx new file mode 100644 index 000000000..33c3aa582 --- /dev/null +++ b/packages/vitnode/src/routes/main/login/sso/[providerId]/loading.tsx @@ -0,0 +1,10 @@ +import { Loader } from "@/components/ui/loader"; + +export default function Loading() { + return ( +
+ + Loading +
+ ); +} diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx index 576e5e199..e41b85448 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx @@ -272,7 +272,6 @@ export const DashboardBoardProvider = ({ } setIsEditing(false); - toast.success(t("saved_title"), { description: t("saved_desc") }); router.refresh(); }); }; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx index 37d661c02..6c0be9e87 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx @@ -121,14 +121,6 @@ export const WidgetSettingsDialog = ({ } setOpen(false); - toast.success(t("settings.saved_title"), { - description: t("settings.saved_desc"), - }); - - // Left until the dialog has finished closing. The card suspends - // while it is re-rendered, and a suspended render mid-animation - // strands the overlay on screen - batched into this transition it - // would also hold the close back until the new card was ready. setTimeout(onSaved, 300); } finally { resolve(); diff --git a/packages/vitnode/src/views/error/global-error-view.tsx b/packages/vitnode/src/views/error/global-error-view.tsx index a5d95df93..dea63c3a0 100644 --- a/packages/vitnode/src/views/error/global-error-view.tsx +++ b/packages/vitnode/src/views/error/global-error-view.tsx @@ -1,17 +1,35 @@ +"use client"; + import type { Metadata } from "next/dist/types"; +import { HomeIcon, RefreshCwIcon } from "lucide-react"; // eslint-disable-next-line no-restricted-imports import Link from "next/link"; +import { useTransition } from "react"; import { LogoVitNode } from "@/components/logo-vitnode"; import { ThemeProvider } from "@/components/theme-provider"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; export const metadata: Metadata = { title: "Error 500!", }; -export const GlobalErrorView = ({ className }: { className?: string }) => { +export interface GlobalErrorViewProps { + className?: string; + error: Error & { digest?: string }; + retry: () => void; +} + +export const GlobalErrorView = ({ + className, + error, + retry, +}: GlobalErrorViewProps) => { + const [isRetrying, startRetry] = useTransition(); + return ( @@ -22,16 +40,49 @@ export const GlobalErrorView = ({ className }: { className?: string }) => { -

+

Oops! Something went wrong.

- -

- An unexpected error occurred. Please try refreshing the page - or come back later. + +

+ An unexpected error occurred. Try again, and if it keeps + happening come back a little later.

+ + {error.digest ? ( +

+ Error reference: {error.digest} +

+ ) : null} + +
+ + + + + Back to home + +
diff --git a/packages/vitnode/src/views/error/route-error-view.tsx b/packages/vitnode/src/views/error/route-error-view.tsx new file mode 100644 index 000000000..0f20e9870 --- /dev/null +++ b/packages/vitnode/src/views/error/route-error-view.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { HomeIcon, RefreshCwIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useTransition } from "react"; + +import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button"; +import { Link } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; + +import { ErrorView } from "./error-view"; + +export interface RouteErrorViewProps { + error: Error & { digest?: string }; + retry: () => void; +} + +export const RouteErrorView = ({ error, retry }: RouteErrorViewProps) => { + const t = useTranslations("core.global"); + const [isRetrying, startRetry] = useTransition(); + + return ( + + + + + + {t("back_home")} + + + } + customDescription={ + <> + {t("errors.500.desc")} + {error.digest ? ( + + {t("errors.reference", { digest: error.digest })} + + ) : null} + + } + /> + ); +}; From 9fd84d19019bb4057424b30df729a7f75cd55ade Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 2 Aug 2026 22:57:07 +0200 Subject: [PATCH 002/123] feat: Add content engine stage 1 --- apps/api/package.json | 1 + apps/api/probe-routes.ts | 13 + apps/api/src/vitnode.api.config.ts | 3 +- apps/docs/AGENTS.md | 9 + apps/docs/CLAUDE.md | 1 + .../docs/dev/content-engine/admincp.mdx | 95 + .../database-and-migrations.mdx | 124 + .../defining-a-content-type.mdx | 227 ++ .../docs/dev/content-engine/events.mdx | 92 + .../docs/dev/content-engine/fields.mdx | 143 + .../content/docs/dev/content-engine/index.mdx | 101 + .../docs/dev/content-engine/limitations.mdx | 55 + .../content/docs/dev/content-engine/meta.json | 18 + .../dev/content-engine/overriding-admincp.mdx | 104 + .../docs/dev/content-engine/permissions.mdx | 86 + .../docs/dev/content-engine/schemas.mdx | 97 + .../docs/dev/content-engine/service.mdx | 116 + apps/docs/content/docs/dev/database/index.mdx | 26 +- .../docs/dev/events/built-in-events.mdx | 39 + apps/docs/content/docs/dev/meta.json | 1 + .../migrations/0022_add_example_content.sql | 32 + apps/docs/migrations/meta/0022_snapshot.json | 2472 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + apps/docs/package.json | 1 + .../(vitnode-core)/content/[...slug]/page.tsx | 28 + .../@breadcrumb/content/[...slug]/page.tsx | 22 + apps/docs/src/vitnode.api.config.ts | 3 +- apps/docs/src/vitnode.config.ts | 3 +- packages/vitnode/package.json | 11 + packages/vitnode/src/api/lib/module.ts | 12 + packages/vitnode/src/api/lib/plugin.test.ts | 91 + packages/vitnode/src/api/lib/plugin.ts | 37 +- packages/vitnode/src/api/lib/route.test.ts | 88 + packages/vitnode/src/api/lib/route.ts | 5 +- .../src/api/middlewares/global.middleware.ts | 15 + .../src/components/form/fields/date-time.tsx | 71 + packages/vitnode/src/content/admin/config.ts | 47 + .../vitnode/src/content/admin/fetch.server.ts | 76 + packages/vitnode/src/content/admin/labels.ts | 44 + .../vitnode/src/content/admin/spec.test.ts | 260 ++ packages/vitnode/src/content/admin/spec.ts | 307 ++ packages/vitnode/src/content/const.ts | 44 + packages/vitnode/src/content/define.test-d.ts | 181 ++ packages/vitnode/src/content/define.test.ts | 278 ++ packages/vitnode/src/content/define.ts | 356 +++ packages/vitnode/src/content/errors.ts | 23 + packages/vitnode/src/content/events.test-d.ts | 74 + packages/vitnode/src/content/events.ts | 52 + packages/vitnode/src/content/fields.ts | 174 ++ packages/vitnode/src/content/index.ts | 88 + packages/vitnode/src/content/registry.test.ts | 189 ++ packages/vitnode/src/content/registry.ts | 165 ++ packages/vitnode/src/content/schemas.test.ts | 233 ++ packages/vitnode/src/content/schemas.ts | 254 ++ .../src/content/server/column-builders.ts | 133 + packages/vitnode/src/content/server/emit.ts | 39 + .../src/content/server/http-errors.test.ts | 94 + .../vitnode/src/content/server/http-errors.ts | 67 + packages/vitnode/src/content/server/index.ts | 45 + packages/vitnode/src/content/server/model.ts | 73 + packages/vitnode/src/content/server/module.ts | 50 + .../vitnode/src/content/server/query.test.ts | 229 ++ packages/vitnode/src/content/server/query.ts | 147 + .../vitnode/src/content/server/routes.test.ts | 431 +++ packages/vitnode/src/content/server/routes.ts | 263 ++ .../src/content/server/service.test.ts | 275 ++ .../vitnode/src/content/server/service.ts | 399 +++ .../src/content/server/table.test-d.ts | 96 + .../vitnode/src/content/server/table.test.ts | 198 ++ packages/vitnode/src/content/server/table.ts | 150 + packages/vitnode/src/content/server/types.ts | 127 + packages/vitnode/src/content/types.ts | 355 +++ packages/vitnode/src/lib/fetcher/core.ts | 87 +- packages/vitnode/src/lib/fetcher/raw.ts | 120 + packages/vitnode/src/lib/plugin.ts | 71 + packages/vitnode/src/locales/en.json | 52 + .../routes/admin/content/[...slug]/page.tsx | 28 + .../admin/content/[...slug]/page.tsx | 22 + .../vitnode/src/tests/content-fixtures.ts | 52 + .../layouts/sidebar/nav/get-admin-nav.tsx | 86 +- .../views/content/actions/content-form.tsx | 125 + .../views/content/actions/create-action.tsx | 52 + .../views/content/actions/delete-action.tsx | 92 + .../views/content/actions/edit-action.tsx | 89 + .../content/actions/mutation-api.server.ts | 130 + .../views/content/content-admin-view.tsx | 140 + .../content/lib/field-component.test.tsx | 120 + .../views/content/lib/field-component.tsx | 104 + .../views/admin/views/content/table/cells.tsx | 90 + .../content/table/content-table-view.tsx | 151 + packages/vitnode/vitest.config.ts | 6 + plugins/example/.npmignore | 17 + plugins/example/.swcrc | 26 + plugins/example/eslint.config.mjs | 19 + plugins/example/global.d.ts | 10 + plugins/example/package.json | 47 + plugins/example/src/api/lib/events.ts | 20 + .../src/api/modules/admin/admin.module.ts | 25 + plugins/example/src/config.api.ts | 17 + plugins/example/src/config.tsx | 28 + plugins/example/src/const.ts | 1 + plugins/example/src/content/article.ts | 47 + plugins/example/src/content/category.ts | 24 + plugins/example/src/database/articles.ts | 13 + plugins/example/src/database/categories.ts | 9 + plugins/example/src/locales/en.json | 47 + plugins/example/src/locales/index.ts | 11 + plugins/example/tsconfig.build.json | 5 + plugins/example/tsconfig.json | 26 + pnpm-lock.yaml | 464 ++-- 110 files changed, 12437 insertions(+), 301 deletions(-) create mode 100644 apps/api/probe-routes.ts create mode 100644 apps/docs/AGENTS.md create mode 100644 apps/docs/CLAUDE.md create mode 100644 apps/docs/content/docs/dev/content-engine/admincp.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/events.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/fields.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/index.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/limitations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/meta.json create mode 100644 apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/permissions.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/schemas.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/service.mdx create mode 100644 apps/docs/migrations/0022_add_example_content.sql create mode 100644 apps/docs/migrations/meta/0022_snapshot.json create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx create mode 100644 packages/vitnode/src/api/lib/plugin.test.ts create mode 100644 packages/vitnode/src/api/lib/route.test.ts create mode 100644 packages/vitnode/src/components/form/fields/date-time.tsx create mode 100644 packages/vitnode/src/content/admin/config.ts create mode 100644 packages/vitnode/src/content/admin/fetch.server.ts create mode 100644 packages/vitnode/src/content/admin/labels.ts create mode 100644 packages/vitnode/src/content/admin/spec.test.ts create mode 100644 packages/vitnode/src/content/admin/spec.ts create mode 100644 packages/vitnode/src/content/const.ts create mode 100644 packages/vitnode/src/content/define.test-d.ts create mode 100644 packages/vitnode/src/content/define.test.ts create mode 100644 packages/vitnode/src/content/define.ts create mode 100644 packages/vitnode/src/content/errors.ts create mode 100644 packages/vitnode/src/content/events.test-d.ts create mode 100644 packages/vitnode/src/content/events.ts create mode 100644 packages/vitnode/src/content/fields.ts create mode 100644 packages/vitnode/src/content/index.ts create mode 100644 packages/vitnode/src/content/registry.test.ts create mode 100644 packages/vitnode/src/content/registry.ts create mode 100644 packages/vitnode/src/content/schemas.test.ts create mode 100644 packages/vitnode/src/content/schemas.ts create mode 100644 packages/vitnode/src/content/server/column-builders.ts create mode 100644 packages/vitnode/src/content/server/emit.ts create mode 100644 packages/vitnode/src/content/server/http-errors.test.ts create mode 100644 packages/vitnode/src/content/server/http-errors.ts create mode 100644 packages/vitnode/src/content/server/index.ts create mode 100644 packages/vitnode/src/content/server/model.ts create mode 100644 packages/vitnode/src/content/server/module.ts create mode 100644 packages/vitnode/src/content/server/query.test.ts create mode 100644 packages/vitnode/src/content/server/query.ts create mode 100644 packages/vitnode/src/content/server/routes.test.ts create mode 100644 packages/vitnode/src/content/server/routes.ts create mode 100644 packages/vitnode/src/content/server/service.test.ts create mode 100644 packages/vitnode/src/content/server/service.ts create mode 100644 packages/vitnode/src/content/server/table.test-d.ts create mode 100644 packages/vitnode/src/content/server/table.test.ts create mode 100644 packages/vitnode/src/content/server/table.ts create mode 100644 packages/vitnode/src/content/server/types.ts create mode 100644 packages/vitnode/src/content/types.ts create mode 100644 packages/vitnode/src/lib/fetcher/raw.ts create mode 100644 packages/vitnode/src/routes/admin/content/[...slug]/page.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx create mode 100644 packages/vitnode/src/tests/content-fixtures.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/content-form.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/create-action.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts create mode 100644 packages/vitnode/src/views/admin/views/content/content-admin-view.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/field-component.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/field-component.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/table/cells.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx create mode 100644 plugins/example/.npmignore create mode 100644 plugins/example/.swcrc create mode 100644 plugins/example/eslint.config.mjs create mode 100644 plugins/example/global.d.ts create mode 100644 plugins/example/package.json create mode 100644 plugins/example/src/api/lib/events.ts create mode 100644 plugins/example/src/api/modules/admin/admin.module.ts create mode 100644 plugins/example/src/config.api.ts create mode 100644 plugins/example/src/config.tsx create mode 100644 plugins/example/src/const.ts create mode 100644 plugins/example/src/content/article.ts create mode 100644 plugins/example/src/content/category.ts create mode 100644 plugins/example/src/database/articles.ts create mode 100644 plugins/example/src/database/categories.ts create mode 100644 plugins/example/src/locales/en.json create mode 100644 plugins/example/src/locales/index.ts create mode 100644 plugins/example/tsconfig.build.json create mode 100644 plugins/example/tsconfig.json diff --git a/apps/api/package.json b/apps/api/package.json index 291fc14ac..52db9277d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,6 +43,7 @@ "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@vitnode/blog": "workspace:*", + "@vitnode/example": "workspace:*", "@vitnode/config": "workspace:*", "@vitnode/nodemailer": "workspace:*", "dotenv": "^17.4.2", diff --git a/apps/api/probe-routes.ts b/apps/api/probe-routes.ts new file mode 100644 index 000000000..1817c4491 --- /dev/null +++ b/apps/api/probe-routes.ts @@ -0,0 +1,13 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { VitNodeAPI } from "@vitnode/core/api/config"; + +import { vitNodeApiConfig } from "./src/vitnode.api.config"; + +const app = new OpenAPIHono().basePath("/api"); +VitNodeAPI({ app, vitNodeApiConfig }); + +const paths = app.routes + .map(r => `${r.method.padEnd(7)} ${r.path}`) + .filter(p => p.includes("example")); +console.log([...new Set(paths)].sort().join("\n")); +console.log("\ntotal example routes:", new Set(paths).size); diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 8667b7cc2..cf894ab6f 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -1,5 +1,6 @@ import { google } from "@ai-sdk/google"; import { blogApiPlugin } from "@vitnode/blog/config.api"; +import { exampleApiPlugin } from "@vitnode/example/config.api"; // import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { NodeCronAdapter } from "@vitnode/node-cron"; @@ -17,7 +18,7 @@ export const POSTGRES_URL = process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode"; export const vitNodeApiConfig = buildApiConfig({ - plugins: [blogApiPlugin()], + plugins: [blogApiPlugin(), exampleApiPlugin()], ai: { models: [ { diff --git a/apps/docs/AGENTS.md b/apps/docs/AGENTS.md new file mode 100644 index 000000000..643577dfa --- /dev/null +++ b/apps/docs/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/docs/CLAUDE.md b/apps/docs/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/apps/docs/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx new file mode 100644 index 000000000..84aef55a2 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -0,0 +1,95 @@ +--- +title: Generated AdminCP +description: The list, form and delete screens you get for free - and the one route that serves all of them. +icon: LayoutDashboard +--- + +Registering a content type in `buildPlugin` is the entire frontend integration. +There is no page to write. + +```tsx title="src/config.tsx" +contentTypes: [ + contentTypeAdmin({ + definition: articleContentType, + icon: , + }), +], +``` + +You get a nav item, a breadcrumb, and a screen at: + +```text +/admin/content/example/article +``` + +## What the screen does + +- **List** - a `DataTable` with the columns from `admin.list.columns` +- **Search** - across `admin.list.searchableFields`, wildcards escaped +- **Sorting** - limited to `admin.list.orderableFields` plus the system columns +- **Pagination** - the standard cursor pagination, capped at 100 per page +- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open +- **Delete** - a confirmation dialog +- **Empty, loading and error states** - out of the box + +Every mutation closes its dialog, refreshes the list and raises a `sonner` toast +with a description. Failures raise an error toast instead; a delete blocked by a +foreign key gets its own message rather than a generic one. + +## One route, every content type + +Core ships a single catch-all page that is synced into your app like any other +plugin route: + +```text +packages/vitnode/src/routes/admin/content/[...slug]/page.tsx +packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx +``` + +The slug maps back onto a content type id - `/admin/content/example/article` +resolves `example.article` from the registered plugins at request time. Add a +tenth content type and the file count stays at two. + +## Field to component + +| Kind | Component | +| --- | --- | +| `text` | `AutoFormInput` | +| `textarea` | `AutoFormTextarea` | +| `number` | number input, or `AutoFormNullableNumber` when nullable | +| `boolean` | `AutoFormSwitch` | +| `enum` | `AutoFormSelect`, or `AutoFormRadioGroup` with `display: "radio"` | +| `dateTime` | `AutoFormDateTime` | +| `user` | `AutoFormCombobox`, async | +| `relation` | `AutoFormCombobox`, async | + +These are the components every other VitNode admin screen uses. The engine adds +no second form system - `AutoForm` does the work, exactly as it does in the blog +plugin. + +## The server/client boundary + +The page is a server component; the form is a client one. A definition cannot +cross that boundary - it holds `target` thunks and Zod schemas, neither of which +serialise. + +So the server projects the definition into a plain JSON **spec** - field kinds, +resolved labels, enum options, validation bounds - and the client rebuilds the +form schema from it: + +```text +server client +────── ────── +definition ──► buildContentFormSpec ──► buildFormSchemaFromSpec ──► AutoForm + (plain JSON) +``` + +The relation pickers go through a server action rather than a client fetch, so +the browser never needs the API origin and the request is gated by the content +type's own `can_view`. + +## Permissions + +The page checks `can_view` server-side and 404s without it. The create, edit and +delete controls check their own permissions client-side - and the routes behind +them check again, which is the check that actually matters. 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 new file mode 100644 index 000000000..474b912d5 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx @@ -0,0 +1,124 @@ +--- +title: Database & migrations +description: How Drizzle Kit discovers generated tables, what the migration looks like, and what happens when you rename a field. +icon: Database +--- + +A content type is one real Postgres table. Nothing about migrations changes - +the same `drizzle-kit generate`, the same committed SQL, the same journal. + +## What gets generated + +Every content table gets three system columns, matching the conventions used by +every hand-written VitNode table: + +```ts +id: serial primary key +createdAt: timestamp not null default now() +updatedAt: timestamp not null default now() // refreshed via $onUpdate +``` + +plus one column per field, an index on both timestamps, an index on every +foreign key, any index you declared - and `ENABLE ROW LEVEL SECURITY`. + +Here is the real migration for the example plugin: + +```sql title="apps/docs/migrations/0022_add_example_content.sql" +CREATE TABLE "example_articles" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "excerpt" text, + "views" integer DEFAULT 0 NOT NULL, + "featured" boolean DEFAULT false NOT NULL, + "status" varchar(64) DEFAULT 'draft' NOT NULL, + "publishedAt" timestamp, + "author" integer, + "category" integer NOT NULL +); +ALTER TABLE "example_articles" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "example_articles" ADD CONSTRAINT "example_articles_author_core_users_id_fk" + FOREIGN KEY ("author") REFERENCES "public"."core_users"("id") + ON DELETE set null ON UPDATE cascade; +ALTER TABLE "example_articles" ADD CONSTRAINT "example_articles_category_example_categories_id_fk" + FOREIGN KEY ("category") REFERENCES "public"."example_categories"("id") + ON DELETE restrict ON UPDATE cascade; +CREATE INDEX "example_articles_status_createdat_idx" + ON "example_articles" USING btree ("status","createdAt"); +``` + +Ordinary SQL. Nothing about it says "generated", which is the point. + +## How Drizzle Kit finds it + +`defineVitNodeDrizzleConfig` globs every registered plugin's compiled +`node_modules//dist/src/database/*.js` and collects anything that is a +Drizzle table at runtime. A table produced by `createContentModel` is a real +`pgTable`, so it is picked up exactly like a hand-written one. + +Two rules follow from that: + + + The glob reads `dist`, not `src`. Run `build:plugins` before `db:migrate`, or + your new table simply will not appear. + + +- **Keep database files flat.** The glob is `*.js`, not `**/*.js` - + `src/database/blog/posts.ts` is invisible. +- **Keep them cheap to import.** Drizzle Kit *executes* these modules. No Hono + context, no React, no `server-only`, no top-level side effects. + +## Naming and clean installs + +Migrations are named by `drizzle-kit` and then renamed to something descriptive +when the change is worth recognising - `0022_add_example_content.sql` rather +than `0022_dry_hercules.sql`. Update the matching `tag` in +`migrations/meta/_journal.json` when you do. + +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. + +## Renaming or removing a field + + + The engine has no rename detection. Changing `excerpt` to `summary` generates + `DROP COLUMN "excerpt"` and `ADD COLUMN "summary"` - **the data is gone**. + + +The safe path is the same one you would use for a hand-written table: + +1. Add the new field, keep the old one. +2. Generate and run that migration. +3. Backfill with a hand-written migration. +4. Remove the old field in a later release. + +Other destructive changes to watch: lowering a `text` field's `maxLength` can +fail on existing rows, and flipping `number.integer` changes the column type. + +Rollback works the way it does everywhere else in VitNode: there are no down +migrations, so you restore from a backup or write a forward migration. + +## Row level security + +Generated tables get `.enableRLS()` with no policies, matching all 22 core +tables. The application connects as the table owner, which bypasses non-forced +RLS, so this changes no behaviour today - it just means a content table is never +the loose one if policies arrive later. + +## Dropping down to Drizzle + +The model exposes everything the generated code uses, so nothing is a dead end: + +```ts +import { eq } from "drizzle-orm"; + +import { articleContent, example_articles } from "@/database/articles"; + +const rows = await c + .get("db") + .select() + .from(example_articles) + .where(eq(articleContent.columns.status, "published")); +``` diff --git a/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx b/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx new file mode 100644 index 000000000..1a2c288d6 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx @@ -0,0 +1,227 @@ +--- +title: Defining a content type +description: The three files a content type needs, why they are separate, and how to register it with the API and the AdminCP. +icon: FilePlus2 +--- + +A content type is three small files. They are separate on purpose - see +[the boundary](#why-three-files) below. + + + + +### Declare the content type + +This file is imported by both the AdminCP and the API, so it must stay free of +Drizzle, Hono and React. Only `zod` and plain objects. + +```ts title="src/content/article.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "./category"; + +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + views: field.number({ integer: true, min: 0, defaultValue: 0 }), + featured: field.boolean({ defaultValue: false }), + status: field.enum({ + values: ["draft", "published", "archived"], + defaultValue: "draft", + }), + publishedAt: field.dateTime({ nullable: true }), + author: field.user({ nullable: true, onDelete: "set null" }), + category: field.relation({ + required: true, + onDelete: "restrict", + target: () => categoryContentType, + }), + }, + + indexes: [{ on: ["status", "createdAt"] }], + + admin: { + label: { plural: "Articles", singular: "Article" }, + titleField: "title", + list: { + columns: ["title", "status", "category", "author", "updatedAt"], + searchableFields: ["title", "excerpt"], + orderableFields: ["title", "status"], + defaultOrderBy: "updatedAt", + defaultOrder: "desc", + }, + }, +}); +``` + +`id` is `plugin.entity`, lowercase and dot-separated. It becomes the AdminCP +URL (`/admin/content/example/article`) and the event names, so pick it once and +leave it alone. + + + +### Build the table + +This is the file Drizzle Kit reads. It must live directly in `src/database/` +(the glob is flat, not recursive). + +```ts title="src/database/articles.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { articleContentType } from "@/content/article"; + +import { example_categories } from "./categories"; + +export const articleContent = createContentModel(articleContentType, { + references: { category: () => example_categories.id }, +}); + +export const example_articles = articleContent.table; +``` + +`references` needs exactly one thunk per `relation` field - a missing or extra +key is a compile error. `user` fields need no entry; they always point at +`core_users`. The thunk is what keeps two content types that reference each +other from deadlocking on imports. + + + Drizzle Kit finds tables by looking at what a module exports. If you keep the + table on the model and never export it, no migration is generated for it. + + + + +### Register it + +On the API side, nest the generated module inside your plugin's own `admin` +module: + +```ts title="src/api/modules/admin/admin.module.ts" +import { buildModule } from "@vitnode/core/api/lib/module"; +import { buildContentAdminModule } from "@vitnode/core/content/server"; + +import { articleContent } from "@/database/articles"; +import { CONFIG_PLUGIN } from "@/const"; + +export const adminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "admin", + routes: [], + modules: [ + buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent], + }), + ], +}); +``` + +```ts title="src/config.api.ts" +export const exampleApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [adminModule], + }); +``` + +There is no `contentTypes` on `buildApiPlugin`: it walks the module tree, so +the content types you listed above already drive the registry **and** the +derived staff permissions. Declare once. + +On the frontend side, register the definition to get the AdminCP screen, the +nav item and the breadcrumb: + +```tsx title="src/config.tsx" +import { buildPlugin, contentTypeAdmin } from "@vitnode/core/lib/plugin"; +import { NotebookPenIcon } from "lucide-react"; + +import { articleContentType } from "@/content/article"; + +export const examplePlugin = () => + buildPlugin({ + pluginId: "@vitnode/example", + messages, + contentTypes: [ + contentTypeAdmin({ + definition: articleContentType, + icon: , + }), + ], + }); +``` + + + +### Generate the migration + + + +```bash tab="bun" +bun run build:plugins && bun run db:migrate +``` + +```bash tab="pnpm" +pnpm build:plugins && pnpm db:migrate +``` + +```bash tab="npm" +npm run build:plugins && npm run db:migrate +``` + + + +The build step matters: Drizzle Kit reads your plugin's **compiled** +`dist/src/database/*.js`, not its source. + + + + +## Why three files + +The AdminCP screen is a server component, but the create/edit form is a client +one - and `src/database/articles.ts` is executed by Drizzle Kit during +migration generation. One file cannot be all three: + +| File | Imported by | May import | +| --- | --- | --- | +| `src/content/*.ts` | everything | `zod`, plain objects | +| `src/database/*.ts` | the API, Drizzle Kit | Drizzle, the definition | +| `src/config.tsx` | Next.js (server) | React, the definition | + +Keeping the definition in the first row is what lets the AdminCP and the API +share one object. Put a Drizzle import in there and you drag the whole ORM into +the browser bundle. + + + The `server-only` package throws when it is loaded outside a React Server + Component - and both `apps/api` and `drizzle-kit` are plain Node. Use the + directory convention instead. + + +## i18n + +Every label falls back to something readable, so translations are optional. When +you want them, they live under your plugin's namespace: + +```json title="src/locales/en.json" +{ + "@vitnode/example": { + "content": { + "article": { + "title": "Articles", + "desc": "Everything the Content Engine generates, from one definition.", + "fields": { "title": "Title", "publishedAt": "Published at" }, + "enums": { "status": { "draft": "Draft", "published": "Published" } } + } + } + } +} +``` + +The key is `{pluginId}.content.{entity}` where `entity` is your content type id +with the plugin segment removed. Without it, `publishedAt` still renders as +"Published at" - the engine humanises field names. diff --git a/apps/docs/content/docs/dev/content-engine/events.mdx b/apps/docs/content/docs/dev/content-engine/events.mdx new file mode 100644 index 000000000..e3b96243b --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/events.mdx @@ -0,0 +1,92 @@ +--- +title: Generated events +description: Three typed events per content type, emitted only after a successful write. +icon: Radio +--- + +Every content type emits three events, with literal names derived from its id: + +```text +content.example.article.created +content.example.article.updated +content.example.article.deleted +``` + +Payloads stay minimal - the [envelope](/docs/dev/events) already carries the +actor, the emitting plugin and the timestamp: + +```ts +type Created = { contentId: number }; +type Updated = { changedFields: string[]; contentId: number }; +type Deleted = { contentId: number }; +``` + +## Registering the types + +One `declare module` block per plugin adds them to the global event map, using +the same module augmentation every other VitNode event uses: + +```ts title="src/api/lib/events.ts" +import type { ContentEventsFor } from "@vitnode/core/content"; + +import type { articleContentType } from "@/content/article"; +import type { categoryContentType } from "@/content/category"; + +declare module "@vitnode/core/api/models/events" { + interface VitNodeEvents + extends ContentEventsFor, + ContentEventsFor {} +} + +export {}; +``` + +Import that file once from your `config.api.ts` so the augmentation is loaded. + +The types are exact, not approximate: + +```ts +await c.get("events").emit("content.example.article.updated", { + contentId: 7, + changedFields: ["title"], // "title" | "status" | "views" | ... +}); + +await c.get("events").emit("content.example.article.updated", { + contentId: 7, + changedFields: ["slug"], // compile error: not a field on this content type +}); +``` + +## Listening + +Exactly like any other event listener: + +```ts title="src/api/lib/events.ts" +export const reindexArticleListener = buildEventListener({ + event: "content.example.article.updated", + name: "reindex-article", + description: "Refresh the search index when an article changes", + handler: async (c, payload) => { + if (!payload.changedFields.includes("title")) return; + await c.get("search").index(/* ... */); + }, +}); +``` + +Register it on a **top-level** module - listeners are only collected from those, +unlike content types. + +## When they fire + + + Events are emitted once the database write has returned. A create that fails + validation, a delete blocked by a foreign key, and an update that changed + nothing all emit nothing at all. + + +That last one is worth repeating: `PUT` with values identical to what is already +stored skips both the write and the event. `changedFields` never contains a +field that did not move. + +Delivery semantics are the platform's, not the engine's: in-process by default, +per-listener error isolation, no outbox. See [Events](/docs/dev/events). diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx new file mode 100644 index 000000000..a1e7187c2 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -0,0 +1,143 @@ +--- +title: Supported fields +description: The eight field kinds, and exactly what each one becomes in Postgres, in the API and in the AdminCP. +icon: ListChecks +--- + +Every field is built with `field.*`, and every one of them turns into four +things: a column, a Zod rule, an AdminCP input and a table cell. + +| Field | Column | API value | AdminCP input | Sortable | Filterable | Searchable | +| --- | --- | --- | --- | :-: | :-: | :-: | +| `text` | `varchar(maxLength ?? 255)` | `string` | `AutoFormInput` | ✓ | ✓ | ✓ | +| `textarea` | `text` | `string` | `AutoFormTextarea` | ✓ | ✗ | ✓ | +| `number` | `integer` or `double precision` | `number` | number input | ✓ | ✓ | ✗ | +| `boolean` | `boolean` | `boolean` | `AutoFormSwitch` | ✓ | ✓ | ✗ | +| `enum` | `varchar(length ?? 64)` | literal union | select or radio | ✓ | ✓ | ✗ | +| `dateTime` | `timestamp` | ISO string | `AutoFormDateTime` | ✓ | ✗ | ✗ | +| `user` | `integer` → `core_users.id` | `number` | async combobox | ✓ | ✓ | ✗ | +| `relation` | `integer` → target `id` | `number` | async combobox | ✓ | ✓ | ✗ | + +"Sortable" means the column *can* be allowlisted in `admin.list.orderableFields` +- nothing is orderable until you list it. System columns always are. + +## required, nullable and defaults + +Three independent switches, and it is worth being precise about them: + +- **`required: true`** - must be present in a create payload. +- **`nullable: true`** - the column accepts `NULL`, and `null` is a legal value. +- **`defaultValue`** - becomes both the Postgres column default and the Zod + default, so the API and the database can never disagree. + +A field that is none of the three has no way to be written, so +`defineContentType` rejects it: + +```ts +// Error: neither required nor nullable, so it needs a default value +title: field.text(); + +// All fine +title: field.text({ required: true }); +excerpt: field.textarea({ nullable: true }); +views: field.number({ integer: true, defaultValue: 0 }); +``` + + + `PUT { "title": "New" }` changes the title and nothing else. Defaults belong + to create; a partial update that silently reset `status` to `"draft"` would be + a nasty surprise. + + +## text and textarea + +Same storage family, different intent. `text` is a bounded `varchar` and gets a +single-line input; `textarea` is unbounded `text` and gets a multi-line one. +Only these two may appear in `searchableFields`. + +```ts +title: field.text({ required: true, minLength: 3, maxLength: 200 }), +excerpt: field.textarea({ maxLength: 500, nullable: true }), +``` + +## number + +`integer` is required - there is no sensible default, and guessing would decide +your column type for you. + +```ts +views: field.number({ integer: true, min: 0, defaultValue: 0 }), // integer +score: field.number({ integer: false, required: true }), // double precision +``` + +A nullable number renders with a "no value" toggle +(`AutoFormNullableNumber`) rather than an empty box. + +## enum + +Values are stored as `varchar`, matching the rest of VitNode - no `pgEnum`, so +adding a value is a code change and not a migration. + +```ts +status: field.enum({ + values: ["draft", "published", "archived"], + defaultValue: "draft", + display: "radio", // default is a select +}), +``` + +The values narrow all the way through: `ContentSelect["status"]` +is `"draft" | "published" | "archived"`, not `string`. Values longer than the +column length (64 by default) are rejected at definition time - raise `length` +if you need more. + +## dateTime + +Stored as `timestamp` without a time zone, matching every other VitNode table. + + + A `dateTime` crosses the API and the form as an **ISO 8601 string**, and comes + back from `select` as a `Date`. This is not stylistic: `AutoForm` runs + `z.toJSONSchema` on every schema and Zod v4 throws on `z.date()`. + + +```ts +publishedAt: field.dateTime({ nullable: true }), +seenAt: field.dateTime({ defaultNow: true }), +``` + +## user + +A foreign key to the core users table, with an async picker backed by the +content type's own `can_view` - so an editor does not need permission on the +whole user list to attribute an article. + +```ts +author: field.user({ nullable: true, onDelete: "set null" }), +``` + +## relation + +The owning side of a many-to-one. The target is a thunk so two content types can +reference each other. + +```ts +category: field.relation({ + required: true, + onDelete: "restrict", + target: () => categoryContentType, +}), +``` + +The picker's labels come from the target's `admin.titleField`, resolved with a +single `LEFT JOIN` on the list query - never one query per row. + +`onDelete: "restrict"` means deleting a category that still has articles returns +**409** with a generic message, not a 500 and not a stack trace. + +## Adding a field kind later + +The descriptor union plus one case in each of the six mappers - column, select +schema, input schema, form schema, AdminCP input, table cell. Nothing else in +the engine needs to change, which is the whole reason the descriptors are plain +data. diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx new file mode 100644 index 000000000..fa4cbba39 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -0,0 +1,101 @@ +--- +title: Content Engine +description: Declare a content type once in TypeScript and get a real Postgres table, Zod schemas, a typed service, CRUD routes, staff permissions, an AdminCP screen and typed events. +icon: Boxes +--- + +Every plugin that stores structured data ends up writing the same things: a +Drizzle table, a handful of Zod schemas, five routes, a permission block, a +DataTable, an AutoForm dialog, a delete confirmation and a few events. It is a +lot of typing for not much thinking. + +The Content Engine writes all of it from one declaration. + +```ts title="src/content/article.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + status: field.enum({ values: ["draft", "published"], defaultValue: "draft" }), + publishedAt: field.dateTime({ nullable: true }), + author: field.user({ nullable: true, onDelete: "set null" }), + }, + admin: { + label: { plural: "Articles", singular: "Article" }, + }, +}); +``` + +That gives you: + +- a dedicated `example_articles` table with a `serial` primary key, timestamps + and RLS, migrated by the normal `drizzle-kit` flow +- Zod schemas for create, update, select, filters, ordering and the form +- a typed service (`findById`, `findMany`, `create`, `update`, `delete`) +- five CRUD routes plus a relation-picker route, each behind a staff permission +- an AdminCP list with search, pagination, sorting, and create/edit/delete + dialogs - with **no Next.js file to write** +- `can_view` / `can_create` / `can_edit` / `can_delete` in the staff editor +- `content.example.article.created` / `.updated` / `.deleted` events + +## What it is not + + + Content types live in TypeScript and are migrated from source control. There + is no admin UI for creating one, no JSONB blob, and no runtime `CREATE TABLE`. + One content type is one real Postgres table, and you can always drop down to + Drizzle and write the query yourself. + + +## How the pieces fit + +```text + src/content/article.ts defineContentType(...) ← client-safe + │ zod + plain objects + ├──────────────┬──────────────────────────────┐ + ▼ ▼ ▼ + src/config.tsx src/database/articles.ts src/api/.../admin.module.ts + buildPlugin createContentModel(...) buildContentAdminModule(...) + (AdminCP) ├─ .table → migrations └─ 6 routes + permissions + ├─ .columns + └─ .service(c) +``` + +The definition sits in the middle because it is *client-safe by construction*: +it imports nothing but `zod`. That is what lets the AdminCP and the API share +one object instead of two that drift apart. + +## Getting started + + + + + + + + +## A complete example + +The `@vitnode/example` plugin in the VitNode repository is a working reference: +two content types, every field kind, generated routes, generated AdminCP and +typed events - in about 120 lines of plugin code. Every page in this section +quotes from it. diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx new file mode 100644 index 000000000..164ed00c9 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -0,0 +1,55 @@ +--- +title: Limitations +description: What the first version of the Content Engine deliberately leaves out, and what to do instead. +icon: TriangleAlert +--- + +The Content Engine covers the boring 80% of a CRUD feature. This page is the +other 20%, so you find out here rather than halfway through building. + +## Not in this version + +| Not supported | Do this instead | +| --- | --- | +| One-to-one, many-to-many, polymorphic relations | Write the join table and the queries by hand | +| Localised content fields | Use `core_languages_words` directly, as the blog plugin does | +| Rich text, media and file fields | Hand-build the field, or store an id and resolve it yourself | +| Revisions and drafts | An `enum` status field covers simple cases | +| Record-level ownership ("edit your own") | Check the author in a custom route | +| Field-level permissions | Split the content type, or write the route | +| Bulk actions | Add a custom admin route | +| Public frontend routes | The generated API is AdminCP-only, by design | +| Search indexing | Register a `SearchIndexer` yourself | +| Automatic field renames | See below | + +None of these are blocked - they are simply not generated. The service, the +schemas and the table are all public, so a hand-written route sits next to a +generated one without friction. + +## Renaming a field drops the column + + + Changing `excerpt` to `summary` generates a `DROP COLUMN` and an `ADD COLUMN`. + Add the new field, backfill with a hand-written migration, then remove the old + one in a later release. + + +## Public API is AdminCP-only + +Every generated route sits under `/admin/` and requires a staff permission. +There is no public read endpoint, and adding one is your call - fetch through +the service from your own route so you control caching and visibility. + +## Content types are code + +There is no admin UI for creating a content type, and there never will be one in +this shape: the definitions are TypeScript and the tables are migrated from +source control. That is the trade the engine makes - you give up runtime +flexibility and get real columns, real foreign keys, real indexes and reviewable +migrations. + +## Delivery guarantees + +Content events are emitted after a successful write, in-process, with no outbox +and no retries. If a listener must not be missed, do the work in the same +request or push it onto the [queue](/docs/dev/advanced/queue). diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json new file mode 100644 index 000000000..818cb2c6f --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -0,0 +1,18 @@ +{ + "title": "Content Engine", + "description": "Declare a content type once, get the table, API, AdminCP, permissions and events", + "icon": "Boxes", + "pages": [ + "index", + "defining-a-content-type", + "fields", + "database-and-migrations", + "schemas", + "service", + "admincp", + "permissions", + "events", + "overriding-admincp", + "limitations" + ] +} diff --git a/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx new file mode 100644 index 000000000..7b78e7811 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx @@ -0,0 +1,104 @@ +--- +title: Overriding the AdminCP +description: Replace one field input or one table cell without giving up the generated screen. +icon: Paintbrush +--- + +The generated screen is a starting point, not a cage. Two escape hatches cover +most of what plugins actually want, and both are per-field - you never have to +take over the whole page to change one column. + +## Overriding a table cell + +```tsx title="src/views/admin/articles/status-cell.tsx" +"use client"; + +import type { ContentCellProps } from "@vitnode/core/lib/plugin"; + +import { Badge } from "@vitnode/core/components/ui/badge"; + +import type { articleContentType } from "@/content/article"; + +export const StatusCell = ({ + row, +}: ContentCellProps) => ( + + {row.status} + +); +``` + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: articleContentType, + columns: { status: { cell: StatusCell } }, +}); +``` + +`row` is typed as the content type's own select row, so `row.status` narrows to +`"draft" | "published" | "archived"`. + +## Overriding a form field + +```tsx title="src/views/admin/articles/excerpt-field.tsx" +"use client"; + +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"; + +export const ExcerptField = (props: ItemAutoFormComponentProps) => ( + +); +``` + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: articleContentType, + fields: { excerpt: { component: ExcerptField } }, +}); +``` + +The override receives the same props the generated input would, so the field +stays wired into `AutoForm`'s validation and error display. + + + `config.tsx` is a server module, so an inline arrow written there is a server + closure and cannot be handed to the client form. Put the component in its own + `"use client"` file and reference it, as above - that makes it a client + reference, which passes across the boundary fine. + + + + Component references cannot go on the definition: `src/database/*.ts` imports + it, and Drizzle Kit executes that file during migration generation. Keeping + overrides in `config.tsx` keeps React out of the migration path. + + +## Narrowing what appears + +Before reaching for a component override, check whether the definition already +says what you mean: + +```ts +admin: { + list: { + columns: ["title", "status", "updatedAt"], // hide the rest + orderableFields: ["title"], + }, + form: { + fields: ["title", "excerpt"], // views and featured stay API-only + }, + navigation: { enabled: false }, // no sidebar entry +} +``` + +A field left out of `admin.form.fields` is still a real column with a real API - +it just does not appear in the dialog. + +## When you want your own page + +Nothing stops you. Build a normal admin page in `src/routes/admin/**`, and use +the service and schemas directly - they are the same ones the generated screen +uses. Set `navigation.enabled: false` so the generated nav item does not compete +with yours. diff --git a/apps/docs/content/docs/dev/content-engine/permissions.mdx b/apps/docs/content/docs/dev/content-engine/permissions.mdx new file mode 100644 index 000000000..7d9d893eb --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/permissions.mdx @@ -0,0 +1,86 @@ +--- +title: Generated permissions +description: The four staff permissions every content type gets, how the module name is derived, and how to override them. +icon: Lock +--- + +Registering a content type registers four admin permissions with it. No +`permissionStaff` block to write, and no route left ungated. + +```text +can_view list, detail and the relation pickers +can_create POST +can_edit PUT +can_delete DELETE +``` + +The three write permissions depend on `can_view`, so a role cannot be given the +ability to create rows it is not allowed to see. + +## The module name + +Derived from `admin.label.plural`, slugified: "Example Articles" becomes +`example_articles`. The full permission key follows the usual VitNode shape: + +```text +{pluginId}:{module}:{permission} +@vitnode/example:example_articles:can_edit +``` + +Set it explicitly when the derived name is not what you want: + +```ts +admin: { + label: { plural: "Knowledge Base Articles", singular: "Article" }, + permissionModule: "kb_articles", +} +``` + +Two content types in the same plugin that derive the same module name are a boot +error naming both, so a collision can never quietly merge two permission sets. +Different plugins are already scoped by `pluginId` and cannot collide. + +## Overriding the set + +Declare the module yourself and the engine leaves it alone: + +```ts +buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [adminModule], + permissionStaff: { + admin: { + // Read-only: no create, edit or delete permission exists at all + example_articles: ["can_view"], + }, + }, +}); +``` + +## Labels + +Permissions show up in the staff editor with whatever labels you provide, under +the flat key convention: + +```json title="src/locales/en.json" +{ + "@vitnode/example:example_articles": "Articles", + "@vitnode/example:example_articles:can_view": "View articles", + "@vitnode/example:example_articles:can_create": "Create articles", + "@vitnode/example:example_articles:can_edit": "Edit articles", + "@vitnode/example:example_articles:can_delete": "Delete articles" +} +``` + +## Where they are enforced + +Three places, and the first one is the one that counts: + +1. **Every generated route** carries an explicit `adminStaffPermission`, checked + 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`. + +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/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx new file mode 100644 index 000000000..e5028fa65 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx @@ -0,0 +1,97 @@ +--- +title: Generated schemas +description: The seven Zod schemas every content type exposes, and the rules they enforce. +icon: ShieldCheck +--- + +Every definition carries `schemas`, generated from the field descriptors. They +are the same objects the routes and the AdminCP use, so validating against them +yourself gives identical behaviour. + +```ts +articleContentType.schemas.create; // request body for create +articleContentType.schemas.update; // request body for update +articleContentType.schemas.select; // API response +articleContentType.schemas.selectObject; // the same, as an extendable ZodObject +articleContentType.schemas.filters; // query-string filters +articleContentType.schemas.order; // orderBy allowlist + direction +articleContentType.schemas.params; // { id } +articleContentType.schemas.form; // AutoForm-safe variant +``` + +## create + +Built from the fields, with `strictObject` so an unknown key is an **error** +rather than something quietly dropped: + +```ts +schemas.create.parse({ title: "Hello", category: 1 }); +// → { title: "Hello", category: 1, status: "draft", views: 0, featured: false } + +schemas.create.parse({ title: "Hello", category: 1, slug: "x" }); // throws +schemas.create.parse({ title: "Hello", category: 1, id: 99 }); // throws +``` + +System columns are absent from the shape, so `id`, `createdAt` and `updatedAt` +can never be set from a request. Declared defaults are applied here, which is +what keeps Zod and the column default in step. + +## update + +Every field optional, still strict, and **never** re-applies create defaults: + +```ts +schemas.update.parse({ title: "New" }); // → { title: "New" } and nothing else +schemas.update.parse({}); // throws: at least one field required +``` + +## select + +Describes the response, including `id`, `createdAt` and `updatedAt`. Dates are +`z.date()` here - Hono serialises them to ISO strings on the wire, and +`DateFormat` accepts either. + +`selectObject` is the same schema left as a `ZodObject`, which is what the list +route extends with the joined relation labels. + +## filters and order + +Both are allowlists derived from the definition, and both exist to keep request +strings away from SQL identifiers. + +`filters` only contains equality-filterable fields (everything except +`textarea` and `dateTime`), parsed from their query-string form. `order` is a +literal enum: + +```ts +schemas.order.parse({ orderBy: "title" }); // ok, it is in orderableFields +schemas.order.parse({ orderBy: "views" }); // throws +schemas.order.parse({ orderBy: "id) --" }); // throws +``` + +An out-of-allowlist `orderBy` is a 400 at the route boundary, it shows up in the +OpenAPI document, and the service checks it again before touching a column. + +## form + +The AdminCP variant. Identical rules, with one deliberate difference: it never +contains `z.date()`. + + + `AutoForm` derives its defaults and constraints by running `z.toJSONSchema` on + whatever schema you hand it, and Zod v4 throws on `z.date()`. The form variant + uses ISO strings so date fields work at all. + + +The client rebuilds this schema from a serialisable spec rather than importing +the definition, since a definition holds `target` thunks and cannot cross the +server/client boundary. `buildFormSchemaFromSpec` also folds an existing row in +as Zod defaults, which is how the edit dialog prefills. + +## Why not drizzle-zod + +A Drizzle column cannot tell you whether a `varchar` should render as a +single-line input or a textarea, cannot carry an enum's literal tuple through +`varchar({ enum })`, and cannot express `min`/`max` on a number. The field +descriptors know all three, so they generate both the column *and* the schema +and the two cannot drift. diff --git a/apps/docs/content/docs/dev/content-engine/service.mdx b/apps/docs/content/docs/dev/content-engine/service.mdx new file mode 100644 index 000000000..722a276bf --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/service.mdx @@ -0,0 +1,116 @@ +--- +title: Service API +description: The typed repository every content type exposes, bound to the request's database handle. +icon: Wrench +--- + +`model.service(c)` returns a small typed repository for one content type. It is +deliberately thin: it owns column allowlisting, pagination and relation label +joins, and hands everything else to Drizzle. + +```ts +const articles = articleContent.service(c); +``` + +## findById + +```ts +const article = await articles.findById(7); +if (!article) throw new HTTPException(404); + +article.status; // "draft" | "published" | "archived" +``` + +Missing rows are `null`, never a throw - the generated routes turn that into a +404, and your own code can decide differently. + +## findMany + +```ts +const { edges, pageInfo } = await articles.findMany({ + filters: { status: "published" }, + orderBy: { column: "updatedAt", order: "desc" }, + query: c.req.valid("query"), // cursor / first / last / search +}); +``` + +Built on the same [`withPagination`](/docs/dev/database/pagination) helper the +rest of VitNode uses, so cursors, page caps and `pageInfo` behave identically. + +Each row carries a `labels` object with the display text for every `user` and +`relation` field, resolved by one `LEFT JOIN` per reference field: + +```ts +edges[0].labels; // { author: "Ada Lovelace", category: "News" } +``` + +Filter and order keys are looked up in the model's column map. An unknown key +throws a `ContentEngineError` rather than reaching a query - a request string +never becomes a SQL identifier. + +## create + +```ts +const article = await articles.create({ + title: "Hello world", + category: 1, + publishedAt: "2026-08-02T10:00:00.000Z", // ISO in, Date in the column +}); +``` + +## update + +```ts +const result = await articles.update(7, { title: "Updated" }); +if (!result) throw new HTTPException(404); + +result.changedFields; // ("title" | "status" | ...)[] +``` + +`update` loads the row, diffs it against your patch, and writes only what +actually moved. If nothing changed it skips the write entirely, so `updatedAt` +stays honest and no spurious `content.*.updated` event fires. Dates are compared +by instant, not identity. + +## delete + +```ts +const deleted = await articles.delete(7); +if (!deleted) throw new HTTPException(404); +``` + +Returns the deleted row, or `null` if there was nothing to delete. A row still +referenced by a `restrict` foreign key raises a Postgres error the generated +route maps to 409. + +## options + +Backs the relation and user pickers, capped and search-filtered: + +```ts +await articles.options("category", "ne"); +// → [{ label: "News", value: 3 }] +``` + +## Transactions + +Every write takes an optional transaction handle, so a content write can join a +larger unit of work: + +```ts +await c.get("db").transaction(async tx => { + const article = await articles.create(values, { tx }); + await tx.insert(audit_log).values({ articleId: article.id }); +}); + +// Emit after the transaction returns - never inside it. +await c.get("events").emit("content.example.article.created", { + contentId: article.id, +}); +``` + +## Escape hatches + +The service is not a wall. `model.table` and `model.columns` are public, and +`c.get("db")` is right there - drop to Drizzle whenever the generated query is +not the query you want. diff --git a/apps/docs/content/docs/dev/database/index.mdx b/apps/docs/content/docs/dev/database/index.mdx index 75bcdb1c4..d6e3a8cd2 100644 --- a/apps/docs/content/docs/dev/database/index.mdx +++ b/apps/docs/content/docs/dev/database/index.mdx @@ -10,20 +10,28 @@ VitNode plugins seamlessly integrate with databases using [Drizzle ORM](https:// Create your database schema in the `database` directory of your plugin. Each table should be defined in its own file for better organization. ```ts title="plugins/{plugin_name}/src/database/categories.ts" -import { pgTable, serial, timestamp } from "drizzle-orm/pg-core"; +import { pgTable } from "drizzle-orm/pg-core"; -export const blog_categories = pgTable("blog_categories", { - id: serial().primaryKey(), - createdAt: timestamp().notNull().defaultNow(), - updatedAt: timestamp() +export const blog_categories = pgTable("blog_categories", t => ({ + id: t.serial().primaryKey(), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() .notNull() - .$onUpdate(() => new Date()) -}); + .defaultNow() + .$onUpdate(() => new Date()), +})).enableRLS(); ``` + + Hand-writing the table, its schemas, its routes and its AdminCP screen is a + lot of repetition. The [Content Engine](/docs/dev/content-engine) generates all + of it from one declaration - and produces exactly this kind of table. + + ## Accessing Database -Access the database in your plugin handlers using `c.get('database')` from the Hono context. This provides a Drizzle ORM instance for all your database operations. +Access the database in your plugin handlers using `c.get("db")` from the Hono context. This provides a Drizzle ORM instance for all your database operations. ```ts title="plugins/{plugin_name}/src/routes/posts.ts" export const postsRoute = buildRoute({ @@ -32,7 +40,7 @@ export const postsRoute = buildRoute({ handler: async (c) => { // [!code ++:7] const data = await c - .get("database") + .get("db") .select({ id: blog_posts.id, categoryId: blog_posts.categoryId 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 09a4df9ab..be4044388 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -230,6 +230,45 @@ were removed with it. search index - a good template for cleaning up any data your plugin keys by post id. +## Content Engine events + +Every content type declared with the +[Content Engine](/docs/dev/content-engine) emits three events, named after its +id. For `example.article` that means: + +```text +content.example.article.created +content.example.article.updated +content.example.article.deleted +``` + +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. + + + +The envelope already carries the actor, the emitting plugin and the timestamp, +so the payloads stay minimal. Events fire only after the database write has +returned: a failed validation, a delete blocked by a foreign key, and a no-op +update all emit nothing. + +**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). + ## Deliberately not emitted (yet) High-frequency or consumer-less events are added only when a listener needs diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index b4dbb3272..47d11afc0 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -12,6 +12,7 @@ "---Framework---", "plugins", "database", + "content-engine", "fetcher", "working-with-users", "i18n", diff --git a/apps/docs/migrations/0022_add_example_content.sql b/apps/docs/migrations/0022_add_example_content.sql new file mode 100644 index 000000000..79c393736 --- /dev/null +++ b/apps/docs/migrations/0022_add_example_content.sql @@ -0,0 +1,32 @@ +CREATE TABLE "example_articles" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "excerpt" text, + "views" integer DEFAULT 0 NOT NULL, + "featured" boolean DEFAULT false NOT NULL, + "status" varchar(64) DEFAULT 'draft' NOT NULL, + "publishedAt" timestamp, + "author" integer, + "category" integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE "example_articles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_categories" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "name" varchar(100) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "example_categories" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "example_articles" ADD CONSTRAINT "example_articles_author_core_users_id_fk" FOREIGN KEY ("author") REFERENCES "public"."core_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_articles" ADD CONSTRAINT "example_articles_category_example_categories_id_fk" FOREIGN KEY ("category") REFERENCES "public"."example_categories"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "example_articles_created_at_idx" ON "example_articles" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "example_articles_updated_at_idx" ON "example_articles" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "example_articles_author_idx" ON "example_articles" USING btree ("author");--> statement-breakpoint +CREATE INDEX "example_articles_category_idx" ON "example_articles" USING btree ("category");--> statement-breakpoint +CREATE INDEX "example_articles_status_createdat_idx" ON "example_articles" USING btree ("status","createdAt");--> statement-breakpoint +CREATE INDEX "example_categories_created_at_idx" ON "example_categories" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "example_categories_updated_at_idx" ON "example_categories" USING btree ("updatedAt"); \ No newline at end of file diff --git a/apps/docs/migrations/meta/0022_snapshot.json b/apps/docs/migrations/meta/0022_snapshot.json new file mode 100644 index 000000000..89755ebff --- /dev/null +++ b/apps/docs/migrations/meta/0022_snapshot.json @@ -0,0 +1,2472 @@ +{ + "id": "9b3e7022-4e17-476d-879c-afdd70659848", + "prevId": "bce1e602-a192-4c9c-b6d5-8be955f985d9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "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 + }, + "status": { + "name": "status", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "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_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_status_createdat_idx": { + "name": "example_articles_status_createdat_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": {} + } + }, + "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 6838150d7..8fe0008e0 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1785581778726, "tag": "0021_add_admin_dashboard", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1785696759125, + "tag": "0022_add_example_content", + "breakpoints": true } ] } diff --git a/apps/docs/package.json b/apps/docs/package.json index 28a4af6e7..dac4c657c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -30,6 +30,7 @@ "@hono/zod-openapi": "^1.5.1", "@hono/zod-validator": "^0.9.0", "@vitnode/blog": "workspace:*", + "@vitnode/example": "workspace:*", "@vitnode/core": "workspace:*", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx new file mode 100644 index 000000000..1bc9d2102 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next/dist/types"; + +import { + ContentAdminView, + type ContentAdminViewProps, + getContentLabels, + resolveContentType, +} from "@vitnode/core/views/admin/views/content/content-admin-view"; + +export const generateMetadata = async ({ + params, +}: ContentAdminViewProps): Promise => { + const entry = await resolveContentType(params); + if (!entry) return {}; + + const labels = await getContentLabels(entry); + + return { description: labels.desc, title: labels.title }; +}; + +/** + * One route serves every registered content type - the slug maps onto a content + * type id (`/admin/content/example/article` -> `example.article`), so a plugin + * adds a content type without adding a single Next.js file. + */ +export default function ContentAdminPage(props: ContentAdminViewProps) { + return ; +} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx new file mode 100644 index 000000000..23c72b508 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx @@ -0,0 +1,22 @@ +import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; +import { + getContentLabels, + resolveContentType, +} from "@vitnode/core/views/admin/views/content/content-admin-view"; + +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ slug: string[] }>; +}) { + const { slug } = await params; + const entry = await resolveContentType(params); + const labels = entry ? await getContentLabels(entry) : undefined; + + return ( + + ); +} diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts index a146553c9..0b30a6682 100644 --- a/apps/docs/src/vitnode.api.config.ts +++ b/apps/docs/src/vitnode.api.config.ts @@ -1,4 +1,5 @@ import { blogApiPlugin } from "@vitnode/blog/config.api"; +import { exampleApiPlugin } from "@vitnode/example/config.api"; import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord"; // import { ResendEmailAdapter } from "@vitnode/resend"; import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook"; @@ -29,7 +30,7 @@ export const vitNodeApiConfig = buildApiConfig({ title: "VitNode API", shortTitle: "VitNode", }, - plugins: [blogApiPlugin()], + plugins: [blogApiPlugin(), exampleApiPlugin()], dbProvider: drizzle({ connection: POSTGRES_URL, casing: "camelCase", diff --git a/apps/docs/src/vitnode.config.ts b/apps/docs/src/vitnode.config.ts index 40b227c47..0a4d41ed1 100644 --- a/apps/docs/src/vitnode.config.ts +++ b/apps/docs/src/vitnode.config.ts @@ -1,4 +1,5 @@ import { blogPlugin } from "@vitnode/blog/config"; +import { examplePlugin } from "@vitnode/example/config"; import { buildConfig, handleRequestConfig } from "@vitnode/core/vitnode.config"; import { getRequestConfig } from "next-intl/server"; @@ -9,7 +10,7 @@ export const vitNodeConfig = buildConfig({ title: "VitNode", shortTitle: "VitNode", }, - plugins: [blogPlugin()], + plugins: [blogPlugin(), examplePlugin()], debug: false, i18n, theme: { diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 4c63edb60..83c54e5a9 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -81,6 +81,16 @@ "vitnode": "./cli.mjs" }, "exports": { + "./content": { + "import": "./dist/src/content/index.js", + "types": "./dist/src/content/index.d.ts", + "default": "./dist/src/content/index.js" + }, + "./content/server": { + "import": "./dist/src/content/server/index.js", + "types": "./dist/src/content/server/index.d.ts", + "default": "./dist/src/content/server/index.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", @@ -104,6 +114,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:types": "vitest run --typecheck.only", "lint": "eslint .", "lint:fix": "eslint . --fix" }, diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 1c8bcbf61..9c2f93ef3 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -1,5 +1,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; +import type { AnyContentTypeDefinition } from "@/content/types"; + import type { BuildCronReturn } from "./cron"; import type { BuildEventListenerReturn } from "./events"; import type { BuildQueueTaskReturn } from "./queue"; @@ -16,6 +18,13 @@ export interface BaseBuildModuleReturn< M extends string = string, Routes extends Route

[] = Route

[], > { + /** + * Content types whose CRUD routes this module serves. Unlike `events` and + * `cronJobs`, these are collected recursively by `buildApiPlugin`, so a + * generated content module can sit wherever it reads best in the tree - + * usually nested inside the plugin's own `admin` module. + */ + contentTypes?: AnyContentTypeDefinition[]; cronJobs: BuildCronReturn[]; events: BuildEventListenerReturn[]; hono: OpenAPIHono; @@ -46,11 +55,13 @@ export function buildModule< pluginId, name, modules, + contentTypes, cronJobs = [], events = [], queueTasks = [], webSockets = [], }: { + contentTypes?: AnyContentTypeDefinition[]; cronJobs?: BuildCronReturn[]; events?: BuildEventListenerReturn[]; modules?: Modules; @@ -80,6 +91,7 @@ export function buildModule< hono, name, modules, + contentTypes, cronJobs, events, queueTasks, diff --git a/packages/vitnode/src/api/lib/plugin.test.ts b/packages/vitnode/src/api/lib/plugin.test.ts new file mode 100644 index 000000000..72a8d9eee --- /dev/null +++ b/packages/vitnode/src/api/lib/plugin.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import { buildModule } from "./module"; +import { buildApiPlugin } from "./plugin"; + +const contentModule = buildModule({ + pluginId: "@vitnode/example", + name: "content", + routes: [], + contentTypes: [testArticleContentType, testCategoryContentType], +}); + +const adminModule = buildModule({ + pluginId: "@vitnode/example", + name: "admin", + routes: [], + modules: [contentModule], +}); + +describe("buildApiPlugin content types", () => { + it("collects content types from nested modules", () => { + const plugin = buildApiPlugin({ + pluginId: "@vitnode/example", + modules: [adminModule], + }); + + expect(plugin.contentTypes?.map(item => item.id)).toEqual([ + "test.article", + "test.category", + ]); + }); + + it("derives staff permissions for each collected content type", () => { + const plugin = buildApiPlugin({ + pluginId: "@vitnode/example", + modules: [adminModule], + }); + + expect(plugin.permissionStaff?.admin?.test_articles).toEqual([ + "can_view", + { dependsOn: ["can_view"], permission: "can_create" }, + { dependsOn: ["can_view"], permission: "can_edit" }, + { dependsOn: ["can_view"], permission: "can_delete" }, + ]); + expect(plugin.permissionStaff?.admin?.test_categories).toBeDefined(); + }); + + it("keeps hand-declared permissions and other modules intact", () => { + const plugin = buildApiPlugin({ + pluginId: "@vitnode/example", + modules: [adminModule], + permissionStaff: { + admin: { posts: ["can_view"], test_articles: ["can_view"] }, + moderator: { posts: ["can_edit"] }, + }, + }); + + expect(plugin.permissionStaff?.admin?.test_articles).toEqual(["can_view"]); + expect(plugin.permissionStaff?.admin?.posts).toEqual(["can_view"]); + expect(plugin.permissionStaff?.moderator?.posts).toEqual(["can_edit"]); + }); + + it("leaves permissionStaff untouched for a plugin with no content types", () => { + const plugin = buildApiPlugin({ + pluginId: "@vitnode/example", + modules: [], + }); + + expect(plugin.contentTypes).toEqual([]); + expect(plugin.permissionStaff).toBeUndefined(); + }); + + it("rejects two content types sharing a table inside one plugin", () => { + const duplicate = buildModule({ + pluginId: "@vitnode/example", + name: "content", + routes: [], + contentTypes: [testArticleContentType, testArticleContentType], + }); + + expect(() => + buildApiPlugin({ pluginId: "@vitnode/example", modules: [duplicate] }), + ).toThrow(/Duplicate content type id/); + }); +}); diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 24e165a69..d3abc12d5 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -1,11 +1,18 @@ import { OpenAPIHono } from "@hono/zod-openapi"; +import type { RegisteredContentType } from "@/content/registry"; +import type { AnyContentTypeDefinition } from "@/content/types"; import type { LocaleMessagesMap } from "@/lib/i18n/types"; +import { + validateContentTypes, + withContentPermissions, +} from "@/content/registry"; + import type { SearchIndexer } from "../models/search"; import type { CronJobConfig } from "./cron"; import type { EventListenerConfig } from "./events"; -import type { BuildModuleReturn } from "./module"; +import type { BaseBuildModuleReturn, BuildModuleReturn } from "./module"; import type { PermissionStaffConfig } from "./permission-staff"; import type { QueueTaskConfig } from "./queue"; import type { WebSocketConfig } from "./websocket"; @@ -13,6 +20,7 @@ import type { WebSocketConfig } from "./websocket"; import { checkPluginId } from "./check-plugin-id"; export interface BuildPluginApiReturn { + contentTypes?: AnyContentTypeDefinition[]; cronJobs?: Omit[]; events?: Omit[]; hono: OpenAPIHono; @@ -47,6 +55,7 @@ export function buildApiPlugin

({ checkPluginId(pluginId); const hono = new OpenAPIHono(); + const contentTypes: AnyContentTypeDefinition[] = []; const cronJobs: BuildPluginApiReturn["cronJobs"] = []; const events: BuildPluginApiReturn["events"] = []; const queueTasks: BuildPluginApiReturn["queueTasks"] = []; @@ -54,6 +63,8 @@ export function buildApiPlugin

({ modules.forEach(handler => { hono.route(`/${handler.name}`, handler.hono); + contentTypes.push(...collectContentTypes(handler)); + handler.cronJobs?.forEach(cron => { cronJobs.push({ ...cron, module: handler.name }); }); @@ -71,15 +82,37 @@ export function buildApiPlugin

({ }); }); + const registered: RegisteredContentType[] = validateContentTypes( + contentTypes.map(definition => ({ definition, pluginId })), + ); + return { pluginId, messages, hono, + contentTypes: registered.map(entry => entry.definition), cronJobs, events, queueTasks, searchIndexers, webSockets, - permissionStaff, + // Every content type contributes can_view/can_create/can_edit/can_delete + // unless the plugin declared that module itself. + permissionStaff: withContentPermissions(permissionStaff, registered), }; } + +/** + * Walks the whole module tree. Content types are collected recursively - unlike + * `events`, `cronJobs` and friends, which only come from top-level modules - so + * a generated content module can be nested inside the plugin's `admin` module + * and still register its permissions. + */ +function collectContentTypes( + module: BaseBuildModuleReturn, +): AnyContentTypeDefinition[] { + return [ + ...(module.contentTypes ?? []), + ...(module.modules ?? []).flatMap(collectContentTypes), + ]; +} diff --git a/packages/vitnode/src/api/lib/route.test.ts b/packages/vitnode/src/api/lib/route.test.ts new file mode 100644 index 000000000..e9272f91e --- /dev/null +++ b/packages/vitnode/src/api/lib/route.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment node +import type { MiddlewareHandler } from "hono"; + +import { OpenAPIHono, z } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import { buildRoute } from "./route"; + +const okResponse = { + 200: { + content: { + "application/json": { schema: z.object({ plugin: z.string() }) }, + }, + description: "ok", + }, +} as const; + +const mount = (route: ReturnType) => { + const app = new OpenAPIHono(); + app.openapi(route.route, route.handler); + + return app; +}; + +describe("buildRoute", () => { + it("keeps pluginMiddleware when the route brings its own middleware", async () => { + const marks: string[] = []; + const custom: MiddlewareHandler = async (_c, next) => { + marks.push("custom"); + await next(); + }; + + const route = buildRoute({ + pluginId: "@vitnode/test", + route: { + method: "get", + path: "/", + middleware: [custom], + responses: okResponse, + }, + handler: c => c.json({ plugin: c.get("plugin").id }, 200), + }); + + const res = await mount(route).request("/"); + + expect(res.status).toBe(200); + // Without the fix the `...route` spread replaced the composed array and + // `c.get("plugin")` was undefined. + expect(await res.json()).toEqual({ plugin: "@vitnode/test" }); + expect(marks).toEqual(["custom"]); + }); + + it("composes pluginMiddleware, the permission guard and route middleware in order", () => { + const custom: MiddlewareHandler = async (_c, next) => next(); + + const { route } = buildRoute({ + pluginId: "@vitnode/test", + adminStaffPermission: { module: "articles", permission: "can_view" }, + route: { + method: "get", + path: "/", + middleware: [custom], + responses: okResponse, + }, + handler: c => c.json({ plugin: c.get("plugin").id }, 200), + }); + + const middleware = route.middleware; + + expect(middleware).toHaveLength(3); + expect(middleware.at(-1)).toBe(custom); + }); + + it("prepends the plugin tag and keeps route tags", () => { + const { route } = buildRoute({ + pluginId: "@vitnode/test_plugin", + route: { + method: "get", + path: "/", + tags: ["Articles"], + responses: okResponse, + }, + handler: c => c.json({ plugin: c.get("plugin").id }, 200), + }); + + expect(route.tags).toEqual(["@vitnode/test Plugin", "Articles"]); + }); +}); diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts index e4e4f7830..c40ddb664 100644 --- a/packages/vitnode/src/api/lib/route.ts +++ b/packages/vitnode/src/api/lib/route.ts @@ -67,9 +67,12 @@ export const buildRoute = < return { route: createRouteHono({ + // `route` is spread first on purpose: `tags` and `middleware` already + // merge the route's own values, so letting the spread win would drop + // `pluginMiddleware` and the staff-permission guard. + ...route, tags, middleware, - ...route, }), handler: handler as Route["handler"], pluginId, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 0a6e738e3..47282cff0 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -3,6 +3,7 @@ import type { Redis } from "ioredis"; import { HTTPException } from "hono/http-exception"; +import type { RegisteredContentType } from "@/content/registry"; import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types"; import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config"; import type { VitNodeRealtime } from "@/ws/registry"; @@ -19,6 +20,7 @@ import { SearchModel } from "@/api/models/search"; 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 { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; @@ -83,6 +85,7 @@ export interface EnvVariablesVitNode { ssoAdapters: SSOApiPlugin[]; }; captcha?: Pick["captcha"]; + contentTypes: RegisteredContentType[]; cron: (BuildCronReturn & { module: string; pluginId: string })[]; cronSecret?: string; email?: VitNodeApiConfig["email"]; @@ -220,6 +223,17 @@ export const globalMiddleware = ({ })), ); + // Validated once more across *all* plugins: `buildApiPlugin` can only catch + // collisions inside a single plugin. + const contentTypesMetadata: RegisteredContentType[] = validateContentTypes( + plugins.flatMap(plugin => + (plugin.contentTypes ?? []).map(definition => ({ + definition, + pluginId: plugin.pluginId, + })), + ), + ); + const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map( plugin => ({ pluginId: plugin.pluginId, @@ -307,6 +321,7 @@ export const globalMiddleware = ({ queue: queueMetadata, webSockets: webSocketsMetadata, permissionStaff: permissionStaffMetadata, + contentTypes: contentTypesMetadata, }); const user = await new SessionModel(c).getUser(); diff --git a/packages/vitnode/src/components/form/fields/date-time.tsx b/packages/vitnode/src/components/form/fields/date-time.tsx new file mode 100644 index 000000000..c6cdb4023 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/date-time.tsx @@ -0,0 +1,71 @@ +import type React from "react"; + +import { FormControl, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; + +import { AutoFormDesc } from "../common/desc"; +import { AutoFormLabel } from "../common/label"; + +/** `2026-08-02T10:00:00.000Z` -> `2026-08-02T10:00`, what the input expects. */ +const toInputValue = (value: unknown): string => { + if (typeof value !== "string" || value === "") return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + + const pad = (part: number) => String(part).padStart(2, "0"); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +/** + * A date and time field backed by the platform's `datetime-local` input. + * + * The form value is an ISO 8601 string (or `null` for a nullable field), which + * is exactly what the generated API accepts - Zod v4 cannot turn `z.date()` + * into JSON Schema, and `AutoForm` runs `z.toJSONSchema` on every schema. + */ +export const AutoFormDateTime = ({ + label, + labelRight, + description, + field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, + otherProps: { isOptional }, + ...props +}: ItemAutoFormComponentProps & + Omit, "type" | "value">) => { + return ( + <> + {!!label && ( + + {label} + + )} + + + { + field.onBlur(); + props.onBlur?.(event); + }} + onChange={event => { + const { value } = event.target; + // An emptied input means "no value" - `null` when the field allows + // it, otherwise an empty string so validation reports it. + field.onChange(value === "" ? null : new Date(value).toISOString()); + props.onChange?.(event); + }} + type="datetime-local" + value={toInputValue(field.value)} + {...props} + /> + + + {!!description && {description}} + + + ); +}; diff --git a/packages/vitnode/src/content/admin/config.ts b/packages/vitnode/src/content/admin/config.ts new file mode 100644 index 000000000..ea3a5eca0 --- /dev/null +++ b/packages/vitnode/src/content/admin/config.ts @@ -0,0 +1,47 @@ +import type { ContentTypeFrontendRegistration } from "../../lib/plugin"; +import type { VitNodeConfig } from "../../vitnode.config"; +import type { AnyContentTypeDefinition } from "../types"; + +import { getVitNodeConfig } from "../../vitnode.config"; +import { validateContentTypes } from "../registry"; + +export interface RegisteredFrontendContentType { + definition: AnyContentTypeDefinition; + pluginId: string; + registration: ContentTypeFrontendRegistration; +} + +/** + * Every content type registered with the AdminCP, in a deterministic order. + * + * Reads the app config rather than a mutable singleton, so hot reload simply + * re-derives it. The same validation the API side runs applies here, which is + * what catches a content type registered on only one of the two sides. + */ +export const getFrontendContentTypes = ( + vitNodeConfig: VitNodeConfig = getVitNodeConfig(), +): RegisteredFrontendContentType[] => { + const entries = vitNodeConfig.plugins.flatMap(plugin => + (plugin.contentTypes ?? []).map(registration => ({ + definition: registration.definition, + pluginId: plugin.pluginId, + registration, + })), + ); + + validateContentTypes( + entries.map(({ definition, pluginId }) => ({ definition, pluginId })), + ); + + return [...entries].sort((a, b) => + a.definition.id.localeCompare(b.definition.id), + ); +}; + +export const findFrontendContentType = ( + contentTypeId: string, + vitNodeConfig?: VitNodeConfig, +): RegisteredFrontendContentType | undefined => + getFrontendContentTypes(vitNodeConfig).find( + entry => entry.definition.id === contentTypeId, + ); diff --git a/packages/vitnode/src/content/admin/fetch.server.ts b/packages/vitnode/src/content/admin/fetch.server.ts new file mode 100644 index 000000000..f7900154d --- /dev/null +++ b/packages/vitnode/src/content/admin/fetch.server.ts @@ -0,0 +1,76 @@ +import "server-only"; +import type { z } from "zod"; + +import { cookies, headers } from "next/headers"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { rawApiFetch } from "../../lib/fetcher/raw"; + +export interface ContentFetchResult { + data?: TData; + error?: string; + status: number; +} + +/** + * Calls a generated content route from a server component or server action. + * + * The generic AdminCP page does not know which plugin module it is talking to + * at compile time, so route-literal inference buys nothing here - the response + * is typed (and validated) by the content type's own Zod schema instead, which + * is stricter. Everything else - URL shape, cookie and header forwarding, error + * logging - is the same `rawApiFetch` the typed `fetcher` uses. + */ +export const contentApiFetch = async ({ + body, + definition, + method, + path = "/", + pluginId, + query, + schema, +}: { + body?: unknown; + definition: AnyContentTypeDefinition; + method: "delete" | "get" | "post" | "put"; + path?: string; + pluginId: string; + query?: Record; + schema?: TSchema; +}): Promise>> => { + const [nextHeaders, cookieStore] = await Promise.all([headers(), cookies()]); + + const response = await rawApiFetch({ + additionalHeaders: { + Cookie: cookieStore.toString(), + ["user-agent"]: nextHeaders.get("user-agent") ?? "node", + ["x-forwarded-for"]: nextHeaders.get("x-forwarded-for") ?? "0.0.0.0", + }, + body, + method, + module: `content/${definition.permissionModule}`, + path, + pluginId, + prefixPath: "/admin", + query, + }); + + if (!response.ok) { + return { error: await response.text(), 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); + if (!parsed.success) { + return { + error: "The API returned a response this content type does not describe.", + status: response.status, + }; + } + + return { data: parsed.data, status: response.status }; +}; diff --git a/packages/vitnode/src/content/admin/labels.ts b/packages/vitnode/src/content/admin/labels.ts new file mode 100644 index 000000000..ed975a01a --- /dev/null +++ b/packages/vitnode/src/content/admin/labels.ts @@ -0,0 +1,44 @@ +import type { AnyContentTypeDefinition } from "../types"; + +/** + * Turns `publishedAt` into "Published at" - the fallback whenever a plugin has + * not translated a field name. + */ +export const humanizeFieldName = (name: string): string => { + // Sentence case, not title case: "Published at" reads better as a form label + // than "Published At". + const spaced = name + .replace( + /([a-z0-9])([A-Z])/g, + (_match, before: string, upper: string) => + `${before} ${upper.toLowerCase()}`, + ) + .replace(/[_-]+/g, " ") + .trim(); + + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +}; + +/** `example.article` -> `article`; `example.kb.article` -> `kb_article`. */ +export const contentEntityKey = (contentTypeId: string): string => + contentTypeId.split(".").slice(1).join("_"); + +/** + * The i18n keys the generated AdminCP looks up, all under the owning plugin's + * namespace. Every one is optional - `t.has(key)` decides, and the definition's + * own labels are the fallback. + */ +export const contentI18nKeys = ( + definition: AnyContentTypeDefinition, + pluginId: string, +) => { + const base = `${pluginId}.content.${contentEntityKey(definition.id)}`; + + return { + desc: `${base}.desc`, + enumValue: (field: string, value: string) => + `${base}.enums.${field}.${value}`, + field: (field: string) => `${base}.fields.${field}`, + title: `${base}.title`, + }; +}; diff --git a/packages/vitnode/src/content/admin/spec.test.ts b/packages/vitnode/src/content/admin/spec.test.ts new file mode 100644 index 000000000..ae8ffd7c3 --- /dev/null +++ b/packages/vitnode/src/content/admin/spec.test.ts @@ -0,0 +1,260 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import { humanizeFieldName } from "./labels"; +import { + buildContentColumnSpec, + buildContentFormSpec, + buildFormSchemaFromSpec, + contentFormValuesToPayload, +} from "./spec"; + +const labelField = (name: string) => humanizeFieldName(name); +const labelEnum = (_field: string, value: string) => value.toUpperCase(); + +const formSpec = buildContentFormSpec({ + definition: testArticleContentType, + labelEnum, + labelField, + pluginId: "@vitnode/example", +}); + +const columnSpecs = buildContentColumnSpec({ + definition: testArticleContentType, + labelEnum, + labelField, +}); + +const REF = { label: "News", value: "1" }; + +const specFor = (name: string) => { + const found = formSpec.fields.find(item => item.name === name); + if (!found) throw new Error(`no spec for ${name}`); + + return found; +}; + +describe("buildContentFormSpec", () => { + it("is plain JSON, so it can cross the server/client boundary", () => { + expect(JSON.parse(JSON.stringify(formSpec))).toEqual(formSpec); + }); + + it("covers exactly the declared form fields", () => { + expect(formSpec.fields.map(item => item.name)).toEqual( + testArticleContentType.admin.form.fields, + ); + }); + + it("humanises a field name when the plugin has no translation", () => { + expect(specFor("publishedAt").label).toBe("Published at"); + }); + + it("carries enum options with translated labels", () => { + expect(specFor("status").options).toEqual([ + { label: "DRAFT", value: "draft" }, + { label: "PUBLISHED", value: "published" }, + { label: "ARCHIVED", value: "archived" }, + ]); + }); + + it("carries the validation bounds the form needs", () => { + expect(specFor("title")).toMatchObject({ + maxLength: 200, + minLength: 3, + required: true, + }); + expect(specFor("views")).toMatchObject({ integer: true, min: 0 }); + }); + + it("keeps nullability", () => { + expect(specFor("excerpt").nullable).toBe(true); + expect(specFor("title").nullable).toBe(false); + }); +}); + +describe("buildContentColumnSpec", () => { + it("marks the system columns", () => { + expect(columnSpecs.find(item => item.name === "updatedAt")?.kind).toBe( + "system", + ); + }); + + it("keeps the declared column order", () => { + expect(columnSpecs.map(item => item.name)).toEqual([ + "title", + "status", + "author", + "updatedAt", + ]); + }); + + it("carries an enum lookup for badge cells", () => { + expect(columnSpecs.find(item => item.name === "status")?.options).toEqual({ + archived: "ARCHIVED", + draft: "DRAFT", + published: "PUBLISHED", + }); + }); +}); + +describe("buildFormSchemaFromSpec", () => { + const schema = buildFormSchemaFromSpec(formSpec); + + it("survives z.toJSONSchema, which AutoForm runs on every schema", () => { + expect(() => z.toJSONSchema(schema)).not.toThrow(); + }); + + it("prefills AutoForm from the declared defaults", () => { + const json = z.toJSONSchema(schema); + + expect(json.properties?.status).toMatchObject({ default: "draft" }); + expect(json.properties?.featured).toMatchObject({ default: false }); + expect(json.properties?.views).toMatchObject({ default: 0 }); + }); + + it("prefills from an existing row when editing", () => { + const json = z.toJSONSchema( + buildFormSchemaFromSpec(formSpec, { + status: "published", + title: "Existing", + }), + ); + + expect(json.properties?.title).toMatchObject({ default: "Existing" }); + expect(json.properties?.status).toMatchObject({ default: "published" }); + }); + + it("enforces the same bounds as the API", () => { + expect(schema.safeParse({ category: REF, title: "ab" }).success).toBe( + false, + ); + expect(schema.safeParse({ category: REF, title: "Hello" }).success).toBe( + true, + ); + }); + + it("rejects a value outside the enum", () => { + expect( + schema.safeParse({ category: REF, status: "nope", title: "Hello" }) + .success, + ).toBe(false); + }); + + it("takes dateTime as an ISO string, never a Date", () => { + expect( + schema.safeParse({ + category: REF, + publishedAt: "2026-08-02T10:00:00.000Z", + title: "Hello", + }).success, + ).toBe(true); + expect( + schema.safeParse({ + category: REF, + publishedAt: new Date(), + title: "Hello", + }).success, + ).toBe(false); + }); + + it("models a relation as the option object AutoFormCombobox stores", () => { + const option = { label: "News", value: "3" }; + + expect(schema.safeParse({ category: option, title: "Hello" }).success).toBe( + true, + ); + // A bare identifier is what the API takes, not what the form holds. + expect(schema.safeParse({ category: 3, title: "Hello" }).success).toBe( + false, + ); + }); + + it("rejects a required relation left unselected", () => { + expect( + schema.safeParse({ category: { label: "", value: "" }, title: "Hello" }) + .success, + ).toBe(false); + }); + + it("prefills a relation with the label the list already resolved", () => { + const json = z.toJSONSchema( + buildFormSchemaFromSpec(formSpec, { + category: 3, + labels: { category: "News" }, + title: "Existing", + }), + { io: "input" }, + ); + + expect(json.properties?.category).toMatchObject({ + default: { label: "News", value: "3" }, + }); + }); + + it("accepts what a number input actually produces - a string", () => { + expect( + schema.parse({ category: REF, title: "Hello", views: "7" }).views, + ).toBe(7); + }); + + it("treats an empty date input as no value rather than an invalid date", () => { + const parsed = schema.parse({ + category: REF, + publishedAt: "", + title: "Hello", + }); + + expect(parsed.publishedAt).toBeNull(); + }); + + it("converts form values into the API payload", () => { + expect( + contentFormValuesToPayload(formSpec, { + author: null, + category: { label: "News", value: "3" }, + title: "Hello", + }), + ).toEqual({ author: null, category: 3, title: "Hello" }); + }); + + it("stays valid with every value exactly as the DOM reports it", () => { + // This is the combination that previously left the submit button disabled + // forever with no visible error. + const result = schema.safeParse({ + author: null, + category: { label: "News", value: "1" }, + excerpt: "", + featured: false, + publishedAt: "", + status: "draft", + title: "QA Article", + views: "0", + }); + + expect(result.success).toBe(true); + }); + + it("accepts null only for nullable fields", () => { + expect( + schema.safeParse({ category: REF, excerpt: null, title: "Hello" }) + .success, + ).toBe(true); + expect(schema.safeParse({ category: REF, title: null }).success).toBe( + false, + ); + }); +}); + +describe("humanizeFieldName", () => { + it.each([ + ["publishedAt", "Published at"], + ["title", "Title"], + ["author_id", "Author id"], + ["viewsCount", "Views count"], + ])("turns %s into %s", (input, expected) => { + expect(humanizeFieldName(input)).toBe(expected); + }); +}); diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts new file mode 100644 index 000000000..a71cf7380 --- /dev/null +++ b/packages/vitnode/src/content/admin/spec.ts @@ -0,0 +1,307 @@ +import { z } from "zod"; + +import type { + AnyContentTypeDefinition, + ContentFieldDescriptor, + ContentFieldKind, +} from "../types"; + +/** + * A single form field, reduced to plain JSON. + * + * The AdminCP page is a server component but the form is a client one, and a + * content type definition cannot cross that boundary - `field.relation` holds a + * `target` thunk, and Zod schemas are not serialisable either. So the server + * projects the definition into this spec, and the client rebuilds the form + * schema from it with {@link buildFormSchemaFromSpec}. + */ +export interface ContentFormFieldSpec { + defaultValue?: boolean | null | number | string; + description?: string; + display?: "radio" | "select"; + integer?: boolean; + kind: ContentFieldKind; + label: string; + max?: number; + maxLength?: number; + min?: number; + minLength?: number; + name: string; + nullable: boolean; + /** Enum choices, already translated. */ + options?: { label: string; value: string }[]; + required: boolean; +} + +export interface ContentFormSpec { + contentTypeId: string; + fields: ContentFormFieldSpec[]; + pluginId: string; +} + +export interface ContentColumnSpec { + kind: "system" | ContentFieldKind; + label: string; + name: string; + /** Enum value -> translated label, for badge cells. */ + options?: Record; +} + +export type ContentFieldLabeller = ( + name: string, + fieldValue?: ContentFieldDescriptor, +) => string; + +export type ContentEnumLabeller = (name: string, value: string) => string; + +const systemKinds: Record = { + createdAt: "system", + id: "system", + updatedAt: "system", +}; + +/** Projects a definition's form fields into the serialisable spec. */ +export const buildContentFormSpec = ({ + definition, + labelEnum, + labelField, + pluginId, +}: { + definition: AnyContentTypeDefinition; + labelEnum: ContentEnumLabeller; + labelField: ContentFieldLabeller; + pluginId: string; +}): ContentFormSpec => { + const fields = definition.fields; + + return { + contentTypeId: definition.id, + pluginId, + fields: definition.admin.form.fields.map(name => { + const fieldValue = fields[name]; + const base: ContentFormFieldSpec = { + kind: fieldValue.kind, + label: labelField(name, fieldValue), + name, + nullable: fieldValue.nullable, + required: fieldValue.required, + ...(fieldValue.description === undefined + ? {} + : { description: fieldValue.description }), + }; + + switch (fieldValue.kind) { + case "boolean": + return { ...base, defaultValue: fieldValue.defaultValue }; + case "enum": + return { + ...base, + defaultValue: fieldValue.defaultValue, + display: fieldValue.display, + options: fieldValue.values.map(value => ({ + label: labelEnum(name, value), + value, + })), + }; + case "number": + return { + ...base, + defaultValue: fieldValue.defaultValue, + integer: fieldValue.integer, + max: fieldValue.max, + min: fieldValue.min, + }; + case "text": + case "textarea": + return { + ...base, + defaultValue: fieldValue.defaultValue, + maxLength: fieldValue.maxLength, + minLength: fieldValue.minLength, + }; + default: + return base; + } + }), + }; +}; + +/** Projects the list columns into the serialisable spec. */ +export const buildContentColumnSpec = ({ + definition, + labelEnum, + labelField, +}: { + definition: AnyContentTypeDefinition; + labelEnum: ContentEnumLabeller; + labelField: ContentFieldLabeller; +}): ContentColumnSpec[] => { + const fields = definition.fields; + + return definition.admin.list.columns.map(name => { + const fieldValue = fields[name] as ContentFieldDescriptor | undefined; + + return { + kind: systemKinds[name] ?? fieldValue?.kind ?? "system", + label: labelField(name, fieldValue), + name, + ...(fieldValue?.kind === "enum" + ? { + options: Object.fromEntries( + fieldValue.values.map(value => [value, labelEnum(name, value)]), + ), + } + : {}), + }; + }); +}; + +/** What `AutoFormCombobox` stores for a selected option. */ +export const referenceOptionSchema = z.object({ + label: z.string(), + value: z.string(), +}); + +export type ContentReferenceOption = z.infer; + +export const isReferenceKind = (kind: ContentFieldKind): boolean => + kind === "relation" || kind === "user"; + +const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { + switch (spec.kind) { + case "boolean": + return z.boolean(); + case "dateTime": + // ISO strings all the way through the form - `z.toJSONSchema`, which + // AutoForm runs on every schema, throws on `z.date()`. + return z.iso.datetime(); + case "enum": { + const values = (spec.options ?? []).map(option => option.value); + + return values.length > 0 + ? z.enum(values as [string, ...string[]]) + : z.string(); + } + case "number": { + // A number input hands react-hook-form a string, so the form schema + // coerces - `z.number()` would reject "0" and disable submit. + let schema = spec.integer ? z.coerce.number().int() : z.coerce.number(); + if (spec.min !== undefined) schema = schema.min(spec.min); + if (spec.max !== undefined) schema = schema.max(spec.max); + + return schema; + } + case "relation": + case "user": + // `AutoFormCombobox` holds the whole option, not the id - the same shape + // the blog plugin models by hand. `contentFormValuesToPayload` turns it + // back into an identifier on submit. + return referenceOptionSchema; + default: { + let schema = z.string(); + if (spec.minLength !== undefined) schema = schema.min(spec.minLength); + if (spec.maxLength !== undefined) schema = schema.max(spec.maxLength); + + return schema; + } + } +}; + +/** + * Rebuilds the AutoForm schema on the client. + * + * Mirrors the server's create schema, with one difference: existing values are + * folded in as Zod defaults so `AutoForm`'s `getDefaults` prefills the edit + * form without a separate `defaultValues` path. + */ +/** + * Field kinds whose input renders an empty string when it holds no value. Left + * as-is, `""` fails ISO-date and identifier validation and the form can never + * become valid. + */ +const EMPTY_MEANS_UNSET: ReadonlySet = new Set(["dateTime"]); + +/** + * The combobox needs the whole option to show a label, so an existing + * identifier is paired with the label the list query already resolved. + */ +const toInitialValue = ( + fieldSpec: ContentFormFieldSpec, + current: unknown, + labels: Record, +): unknown => { + if (!isReferenceKind(fieldSpec.kind)) return current; + if (current === null || current === undefined) return undefined; + + const id = typeof current === "number" ? current.toString() : ""; + + return { label: labels[fieldSpec.name] ?? id, value: id }; +}; + +/** Turns validated form values into the payload the generated API accepts. */ +export const contentFormValuesToPayload = ( + spec: ContentFormSpec, + values: Record, +): Record => + Object.fromEntries( + Object.entries(values).map(([name, value]) => { + const fieldSpec = spec.fields.find(item => item.name === name); + if (!fieldSpec || !isReferenceKind(fieldSpec.kind)) return [name, value]; + + const option = value as ContentReferenceOption | null | undefined; + if (!option?.value) return [name, null]; + + return [name, Number(option.value)]; + }), + ); + +/** + * Rebuilds the AutoForm schema on the client. + * + * Mirrors the server's create schema, with two differences: existing values are + * folded in as Zod defaults so `AutoForm`'s `getDefaults` prefills the edit + * form, and every rule is written against what the DOM actually produces - + * strings from number inputs, `""` from a cleared picker. + */ +export const buildFormSchemaFromSpec = ( + spec: ContentFormSpec, + values?: Record, +): z.ZodObject => + z.object( + Object.fromEntries( + spec.fields.map(fieldSpec => { + const base = + isReferenceKind(fieldSpec.kind) && fieldSpec.required + ? baseFieldSchema(fieldSpec).refine( + option => (option as ContentReferenceOption).value !== "", + ) + : baseFieldSchema(fieldSpec); + const nullable = fieldSpec.nullable ? base.nullable() : base; + const labels = (values?.labels ?? {}) as Record; + const current = toInitialValue( + fieldSpec, + values?.[fieldSpec.name], + labels, + ); + const initial = + current === undefined ? fieldSpec.defaultValue : current; + + let schema: z.ZodType; + if (initial !== undefined) { + schema = nullable.default(initial); + } else { + schema = fieldSpec.required ? nullable : nullable.optional(); + } + + if (EMPTY_MEANS_UNSET.has(fieldSpec.kind)) { + const unset = fieldSpec.nullable ? null : undefined; + schema = z.preprocess( + value => (value === "" ? unset : value), + schema, + ); + } + + return [fieldSpec.name, schema]; + }), + ), + ); diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts new file mode 100644 index 000000000..b66cfd7b3 --- /dev/null +++ b/packages/vitnode/src/content/const.ts @@ -0,0 +1,44 @@ +/** + * Columns the Content Engine always adds. They can never be declared as + * content fields - `defineContentType` rejects them. + */ +export const CONTENT_SYSTEM_FIELDS = ["id", "createdAt", "updatedAt"] as const; + +/** + * Query-string keys owned by pagination and ordering. A filter may not use one + * of these names or it would silently shadow the pagination contract. + */ +export const RESERVED_FILTER_KEYS = [ + "cursor", + "first", + "last", + "order", + "orderBy", + "search", +] as const; + +/** `plugin.entity`, e.g. `example.article`. */ +export const CONTENT_ID_PATTERN = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/; + +/** Postgres identifier: snake_case, starts with a letter. */ +export const CONTENT_TABLE_NAME_PATTERN = /^[a-z][a-z0-9_]*$/; + +/** camelCase, matching `casing: "camelCase"` on the Drizzle client. */ +export const CONTENT_FIELD_NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/; + +/** Postgres truncates identifiers past this length. */ +export const CONTENT_TABLE_NAME_MAX_LENGTH = 63; + +export const CONTENT_TEXT_DEFAULT_LENGTH = 255; +export const CONTENT_ENUM_DEFAULT_LENGTH = 64; + +export const CONTENT_DEFAULT_PAGE_SIZE = 25; +export const CONTENT_OPTIONS_LIMIT = 25; + +/** Every content type gets these four staff permissions. */ +export const CONTENT_PERMISSIONS = { + create: "can_create", + delete: "can_delete", + edit: "can_edit", + view: "can_view", +} as const; diff --git a/packages/vitnode/src/content/define.test-d.ts b/packages/vitnode/src/content/define.test-d.ts new file mode 100644 index 000000000..659bec45e --- /dev/null +++ b/packages/vitnode/src/content/define.test-d.ts @@ -0,0 +1,181 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { + ContentCreateInput, + ContentSelect, + ContentUpdateInput, + HasColumnDefault, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Article = typeof testArticleContentType; +type Select = ContentSelect

; +type Create = ContentCreateInput
; +type Update = ContentUpdateInput
; + +describe("content type inference", () => { + it("keeps the content type id literal", () => { + expectTypeOf(testArticleContentType.id).toEqualTypeOf<"test.article">(); + expectTypeOf(testCategoryContentType.id).toEqualTypeOf<"test.category">(); + }); + + describe("select output", () => { + it("adds the system columns", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("narrows enums to their literal union", () => { + expectTypeOf().toEqualTypeOf< + "archived" | "draft" | "published" + >(); + }); + + it("distinguishes nullable from non-nullable", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("types relation and user values as row identifiers", () => { + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe("create input", () => { + it("excludes the generated system fields", () => { + expectTypeOf().not.toHaveProperty("id"); + expectTypeOf().not.toHaveProperty("createdAt"); + expectTypeOf().not.toHaveProperty("updatedAt"); + }); + + it("requires only the fields marked required", () => { + expectTypeOf().toEqualTypeOf< + | "author" + | "category" + | "excerpt" + | "featured" + | "publishedAt" + | "status" + | "title" + | "views" + >(); + + assertType({ title: "Hello", category: 1 }); + // @ts-expect-error - `title` is required + assertType({ category: 1 }); + // @ts-expect-error - `category` is required + assertType({ title: "Hello" }); + }); + + it("serializes dateTime as an ISO string on the way in", () => { + expectTypeOf().toEqualTypeOf< + null | string | undefined + >(); + }); + + it("rejects a value outside the enum", () => { + // @ts-expect-error - "nope" is not a declared status + assertType({ title: "Hello", category: 1, status: "nope" }); + }); + + it("rejects a wrong primitive type", () => { + // @ts-expect-error - `views` is a number + assertType({ title: "Hello", category: 1, views: "many" }); + }); + + it("rejects null for a non-nullable field", () => { + // @ts-expect-error - `title` is not nullable + assertType({ title: null, category: 1 }); + }); + }); + + describe("update input", () => { + it("makes every editable field optional", () => { + assertType({}); + assertType({ title: "Only the title" }); + }); + + it("still rejects unknown and wrong-typed fields", () => { + // @ts-expect-error - `slug` is not a field + assertType({ slug: "nope" }); + // @ts-expect-error - `featured` is a boolean + assertType({ featured: "yes" }); + }); + }); + + describe("reserved fields", () => { + it("cannot be declared", () => { + defineContentType({ + id: "test.reserved", + tableName: "test_reserved", + fields: { + title: field.text({ required: true }), + // @ts-expect-error - `id` is a reserved system column + id: field.number({ integer: true, required: true }), + }, + admin: { label: { plural: "Reserved", singular: "Reserved" } }, + }); + }); + }); + + describe("field builders", () => { + it("defaults required and nullable to false", () => { + const plain = field.text({ defaultValue: "" }); + expectTypeOf(plain.required).toEqualTypeOf(); + expectTypeOf(plain.nullable).toEqualTypeOf(); + }); + + it("keeps required and nullable literal when set", () => { + const both = field.text({ required: true, nullable: true }); + expectTypeOf(both.required).toEqualTypeOf(); + expectTypeOf(both.nullable).toEqualTypeOf(); + }); + + it("keeps enum values as a readonly literal tuple", () => { + const status = field.enum({ values: ["draft", "published"] }); + expectTypeOf(status.values).toEqualTypeOf< + readonly ["draft", "published"] + >(); + }); + + it("keeps the declared default literal, so `hasDefault` is knowable", () => { + expectTypeOf( + field.enum({ values: ["draft", "published"], defaultValue: "draft" }) + .defaultValue, + ).toEqualTypeOf<"draft">(); + expectTypeOf( + field.enum({ values: ["draft", "published"] }).defaultValue, + ).toEqualTypeOf(); + }); + }); + + describe("column defaults", () => { + type Fields = (typeof testArticleContentType)["fields"]; + + it("marks declared defaults", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + HasColumnDefault + >().toEqualTypeOf(); + }); + + it("leaves undefaulted fields alone", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + HasColumnDefault + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + }); +}); diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts new file mode 100644 index 000000000..fb00e9336 --- /dev/null +++ b/packages/vitnode/src/content/define.test.ts @@ -0,0 +1,278 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import { defineContentType } from "./define"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; + +const label = { plural: "Widgets", singular: "Widget" }; + +const define = ( + overrides: Partial[0]> = {}, +) => + defineContentType({ + id: "test.widget", + tableName: "test_widgets", + fields: { title: field.text({ required: true }) }, + admin: { label }, + ...overrides, + }); + +describe("defineContentType", () => { + describe("identifiers", () => { + it.each([ + ["Article", "not dotted or lowercase"], + ["example.", "trailing dot"], + ["example.Article", "uppercase segment"], + ["example_article", "underscore instead of dot"], + ])("rejects the id %s (%s)", id => { + expect(() => define({ id })).toThrow(ContentEngineError); + }); + + it("accepts a dotted lowercase id", () => { + expect(define({ id: "example.knowledge-article" }).id).toBe( + "example.knowledge-article", + ); + }); + + it.each(["Test_Widgets", "1widgets", "test-widgets"])( + "rejects the table name %s", + tableName => { + expect(() => define({ tableName })).toThrow(ContentEngineError); + }, + ); + + it("rejects a table name past the Postgres identifier limit", () => { + expect(() => define({ tableName: "a".repeat(64) })).toThrow( + /identifier limit/, + ); + }); + }); + + describe("fields", () => { + it.each(["id", "createdAt", "updatedAt"])( + "rejects the reserved field name %s", + name => { + expect(() => + define({ fields: { [name]: field.text({ required: true }) } }), + ).toThrow(/reserved system column/); + }, + ); + + it("rejects a field name that is not camelCase", () => { + expect(() => + define({ fields: { Title: field.text({ required: true }) } }), + ).toThrow(/camelCase/); + }); + + it("rejects a content type with no fields", () => { + expect(() => define({ fields: {} })).toThrow(/at least one field/); + }); + + it("rejects a field that is neither required, nullable, nor defaulted", () => { + expect(() => define({ fields: { title: field.text() } })).toThrow( + /needs a default value/, + ); + }); + + it("accepts a defaulted field that is neither required nor nullable", () => { + expect(() => + define({ + fields: { views: field.number({ integer: true, defaultValue: 0 }) }, + }), + ).not.toThrow(); + }); + + it("accepts a dateTime with defaultNow instead of a default value", () => { + expect(() => + define({ fields: { seenAt: field.dateTime({ defaultNow: true }) } }), + ).not.toThrow(); + }); + + it("rejects minLength greater than maxLength", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true, minLength: 10, maxLength: 5 }), + }, + }), + ).toThrow(/minLength 10 greater than maxLength 5/); + }); + + it("rejects min greater than max", () => { + expect(() => + define({ + fields: { + views: field.number({ + required: true, + integer: true, + min: 10, + max: 1, + }), + }, + }), + ).toThrow(/min 10 greater than max 1/); + }); + + it("rejects duplicate enum values", () => { + expect(() => + define({ + fields: { + status: field.enum({ required: true, values: ["a", "b", "a"] }), + }, + }), + ).toThrow(/duplicate enum values/); + }); + + it("rejects an enum default that is not one of its values", () => { + expect(() => + define({ + fields: { + // The type already rules this out; the runtime guard covers plain + // JS consumers and `as` escapes. + // @ts-expect-error - "nope" is not in `values` + status: field.enum({ values: ["draft"], defaultValue: "nope" }), + }, + }), + ).toThrow(/not one of its values/); + }); + + it("rejects an enum value longer than the column length", () => { + expect(() => + define({ + fields: { + status: field.enum({ + required: true, + length: 4, + values: ["draft", "ok"], + }), + }, + }), + ).toThrow(/longer than the column length 4/); + }); + }); + + describe("admin defaults", () => { + const definition = define({ + fields: { + title: field.text({ required: true }), + body: field.textarea({ nullable: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + }); + + it("defaults navigation to enabled", () => { + expect(definition.admin.navigation.enabled).toBe(true); + }); + + it("defaults ordering to updatedAt desc", () => { + expect(definition.admin.list.defaultOrderBy).toBe("updatedAt"); + expect(definition.admin.list.defaultOrder).toBe("desc"); + }); + + it("defaults searchable fields to every text and textarea field", () => { + expect(definition.admin.list.searchableFields).toEqual(["title", "body"]); + }); + + it("defaults the title field to the first text field", () => { + expect(definition.admin.titleField).toBe("title"); + }); + + it("defaults the form to every field in declaration order", () => { + expect(definition.admin.form.fields).toEqual(["title", "body", "views"]); + }); + + it("derives the permission module from the plural label", () => { + expect(define({ admin: { label } }).permissionModule).toBe("widgets"); + expect( + define({ + admin: { label: { plural: "Knowledge Articles", singular: "x" } }, + }).permissionModule, + ).toBe("knowledge_articles"); + }); + + it("prefers an explicit permission module", () => { + expect( + define({ admin: { label, permissionModule: "kb_articles" } }) + .permissionModule, + ).toBe("kb_articles"); + }); + }); + + describe("admin validation", () => { + it("rejects a searchable field that is not text-like", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + admin: { label, list: { searchableFields: ["views"] } }, + }), + ).toThrow(/not a text or textarea field/); + }); + + it.each([ + ["list.columns", { list: { columns: ["nope"] } }], + ["list.orderableFields", { list: { orderableFields: ["nope"] } }], + ["form.fields", { form: { fields: ["nope"] } }], + ["titleField", { titleField: "nope" }], + ])("rejects an unknown field in admin.%s", (_name, adminOverrides) => { + expect(() => define({ admin: { label, ...adminOverrides } })).toThrow( + /unknown field "nope"/, + ); + }); + + it("rejects a defaultOrderBy that is not allowlisted", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + admin: { label, list: { defaultOrderBy: "views" } }, + }), + ).toThrow(/not in admin.list.orderableFields/); + }); + + it("allows a system column as defaultOrderBy without allowlisting it", () => { + expect(() => + define({ admin: { label, list: { defaultOrderBy: "createdAt" } } }), + ).not.toThrow(); + }); + + it("rejects an index over an unknown column", () => { + expect(() => define({ indexes: [{ on: ["nope"] }] })).toThrow( + /indexes references unknown field "nope"/, + ); + }); + + it("allows an index over a system column", () => { + expect(() => + define({ indexes: [{ on: ["title", "createdAt"] }] }), + ).not.toThrow(); + }); + }); + + describe("fixtures", () => { + it("resolves the article fixture", () => { + expect(testArticleContentType.permissionModule).toBe("test_articles"); + expect(testArticleContentType.admin.list.searchableFields).toEqual([ + "title", + "excerpt", + ]); + expect(testArticleContentType.admin.titleField).toBe("title"); + }); + + it("resolves relation targets lazily", () => { + const { category } = testArticleContentType.fields; + expect(category.kind).toBe("relation"); + expect(category.target().id).toBe(testCategoryContentType.id); + }); + }); +}); diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts new file mode 100644 index 000000000..5d20b3862 --- /dev/null +++ b/packages/vitnode/src/content/define.ts @@ -0,0 +1,356 @@ +import type { + ContentAdminConfig, + ContentFieldDescriptor, + ContentFieldMap, + ContentFieldsConstraint, + ContentIndexConfig, + ContentIndexInput, + ContentTypeDefinition, + ResolvedContentAdminConfig, +} from "./types"; + +import { + CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_FIELD_NAME_PATTERN, + CONTENT_ID_PATTERN, + CONTENT_SYSTEM_FIELDS, + CONTENT_TABLE_NAME_MAX_LENGTH, + CONTENT_TABLE_NAME_PATTERN, +} from "./const"; +import { ContentEngineError } from "./errors"; +import { buildContentSchemas } from "./schemas"; + +const SEARCHABLE_KINDS = new Set([ + "text", + "textarea", +]); + +const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS; + +const slugifyModule = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +/** A field with no default that is neither required nor nullable is unwritable. */ +const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => { + if (fieldValue.kind === "dateTime") return fieldValue.defaultNow; + if (fieldValue.kind === "relation" || fieldValue.kind === "user") { + return false; + } + + return fieldValue.defaultValue !== undefined; +}; + +const assertFieldName = (id: string, name: string): void => { + if (systemFields.includes(name)) { + throw new ContentEngineError( + `"${name}" is a reserved system column and cannot be declared as a field.`, + { contentTypeId: id }, + ); + } + + if (!CONTENT_FIELD_NAME_PATTERN.test(name)) { + throw new ContentEngineError( + `Field "${name}" must be camelCase and start with a lowercase letter.`, + { contentTypeId: id }, + ); + } +}; + +const FIELD_KINDS = new Set([ + "boolean", + "dateTime", + "enum", + "number", + "relation", + "text", + "textarea", + "user", +]); + +/** Guards the widening of `ContentFieldsConstraint` to `ContentFieldMap`. */ +const assertFieldKind = ( + id: string, + name: string, + fieldValue: ContentFieldDescriptor, +): void => { + if (!FIELD_KINDS.has(fieldValue?.kind)) { + throw new ContentEngineError( + `Field "${name}" is not a field descriptor. Build it with \`field.text()\`, \`field.enum()\`, and so on.`, + { contentTypeId: id }, + ); + } +}; + +const assertField = ( + id: string, + name: string, + fieldValue: ContentFieldDescriptor, +): void => { + if (!fieldValue.required && !fieldValue.nullable) { + if (!hasWritableFallback(fieldValue)) { + throw new ContentEngineError( + `Field "${name}" is neither required nor nullable, so it needs a default value - otherwise a row could never be inserted.`, + { contentTypeId: id }, + ); + } + } + + if (fieldValue.kind === "text" || fieldValue.kind === "textarea") { + const { maxLength, minLength } = fieldValue; + if (maxLength !== undefined && maxLength <= 0) { + throw new ContentEngineError( + `Field "${name}" has a maxLength of ${maxLength}; it must be positive.`, + { contentTypeId: id }, + ); + } + if ( + minLength !== undefined && + maxLength !== undefined && + minLength > maxLength + ) { + throw new ContentEngineError( + `Field "${name}" has minLength ${minLength} greater than maxLength ${maxLength}.`, + { contentTypeId: id }, + ); + } + } + + if (fieldValue.kind === "number") { + const { max, min } = fieldValue; + if (min !== undefined && max !== undefined && min > max) { + throw new ContentEngineError( + `Field "${name}" has min ${min} greater than max ${max}.`, + { contentTypeId: id }, + ); + } + } + + if (fieldValue.kind === "enum") { + const { defaultValue, length = CONTENT_ENUM_DEFAULT_LENGTH } = fieldValue; + const values: readonly string[] = fieldValue.values; + + if (values.length === 0) { + throw new ContentEngineError( + `Field "${name}" needs at least one value.`, + { + contentTypeId: id, + }, + ); + } + if (new Set(values).size !== values.length) { + throw new ContentEngineError( + `Field "${name}" has duplicate enum values.`, + { contentTypeId: id }, + ); + } + const tooLong = values.find(value => value.length > length); + if (tooLong !== undefined) { + throw new ContentEngineError( + `Field "${name}" value "${tooLong}" is longer than the column length ${length}. Raise \`length\` on the field.`, + { contentTypeId: id }, + ); + } + if (defaultValue !== undefined && !values.includes(defaultValue)) { + throw new ContentEngineError( + `Field "${name}" has default "${defaultValue}", which is not one of its values.`, + { contentTypeId: id }, + ); + } + } +}; + +const assertKnownColumns = ( + id: string, + label: string, + names: readonly string[], + known: ReadonlySet, +): void => { + const unknown = names.find(name => !known.has(name)); + if (unknown !== undefined) { + throw new ContentEngineError( + `${label} references unknown field "${unknown}".`, + { contentTypeId: id }, + ); + } +}; + +const resolveAdmin = ( + id: string, + fields: ContentFieldMap, + admin: ContentAdminConfig, +): ResolvedContentAdminConfig => { + const fieldNames = Object.keys(fields); + const knownColumns = new Set([...fieldNames, ...systemFields]); + + const searchableFields = ( + admin.list?.searchableFields?.map(String) ?? + fieldNames.filter(name => SEARCHABLE_KINDS.has(fields[name].kind)) + ).map(String); + assertKnownColumns( + id, + "admin.list.searchableFields", + searchableFields, + new Set(fieldNames), + ); + const notSearchable = searchableFields.find( + name => !SEARCHABLE_KINDS.has(fields[name].kind), + ); + if (notSearchable !== undefined) { + throw new ContentEngineError( + `admin.list.searchableFields includes "${notSearchable}", which is not a text or textarea field.`, + { contentTypeId: id }, + ); + } + + const orderableFields = (admin.list?.orderableFields ?? []).map(String); + assertKnownColumns( + id, + "admin.list.orderableFields", + orderableFields, + new Set(fieldNames), + ); + + const columns = ( + admin.list?.columns?.map(String) ?? [...fieldNames, "updatedAt"] + ).map(String); + assertKnownColumns(id, "admin.list.columns", columns, knownColumns); + + const formFields = (admin.form?.fields?.map(String) ?? fieldNames).map( + String, + ); + assertKnownColumns(id, "admin.form.fields", formFields, new Set(fieldNames)); + + const defaultOrderBy = String(admin.list?.defaultOrderBy ?? "updatedAt"); + if ( + !systemFields.includes(defaultOrderBy) && + !orderableFields.includes(defaultOrderBy) + ) { + throw new ContentEngineError( + `admin.list.defaultOrderBy is "${defaultOrderBy}", which is not in admin.list.orderableFields.`, + { contentTypeId: id }, + ); + } + + const titleField = + admin.titleField === undefined + ? (fieldNames.find(name => SEARCHABLE_KINDS.has(fields[name].kind)) ?? + null) + : String(admin.titleField); + if (titleField !== null && !fieldNames.includes(titleField)) { + throw new ContentEngineError( + `admin.titleField references unknown field "${titleField}".`, + { contentTypeId: id }, + ); + } + + return { + form: { fields: formFields }, + label: admin.label, + list: { + columns, + defaultOrder: admin.list?.defaultOrder ?? "desc", + defaultOrderBy, + orderableFields, + searchableFields, + }, + navigation: { enabled: admin.navigation?.enabled ?? true }, + titleField, + }; +}; + +/** + * 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 + * `createContentModel` in `src/database/*.ts` (server) without dragging Drizzle + * into a client bundle. + */ +export const defineContentType = < + TId extends string, + TFields extends ContentFieldsConstraint, +>({ + admin, + fields, + id, + indexes = [], + tableName, +}: { + admin: ContentAdminConfig; + fields: TFields; + id: TId; + indexes?: ContentIndexInput[]; + tableName: string; +}): ContentTypeDefinition => { + if (!CONTENT_ID_PATTERN.test(id)) { + throw new ContentEngineError( + `Content type id "${id}" must look like "plugin.entity" (lowercase, dot separated).`, + ); + } + + if (!CONTENT_TABLE_NAME_PATTERN.test(tableName)) { + throw new ContentEngineError( + `Table name "${tableName}" must be snake_case and start with a letter.`, + { contentTypeId: id }, + ); + } + + if (tableName.length > CONTENT_TABLE_NAME_MAX_LENGTH) { + throw new ContentEngineError( + `Table name "${tableName}" is longer than the Postgres identifier limit of ${CONTENT_TABLE_NAME_MAX_LENGTH} characters.`, + { contentTypeId: id }, + ); + } + + // `ContentFieldsConstraint` only pins `kind` (see its doc comment), so widen + // to the real descriptor union here. This is the only unchecked widening in + // the engine, and `assertFieldKind` below makes it true at runtime for + // anything that skipped the `field.*` builders. + const fieldMap = fields as unknown as ContentFieldMap; + const fieldNames = Object.keys(fieldMap); + if (fieldNames.length === 0) { + throw new ContentEngineError("A content type needs at least one field.", { + contentTypeId: id, + }); + } + + for (const name of fieldNames) { + assertFieldName(id, name); + assertFieldKind(id, name, fieldMap[name]); + assertField(id, name, fieldMap[name]); + } + + const knownColumns = new Set([...fieldNames, ...systemFields]); + const resolvedIndexes: ContentIndexConfig[] = indexes.map(index => { + const on = index.on.map(String); + assertKnownColumns(id, "indexes", on, knownColumns); + + return { ...index, on }; + }); + + const resolvedAdmin = resolveAdmin(id, fieldMap, admin); + const permissionModule = + admin.permissionModule ?? slugifyModule(admin.label.plural); + + if (!CONTENT_TABLE_NAME_PATTERN.test(permissionModule)) { + throw new ContentEngineError( + `Could not derive a permission module name from label.plural "${admin.label.plural}". Set \`admin.permissionModule\` explicitly.`, + { contentTypeId: id }, + ); + } + + return { + admin: resolvedAdmin, + fields, + id, + indexes: resolvedIndexes, + permissionModule, + schemas: buildContentSchemas>({ + admin: resolvedAdmin, + fields: fieldMap, + }), + tableName, + }; +}; diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts new file mode 100644 index 000000000..c21815c71 --- /dev/null +++ b/packages/vitnode/src/content/errors.ts @@ -0,0 +1,23 @@ +/** + * Thrown while a content type definition is being built or registered - always + * at import/boot time, never per request. The message names the offending + * content type so a misconfigured plugin fails loudly and obviously. + */ +export class ContentEngineError extends Error { + constructor( + message: string, + options?: { cause?: unknown; contentTypeId?: string }, + ) { + super( + options?.contentTypeId + ? `[Content Engine] ${options.contentTypeId}: ${message}` + : `[Content Engine] ${message}`, + { cause: options?.cause }, + ); + + this.name = "ContentEngineError"; + this.contentTypeId = options?.contentTypeId; + } + + readonly contentTypeId: string | undefined; +} diff --git a/packages/vitnode/src/content/events.test-d.ts b/packages/vitnode/src/content/events.test-d.ts new file mode 100644 index 000000000..f35a0cbea --- /dev/null +++ b/packages/vitnode/src/content/events.test-d.ts @@ -0,0 +1,74 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import type { VitNodeEvents } from "../api/models/events"; +import type { ContentEventsFor } from "./events"; + +import { contentEventName } from "./events"; + +type ArticleEvents = ContentEventsFor; + +// The pattern plugins use. It compiles only if the mapped keys are statically +// known, which is exactly what makes the whole approach viable. +declare module "../api/models/events" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the members come from the mapped type + interface VitNodeEvents extends ContentEventsFor< + typeof testArticleContentType + > {} +} + +describe("content events", () => { + it("builds literal event names", () => { + expectTypeOf( + contentEventName(testArticleContentType.id, "created"), + ).toEqualTypeOf<"content.test.article.created">(); + expectTypeOf( + contentEventName(testArticleContentType.id, "deleted"), + ).toEqualTypeOf<"content.test.article.deleted">(); + }); + + it("keys the event map by literal name", () => { + expectTypeOf().toEqualTypeOf< + | "content.test.article.created" + | "content.test.article.deleted" + | "content.test.article.updated" + >(); + }); + + it("carries only the content identifier on create and delete", () => { + expectTypeOf< + ArticleEvents["content.test.article.created"] + >().toEqualTypeOf<{ contentId: number }>(); + expectTypeOf< + ArticleEvents["content.test.article.deleted"] + >().toEqualTypeOf<{ contentId: number }>(); + }); + + it("narrows changedFields to the content type's own field names", () => { + type Updated = ArticleEvents["content.test.article.updated"]; + + expectTypeOf().toEqualTypeOf< + ( + | "author" + | "category" + | "excerpt" + | "featured" + | "publishedAt" + | "status" + | "title" + | "views" + )[] + >(); + + assertType({ changedFields: ["title"], contentId: 1 }); + // @ts-expect-error - "slug" is not a field on this content type + assertType({ changedFields: ["slug"], contentId: 1 }); + }); + + it("registers the events on the global map", () => { + expectTypeOf< + VitNodeEvents["content.test.article.created"] + >().toEqualTypeOf<{ contentId: number }>(); + }); +}); diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts new file mode 100644 index 000000000..08f4ac28a --- /dev/null +++ b/packages/vitnode/src/content/events.ts @@ -0,0 +1,52 @@ +import type { ContentFieldName } from "./types"; + +export type ContentEventAction = "created" | "deleted" | "updated"; + +export interface ContentCreatedPayload { + contentId: number; +} + +export interface ContentDeletedPayload { + contentId: number; +} + +export interface ContentUpdatedPayload { + changedFields: ContentFieldName[]; + contentId: number; +} + +/** + * The three events a content type emits, as a literal-keyed map. + * + * Plugins graft these onto the global event map with one declaration - the + * same module-augmentation mechanism every other VitNode event uses: + * + * ```ts + * declare module "@vitnode/core/api/models/events" { + * interface VitNodeEvents + * extends ContentEventsFor {} + * } + * ``` + * + * `TDefinition` is concrete at the augmentation site, so the keys are + * statically known and `changedFields` narrows to the content type's own field + * names. The envelope already carries the actor, plugin and timestamp, so the + * payloads stay minimal. + */ +export type ContentEventsFor = Record< + `content.${TDefinition["id"]}.created`, + ContentCreatedPayload +> & + Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & + Record< + `content.${TDefinition["id"]}.updated`, + ContentUpdatedPayload + >; + +export const contentEventName = < + TId extends string, + TAction extends ContentEventAction, +>( + contentTypeId: TId, + action: TAction, +): `content.${TId}.${TAction}` => `content.${contentTypeId}.${action}`; diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts new file mode 100644 index 000000000..3236e5ef4 --- /dev/null +++ b/packages/vitnode/src/content/fields.ts @@ -0,0 +1,174 @@ +import type { + AnyContentTypeDefinition, + ContentBooleanField, + ContentDateTimeField, + ContentEnumField, + ContentNumberField, + ContentOnDelete, + ContentRelationField, + ContentTextareaField, + ContentTextField, + ContentUserField, +} from "./types"; + +interface SharedArgs< + TRequired extends boolean = false, + TNullable extends boolean = false, +> { + description?: string; + nullable?: TNullable; + required?: TRequired; +} + +/** + * `required` and `nullable` default to `false`. The assertions keep the literal + * type parameter the caller inferred - `?? false` alone would widen it back to + * `boolean` and every downstream `nullable extends true` check would break. + */ +const shared = ( + args: SharedArgs, +): { nullable: TNullable; required: TRequired } => ({ + nullable: (args.nullable ?? false) as TNullable, + required: (args.required ?? false) as TRequired, +}); + +const text = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends string | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + unique?: boolean; + } = {}, +): ContentTextField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "text", +}); + +const textarea = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends string | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + } = {}, +): ContentTextareaField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "textarea", +}); + +const number = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends number | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + integer: boolean; + max?: number; + min?: number; + }, +): ContentNumberField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "number", +}); + +const boolean = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends boolean | undefined = undefined, +>( + args: SharedArgs & { defaultValue?: TDefault } = {}, +): ContentBooleanField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "boolean", +}); + +const enumField = < + const TValues extends readonly [string, ...string[]], + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends TValues[number] | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + display?: "radio" | "select"; + length?: number; + values: TValues; + }, +): ContentEnumField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "enum", +}); + +const dateTime = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefaultNow extends boolean = false, +>( + args: SharedArgs & { defaultNow?: TDefaultNow } = {}, +): ContentDateTimeField => ({ + ...args, + ...shared(args), + defaultNow: (args.defaultNow ?? false) as TDefaultNow, + kind: "dateTime", +}); + +const user = < + TRequired extends boolean = false, + TNullable extends boolean = false, +>( + args: SharedArgs & { onDelete?: ContentOnDelete } = {}, +): ContentUserField => ({ + ...args, + ...shared(args), + kind: "user", + onDelete: args.onDelete ?? "set null", +}); + +const relation = < + TRequired extends boolean = false, + TNullable extends boolean = false, +>( + args: SharedArgs & { + onDelete?: ContentOnDelete; + target: () => AnyContentTypeDefinition; + }, +): ContentRelationField => ({ + ...args, + ...shared(args), + kind: "relation", + onDelete: args.onDelete ?? "restrict", +}); + +/** + * Field builders for `defineContentType`. Every builder returns plain data - + * no Drizzle, no React - so a content type definition is safe to import from + * both the API and a client component. + */ +export const field = { + boolean, + dateTime, + enum: enumField, + number, + relation, + text, + textarea, + user, +}; diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts new file mode 100644 index 000000000..3c5d7dbcf --- /dev/null +++ b/packages/vitnode/src/content/index.ts @@ -0,0 +1,88 @@ +export { + contentEntityKey, + contentI18nKeys, + humanizeFieldName, +} from "./admin/labels"; +export { + buildContentColumnSpec, + buildContentFormSpec, + buildFormSchemaFromSpec, +} from "./admin/spec"; +export type { + ContentColumnSpec, + ContentEnumLabeller, + ContentFieldLabeller, + ContentFormFieldSpec, + ContentFormSpec, +} from "./admin/spec"; +/** + * Universal Content Engine - client-safe surface. + * + * Everything exported here is plain data plus zod: it is safe to import from a + * client component, from `buildPlugin`, and from `src/database/*.ts` (which + * Drizzle Kit executes). Anything that needs Drizzle or Hono lives in + * `@vitnode/core/content/server`. + */ +export { + CONTENT_DEFAULT_PAGE_SIZE, + CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_OPTIONS_LIMIT, + CONTENT_PERMISSIONS, + CONTENT_SYSTEM_FIELDS, + CONTENT_TEXT_DEFAULT_LENGTH, + RESERVED_FILTER_KEYS, +} from "./const"; +export { defineContentType } from "./define"; +export { ContentEngineError } from "./errors"; +export { contentEventName } from "./events"; +export type { + ContentCreatedPayload, + ContentDeletedPayload, + ContentEventAction, + ContentEventsFor, + ContentUpdatedPayload, +} from "./events"; +export { field } from "./fields"; +export { + contentAdminHref, + contentPermissionEntries, + contentTypeToPath, + findContentTypeById, + orderableColumns, + pathToContentTypeId, + validateContentTypes, + withContentPermissions, +} from "./registry"; +export type { RegisteredContentType } from "./registry"; +export { buildContentSchemas } from "./schemas"; +export type { ContentSchemas } from "./schemas"; +export type { + AnyContentTypeDefinition, + ContentAdminConfig, + ContentAdminLabel, + ContentAdminListConfig, + ContentBooleanField, + ContentCreateInput, + ContentDateTimeField, + ContentEnumField, + ContentFieldDescriptor, + ContentFieldInput, + ContentFieldKind, + ContentFieldMap, + ContentFieldName, + ContentFieldValue, + ContentIndexConfig, + ContentIndexInput, + ContentNumberField, + ContentOnDelete, + ContentReferenceField, + ContentRelationField, + ContentSelect, + ContentSystemField, + ContentTextareaField, + ContentTextField, + ContentTypeDefinition, + ContentUpdateInput, + ContentUserField, + ResolvedContentAdminConfig, +} from "./types"; diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts new file mode 100644 index 000000000..03d3a270e --- /dev/null +++ b/packages/vitnode/src/content/registry.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { RegisteredContentType } from "./registry"; + +import { defineContentType } from "./define"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; +import { + contentAdminHref, + contentTypeToPath, + findContentTypeById, + orderableColumns, + pathToContentTypeId, + validateContentTypes, + withContentPermissions, +} from "./registry"; + +// `widget()` below builds definitions through `Partial>`, which +// erases the inferred field map down to the bare constraint. Real call sites +// keep their concrete map, so this widening only exists for the test helper. +const entry = ( + definition: RegisteredContentType["definition"] | ReturnType, + pluginId = "@vitnode/example", +): RegisteredContentType => ({ + definition: definition as RegisteredContentType["definition"], + pluginId, +}); + +const widget = ( + overrides: Partial[0]> = {}, +) => + defineContentType({ + id: "test.widget", + tableName: "test_widgets", + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Widgets", singular: "Widget" } }, + ...overrides, + }); + +describe("validateContentTypes", () => { + it("accepts distinct content types", () => { + expect(() => + validateContentTypes([ + entry(testArticleContentType), + entry(testCategoryContentType), + ]), + ).not.toThrow(); + }); + + it("returns entries sorted by id, whatever the registration order", () => { + const sorted = validateContentTypes([ + entry(testArticleContentType), + entry(testCategoryContentType), + ]); + + expect(sorted.map(item => item.definition.id)).toEqual([ + "test.article", + "test.category", + ]); + }); + + it("rejects a duplicate content type id and names both plugins", () => { + expect(() => + validateContentTypes([ + entry(widget(), "@vitnode/a"), + entry(widget({ tableName: "test_widgets_two" }), "@vitnode/b"), + ]), + ).toThrow(/@vitnode\/a .* @vitnode\/b/); + }); + + it("rejects a duplicate table name across plugins", () => { + expect(() => + validateContentTypes([ + entry(widget(), "@vitnode/a"), + entry(widget({ id: "test.other" }), "@vitnode/b"), + ]), + ).toThrow(/Table "test_widgets" is claimed by both/); + }); + + it("rejects two content types deriving the same permission module in one plugin", () => { + expect(() => + validateContentTypes([ + entry(widget()), + entry(widget({ id: "test.other", tableName: "test_others" })), + ]), + ).toThrow(/Permission module "widgets" is derived by both/); + }); + + it("allows the same permission module in different plugins", () => { + expect(() => + validateContentTypes([ + entry(widget(), "@vitnode/a"), + entry( + widget({ id: "test.other", tableName: "test_others" }), + "@vitnode/b", + ), + ]), + ).not.toThrow(); + }); + + it.each(["cursor", "first", "last", "order", "orderBy", "search"])( + "rejects a field named %s, which would shadow a pagination parameter", + name => { + expect(() => + validateContentTypes([ + entry(widget({ fields: { [name]: field.text({ required: true }) } })), + ]), + ).toThrow(ContentEngineError); + }, + ); +}); + +describe("withContentPermissions", () => { + it("derives the four permissions per content type", () => { + const merged = withContentPermissions({}, [entry(testArticleContentType)]); + + expect(merged?.admin?.test_articles).toEqual([ + "can_view", + { dependsOn: ["can_view"], permission: "can_create" }, + { dependsOn: ["can_view"], permission: "can_edit" }, + { dependsOn: ["can_view"], permission: "can_delete" }, + ]); + }); + + it("keeps an explicitly declared module untouched", () => { + const merged = withContentPermissions( + { admin: { test_articles: ["can_view"] } }, + [entry(testArticleContentType)], + ); + + expect(merged?.admin?.test_articles).toEqual(["can_view"]); + }); + + it("leaves other modules alone", () => { + const merged = withContentPermissions( + { admin: { posts: ["can_view", "can_edit"] } }, + [entry(testArticleContentType)], + ); + + expect(merged?.admin?.posts).toEqual(["can_view", "can_edit"]); + expect(merged?.admin?.test_articles).toBeDefined(); + }); + + it("passes the config through untouched when there are no content types", () => { + const permissionStaff = { admin: { posts: ["can_view"] } }; + + expect(withContentPermissions(permissionStaff, [])).toBe(permissionStaff); + }); +}); + +describe("routing helpers", () => { + it("maps a content type id onto the catch-all path", () => { + expect(contentTypeToPath("example.article")).toBe("example/article"); + expect(contentAdminHref("example.article")).toBe( + "/admin/content/example/article", + ); + }); + + it("round-trips the catch-all slug", () => { + expect(pathToContentTypeId(["example", "article"])).toBe("example.article"); + }); + + it("finds a registered content type by id", () => { + const entries = validateContentTypes([entry(testArticleContentType)]); + + expect(findContentTypeById(entries, "test.article")?.pluginId).toBe( + "@vitnode/example", + ); + expect(findContentTypeById(entries, "test.nope")).toBeUndefined(); + }); +}); + +describe("orderableColumns", () => { + it("combines the declared allowlist with the system columns", () => { + expect(orderableColumns(testArticleContentType)).toEqual([ + "title", + "status", + "id", + "createdAt", + "updatedAt", + ]); + }); +}); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts new file mode 100644 index 000000000..40e6ce293 --- /dev/null +++ b/packages/vitnode/src/content/registry.ts @@ -0,0 +1,165 @@ +import type { + PermissionStaffConfig, + PermissionStaffEntryInput, + PermissionStaffModulesInput, +} from "../api/lib/permission-staff"; +import type { AnyContentTypeDefinition } from "./types"; + +import { + CONTENT_PERMISSIONS, + CONTENT_SYSTEM_FIELDS, + RESERVED_FILTER_KEYS, +} from "./const"; +import { ContentEngineError } from "./errors"; + +/** A definition plus the plugin that registered it. */ +export interface RegisteredContentType { + definition: AnyContentTypeDefinition; + pluginId: string; +} + +const describe = (entry: RegisteredContentType): string => + `${entry.pluginId} -> ${entry.definition.id}`; + +/** + * Validates a set of content types coming from one or more plugins. + * + * Runs at boot (or at plugin build time), never per request, so a + * misconfiguration fails loudly and immediately. Returns the entries sorted by + * id so registries stay deterministic across processes. + */ +export const validateContentTypes = ( + entries: RegisteredContentType[], +): RegisteredContentType[] => { + const byId = new Map(); + const byTable = new Map(); + const byPermission = new Map(); + + for (const entry of entries) { + const { definition, pluginId } = entry; + + const duplicateId = byId.get(definition.id); + if (duplicateId) { + throw new ContentEngineError( + `Duplicate content type id, registered by both ${describe(duplicateId)} and ${describe(entry)}.`, + { contentTypeId: definition.id }, + ); + } + byId.set(definition.id, entry); + + const duplicateTable = byTable.get(definition.tableName); + if (duplicateTable) { + throw new ContentEngineError( + `Table "${definition.tableName}" is claimed by both ${describe(duplicateTable)} and ${describe(entry)}.`, + { contentTypeId: definition.id }, + ); + } + byTable.set(definition.tableName, entry); + + // Permission modules are scoped per plugin, so only a collision inside one + // plugin is ambiguous. + const permissionKey = `${pluginId}:${definition.permissionModule}`; + const duplicatePermission = byPermission.get(permissionKey); + if (duplicatePermission) { + throw new ContentEngineError( + `Permission module "${definition.permissionModule}" is derived by both ${describe(duplicatePermission)} and ${describe(entry)}. Set \`admin.permissionModule\` on one of them.`, + { contentTypeId: definition.id }, + ); + } + byPermission.set(permissionKey, entry); + + assertFilterKeys(definition); + } + + return [...entries].sort((a, b) => + a.definition.id.localeCompare(b.definition.id), + ); +}; + +const reservedFilterKeys: readonly string[] = RESERVED_FILTER_KEYS; + +/** + * A field named `search`, `cursor`, `first`, ... would shadow a pagination + * query parameter on the generated list route. + */ +const assertFilterKeys = (definition: AnyContentTypeDefinition): void => { + const fields = definition.fields; + const clash = Object.keys(fields).find(name => + reservedFilterKeys.includes(name), + ); + + if (clash !== undefined) { + throw new ContentEngineError( + `Field "${clash}" collides with the "${clash}" pagination query parameter. Rename the field.`, + { contentTypeId: definition.id }, + ); + } +}; + +export const findContentTypeById = ( + entries: readonly RegisteredContentType[], + id: string, +): RegisteredContentType | undefined => + entries.find(entry => entry.definition.id === id); + +/** `example.article` -> `example/article`, for `/admin/content/[...slug]`. */ +export const contentTypeToPath = (id: string): string => + id.split(".").join("/"); + +/** `["example", "article"]` -> `example.article`. */ +export const pathToContentTypeId = (slug: readonly string[]): string => + slug.join("."); + +/** `/admin/content/example/article` */ +export const contentAdminHref = (id: string): string => + `/admin/content/${contentTypeToPath(id)}`; + +/** + * The four permissions every content type gets. `can_view` gates the list and + * the nav item; the writes depend on it so a role cannot create rows it cannot + * see. + */ +export const contentPermissionEntries = (): PermissionStaffEntryInput[] => [ + CONTENT_PERMISSIONS.view, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.create, + }, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.edit, + }, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.delete, + }, +]; + +/** + * Merges the derived content permissions into a plugin's `permissionStaff`. + * An explicitly declared module always wins, so a plugin can still hand-tune + * the permissions of a generated content type. + */ +export const withContentPermissions = ( + permissionStaff: PermissionStaffConfig | undefined, + entries: readonly RegisteredContentType[], +): PermissionStaffConfig | undefined => { + if (entries.length === 0) return permissionStaff; + + const admin: PermissionStaffModulesInput = { ...permissionStaff?.admin }; + + for (const { definition } of entries) { + if (admin[definition.permissionModule]) continue; + admin[definition.permissionModule] = contentPermissionEntries(); + } + + return { ...permissionStaff, admin }; +}; + +/** Column names a generated route may order by. */ +export const orderableColumns = ( + definition: AnyContentTypeDefinition, +): string[] => [ + ...definition.admin.list.orderableFields, + ...CONTENT_SYSTEM_FIELDS, +]; diff --git a/packages/vitnode/src/content/schemas.test.ts b/packages/vitnode/src/content/schemas.test.ts new file mode 100644 index 000000000..da23d6bf9 --- /dev/null +++ b/packages/vitnode/src/content/schemas.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +const { schemas } = testArticleContentType; + +const valid = { category: 1, title: "Hello world" }; + +describe("generated schemas", () => { + describe("create", () => { + it("accepts the required fields alone", () => { + expect(schemas.create.safeParse(valid).success).toBe(true); + }); + + it("applies declared defaults, matching the column defaults", () => { + expect(schemas.create.parse(valid)).toMatchObject({ + featured: false, + status: "draft", + views: 0, + }); + }); + + it("rejects a missing required field", () => { + expect(schemas.create.safeParse({ title: "Hello world" }).success).toBe( + false, + ); + }); + + it("rejects unknown keys instead of stripping them", () => { + const result = schemas.create.safeParse({ ...valid, slug: "nope" }); + + expect(result.success).toBe(false); + }); + + it("rejects the generated system columns", () => { + for (const key of ["id", "createdAt", "updatedAt"]) { + expect(schemas.create.safeParse({ ...valid, [key]: 1 }).success).toBe( + false, + ); + } + }); + + it("enforces the declared string bounds", () => { + expect(schemas.create.safeParse({ ...valid, title: "ab" }).success).toBe( + false, + ); + expect( + schemas.create.safeParse({ ...valid, title: "a".repeat(201) }).success, + ).toBe(false); + }); + + it("enforces the declared number bounds", () => { + expect(schemas.create.safeParse({ ...valid, views: -1 }).success).toBe( + false, + ); + expect(schemas.create.safeParse({ ...valid, views: 1.5 }).success).toBe( + false, + ); + }); + + it("keeps nullable and optional distinct", () => { + // `excerpt` is nullable, so `null` is a value... + expect( + schemas.create.safeParse({ ...valid, excerpt: null }).success, + ).toBe(true); + // ...but `title` is not. + expect(schemas.create.safeParse({ ...valid, title: null }).success).toBe( + false, + ); + }); + + it("takes dateTime as an ISO 8601 string", () => { + expect( + schemas.create.safeParse({ + ...valid, + publishedAt: "2026-08-02T10:00:00.000Z", + }).success, + ).toBe(true); + expect( + schemas.create.safeParse({ ...valid, publishedAt: "2026-08-02" }) + .success, + ).toBe(false); + }); + + it("rejects a value outside the enum", () => { + expect( + schemas.create.safeParse({ ...valid, status: "nope" }).success, + ).toBe(false); + }); + + it("rejects a non-positive relation identifier", () => { + expect(schemas.create.safeParse({ ...valid, category: 0 }).success).toBe( + false, + ); + }); + }); + + describe("update", () => { + it("accepts a single field", () => { + expect(schemas.update.safeParse({ title: "Updated" }).success).toBe(true); + }); + + it("rejects an empty payload", () => { + expect(schemas.update.safeParse({}).success).toBe(false); + }); + + it("still rejects unknown keys and bad values", () => { + expect(schemas.update.safeParse({ slug: "nope" }).success).toBe(false); + expect(schemas.update.safeParse({ views: -1 }).success).toBe(false); + }); + + it("never re-applies create defaults, so a partial update cannot reset a column", () => { + expect(schemas.update.parse({ title: "Updated" })).toEqual({ + title: "Updated", + }); + }); + }); + + describe("select", () => { + it("describes the API response, dates included", () => { + const row = { + author: null, + category: 1, + createdAt: new Date(), + excerpt: null, + featured: false, + id: 1, + publishedAt: null, + status: "draft", + title: "Hello world", + updatedAt: new Date(), + views: 0, + }; + + expect(schemas.select.safeParse(row).success).toBe(true); + }); + }); + + describe("order", () => { + it("allows the declared orderable fields and the system columns", () => { + for (const orderBy of [ + "title", + "status", + "createdAt", + "updatedAt", + "id", + ]) { + expect(schemas.order.safeParse({ orderBy }).success).toBe(true); + } + }); + + it("rejects a column that is not allowlisted", () => { + expect(schemas.order.safeParse({ orderBy: "views" }).success).toBe(false); + expect( + schemas.order.safeParse({ orderBy: "id; drop table" }).success, + ).toBe(false); + }); + + it("only allows asc and desc", () => { + expect(schemas.order.safeParse({ order: "sideways" }).success).toBe( + false, + ); + }); + }); + + describe("filters", () => { + it("exposes only filterable fields", () => { + // `excerpt` is a textarea: it is searchable, not equality-filterable. + expect(Object.keys(schemas.filters.shape).sort()).toEqual([ + "author", + "category", + "featured", + "status", + "title", + "views", + ]); + }); + + it("parses query-string values", () => { + expect( + schemas.filters.parse({ category: "3", featured: "true" }), + ).toMatchObject({ category: 3, featured: "true" }); + }); + + it("rejects an enum filter outside the declared values", () => { + expect(schemas.filters.safeParse({ status: "nope" }).success).toBe(false); + }); + }); + + describe("params", () => { + it("coerces the identifier from the path", () => { + expect(schemas.params.parse({ id: "42" })).toEqual({ id: 42 }); + }); + }); + + describe("form", () => { + it("survives z.toJSONSchema, which AutoForm runs on every schema", () => { + // `z.date()` throws here, which is why the form variant exists at all. + expect(() => z.toJSONSchema(schemas.form)).not.toThrow(); + }); + + it("exposes the declared form fields with their defaults", () => { + const json = z.toJSONSchema(schemas.form); + + expect(Object.keys(json.properties ?? {})).toEqual( + testArticleContentType.admin.form.fields, + ); + expect(json.properties?.status).toMatchObject({ default: "draft" }); + }); + + it("honours an explicit form field list", () => { + const definition = defineContentType({ + id: "test.formsubset", + tableName: "test_form_subsets", + fields: { + title: field.text({ required: true }), + internalNote: field.textarea({ nullable: true }), + }, + admin: { + label: { plural: "Subsets", singular: "Subset" }, + form: { fields: ["title"] }, + }, + }); + + expect(Object.keys(definition.schemas.form.shape)).toEqual(["title"]); + }); + }); +}); diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts new file mode 100644 index 000000000..9d87159e5 --- /dev/null +++ b/packages/vitnode/src/content/schemas.ts @@ -0,0 +1,254 @@ +import { z } from "zod"; + +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentFieldDescriptor, + ContentFieldMap, + ContentSelect, + ContentUpdateInput, + ResolvedContentAdminConfig, +} from "./types"; + +import { CONTENT_SYSTEM_FIELDS } from "./const"; + +export interface ContentSchemas { + /** Request body for create. Rejects unknown keys and system columns. */ + create: z.ZodType>; + /** Query-string filters, restricted to filterable fields. */ + filters: z.ZodObject; + /** + * The create/update shape as `AutoForm` needs it: a plain `ZodObject` with + * no `z.date()` anywhere, because `AutoForm` runs `z.toJSONSchema` on it and + * Zod v4 throws on dates. + */ + form: z.ZodObject; + /** `orderBy` allowlist plus direction. */ + order: z.ZodObject; + /** Path parameters for the detail/update/delete routes. */ + params: z.ZodObject<{ id: z.ZodCoercedNumber }>; + /** API response shape. */ + select: z.ZodType>; + /** + * The same shape as `select`, but left as a `ZodObject` so the generated + * routes can `.extend(...)` it with the joined relation labels. + */ + selectObject: z.ZodObject; + /** Request body for update. Every field optional, but never empty. */ + update: z.ZodType>; +} + +const textSchema = (fieldValue: { + maxLength?: number; + minLength?: number; +}): z.ZodString => { + let schema = z.string(); + if (fieldValue.minLength !== undefined) { + schema = schema.min(fieldValue.minLength); + } + if (fieldValue.maxLength !== undefined) { + schema = schema.max(fieldValue.maxLength); + } + + return schema; +}; + +const numberSchema = (fieldValue: { + integer: boolean; + max?: number; + min?: number; +}): z.ZodNumber => { + let schema = fieldValue.integer ? z.number().int() : z.number(); + if (fieldValue.min !== undefined) schema = schema.min(fieldValue.min); + if (fieldValue.max !== undefined) schema = schema.max(fieldValue.max); + + return schema; +}; + +/** Row identifiers are always positive integers, whatever the field kind. */ +const referenceSchema = (): z.ZodNumber => z.number().int().positive(); + +/** The value as it leaves the API. */ +const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { + switch (fieldValue.kind) { + case "boolean": + return z.boolean(); + case "dateTime": + return z.date(); + case "enum": + return z.enum(fieldValue.values); + case "number": + return numberSchema(fieldValue); + case "relation": + case "user": + return referenceSchema(); + case "text": + case "textarea": + return textSchema(fieldValue); + } +}; + +/** The value as it arrives from a client. `dateTime` is an ISO 8601 string. */ +const baseInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { + if (fieldValue.kind === "dateTime") return z.iso.datetime(); + + return baseSelectSchema(fieldValue); +}; + +const applyNullable = ( + schema: z.ZodType, + fieldValue: ContentFieldDescriptor, +): z.ZodType => (fieldValue.nullable ? schema.nullable() : schema); + +/** + * `required` -> present. Otherwise a declared default becomes a Zod default so + * the value the API writes always matches the column default, and everything + * else is simply optional. + */ +const applyPresence = ( + schema: z.ZodType, + fieldValue: ContentFieldDescriptor, +): z.ZodType => { + if (fieldValue.required) return schema; + + if ( + fieldValue.kind !== "dateTime" && + fieldValue.kind !== "relation" && + fieldValue.kind !== "user" && + fieldValue.defaultValue !== undefined + ) { + return schema.default(fieldValue.defaultValue); + } + + return schema.optional(); +}; + +const inputShape = ( + fields: ContentFieldMap, + names: readonly string[], +): z.ZodRawShape => + Object.fromEntries( + names.map(name => { + const fieldValue = fields[name]; + + return [ + name, + applyPresence( + applyNullable(baseInputSchema(fieldValue), fieldValue), + fieldValue, + ), + ]; + }), + ); + +/** + * Update never applies create defaults: `PUT { title }` must leave `status`, + * `views` and every other defaulted column alone, not silently reset them to + * the column default. Every field is simply optional here. + */ +const updateShape = ( + fields: ContentFieldMap, + names: readonly string[], +): z.ZodRawShape => + Object.fromEntries( + names.map(name => { + const fieldValue = fields[name]; + + return [ + name, + applyNullable(baseInputSchema(fieldValue), fieldValue).optional(), + ]; + }), + ); + +const FILTERABLE_KINDS = new Set([ + "boolean", + "enum", + "number", + "relation", + "text", + "user", +]); + +/** + * Filters arrive as query-string values, so everything is parsed from a string. + * Only allowlisted kinds get an entry - an unknown filter key is rejected by + * the route rather than silently ignored. + */ +const filterShape = (fields: ContentFieldMap): z.ZodRawShape => + Object.fromEntries( + Object.entries(fields) + .filter(([, fieldValue]) => FILTERABLE_KINDS.has(fieldValue.kind)) + .map(([name, fieldValue]) => { + switch (fieldValue.kind) { + case "boolean": + return [name, z.enum(["true", "false"]).optional()]; + case "enum": + return [name, z.enum(fieldValue.values).optional()]; + case "number": + case "relation": + case "user": + return [name, z.coerce.number().optional()]; + default: + return [name, z.string().optional()]; + } + }), + ); + +/** + * Takes only the two pieces it needs rather than a whole definition, so + * `defineContentType` can call it before the definition object exists and + * without re-widening its field map. + */ +export const buildContentSchemas = ({ + admin, + fields, +}: { + admin: ResolvedContentAdminConfig; + fields: ContentFieldMap; +}): ContentSchemas => { + const fieldNames = Object.keys(fields); + + const selectShape: z.ZodRawShape = { + id: z.number(), + ...Object.fromEntries( + fieldNames.map(name => [ + name, + applyNullable(baseSelectSchema(fields[name]), fields[name]), + ]), + ), + createdAt: z.date(), + updatedAt: z.date(), + }; + + // `strictObject` blocks mass assignment: an unknown key is an error, not + // something quietly stripped. System columns are absent from the shape, so + // they can never be set from a request. + const create = z.strictObject(inputShape(fields, fieldNames)); + const update = z + .strictObject(updateShape(fields, fieldNames)) + .refine(value => Object.keys(value).length > 0, { + message: "Provide at least one field to update.", + }); + + const orderable = [...admin.list.orderableFields, ...CONTENT_SYSTEM_FIELDS]; + const selectObject = z.object(selectShape); + + return { + // The shapes are assembled in a loop, so their Zod types are erased. + // Re-attaching the descriptor-derived types here means every consumer - + // route handler, service, AdminCP - stays fully typed with no further + // casts. `buildContentSchemas` is covered by `schemas.test-d.ts`. + create: create as unknown as z.ZodType>, + filters: z.object(filterShape(fields)), + form: z.object(inputShape(fields, admin.form.fields)), + order: z.object({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(orderable as [string, ...string[]]).optional(), + }), + params: z.object({ id: z.coerce.number() }), + select: selectObject as unknown as z.ZodType>, + selectObject, + update: update as unknown as z.ZodType>, + }; +}; diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts new file mode 100644 index 000000000..d967f1ae2 --- /dev/null +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -0,0 +1,133 @@ +import type { AnyPgColumn, PgColumnBuilderBase } from "drizzle-orm/pg-core"; + +import { + boolean, + doublePrecision, + integer, + serial, + text, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; + +import type { ContentFieldDescriptor } from "../types"; + +import { + CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_TEXT_DEFAULT_LENGTH, +} from "../const"; +import { ContentEngineError } from "../errors"; + +export type ColumnReferenceThunk = () => AnyPgColumn; + +/** + * The three columns every content table gets, matching the conventions used by + * all 22 core tables: a `serial` primary key, `defaultNow()` on `createdAt`, + * and `defaultNow().$onUpdate(...)` on `updatedAt`. + */ +export const buildSystemColumns = (): Record => ({ + id: serial().primaryKey(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +/** + * Applies `NOT NULL` and the column default. + * + * Written as a generic over the concrete builder so each `default(...)` call + * sees the narrowed value type - a single shared `default()` at the end would + * have to accept the union of every field kind's value. + */ +const withModifiers = < + TBuilder extends { + default: (value: TValue) => TBuilder; + notNull: () => TBuilder; + }, + TValue, +>( + builder: TBuilder, + { defaultValue, nullable }: { defaultValue?: TValue; nullable: boolean }, +): TBuilder => { + const withNull = nullable ? builder : builder.notNull(); + + return defaultValue === undefined ? withNull : withNull.default(defaultValue); +}; + +/** + * Compiles one field descriptor into a Drizzle column builder. + * + * `nullable` drives `NOT NULL`, and a declared `defaultValue` becomes the + * column default so Postgres and the generated Zod schema agree. + */ +export const buildContentColumn = ({ + contentTypeId, + fieldValue, + name, + reference, +}: { + contentTypeId: string; + fieldValue: ContentFieldDescriptor; + name: string; + reference?: ColumnReferenceThunk; +}): PgColumnBuilderBase => { + const { nullable } = fieldValue; + + switch (fieldValue.kind) { + case "boolean": + return withModifiers(boolean(), { + defaultValue: fieldValue.defaultValue, + nullable, + }); + case "dateTime": { + const column = nullable ? timestamp() : timestamp().notNull(); + + return fieldValue.defaultNow ? column.defaultNow() : column; + } + case "enum": + return withModifiers( + varchar({ + enum: fieldValue.values as [string, ...string[]], + length: fieldValue.length ?? CONTENT_ENUM_DEFAULT_LENGTH, + }), + { defaultValue: fieldValue.defaultValue, nullable }, + ); + case "number": + return withModifiers(fieldValue.integer ? integer() : doublePrecision(), { + defaultValue: fieldValue.defaultValue, + nullable, + }); + case "relation": + case "user": { + if (!reference) { + throw new ContentEngineError( + `Field "${name}" is a ${fieldValue.kind} reference but no target column was resolved.`, + { contentTypeId }, + ); + } + + const column = integer().references(reference, { + onDelete: fieldValue.onDelete, + // Identifiers are `serial`, so an update is only ever a repair; cascade + // keeps children pointing at the right row either way. + onUpdate: "cascade", + }); + + return nullable ? column : column.notNull(); + } + case "text": + return withModifiers( + varchar({ + length: fieldValue.maxLength ?? CONTENT_TEXT_DEFAULT_LENGTH, + }), + { defaultValue: fieldValue.defaultValue, nullable }, + ); + case "textarea": + return withModifiers(text(), { + defaultValue: fieldValue.defaultValue, + nullable, + }); + } +}; diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts new file mode 100644 index 000000000..d4c3df7bf --- /dev/null +++ b/packages/vitnode/src/content/server/emit.ts @@ -0,0 +1,39 @@ +import type { Context } from "hono"; + +import type { VitNodeEventName } from "../../api/models/events"; +import type { + ContentCreatedPayload, + ContentDeletedPayload, + ContentEventAction, + ContentUpdatedPayload, +} from "../events"; +import type { AnyContentTypeDefinition } from "../types"; + +import { contentEventName } from "../events"; + +type ContentPayload = + | ContentCreatedPayload + | ContentDeletedPayload + | ContentUpdatedPayload; + +/** + * Emits a content event after a successful write. + * + * Generated routes work with `AnyContentTypeDefinition`, so the event name is + * only a `string` at this point. Plugins get the real literal types from + * `ContentEventsFor` at their `declare module` site; this is the single place + * where the runtime name is reconciled with the global event map. + * + * Call it only once the database write has returned - never inside a + * transaction callback. + */ +export const emitContentEvent = async ( + c: Context, + definition: AnyContentTypeDefinition, + action: ContentEventAction, + payload: ContentPayload, +): Promise => { + const name = contentEventName(definition.id, action) as VitNodeEventName; + + await c.get("events").emit(name, payload); +}; diff --git a/packages/vitnode/src/content/server/http-errors.test.ts b/packages/vitnode/src/content/server/http-errors.test.ts new file mode 100644 index 000000000..28745da1e --- /dev/null +++ b/packages/vitnode/src/content/server/http-errors.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +import { HTTPException } from "hono/http-exception"; +import { describe, expect, it } from "vitest"; + +import { withHttpErrors } from "./http-errors"; + +const pgError = (code: string) => + Object.assign(new Error("driver said no"), { code }); + +/** How Drizzle actually surfaces a driver failure. */ +const drizzleWrapped = (code: string) => + Object.assign(new Error("Failed query"), { cause: pgError(code) }); + +const reject = async (error: unknown): Promise => { + await Promise.resolve(); + throw error; +}; + +const statusOf = async ( + error: unknown, + action: "create" | "delete" | "update", +) => { + try { + await withHttpErrors(action, async () => await reject(error)); + } catch (thrown) { + if (thrown instanceof HTTPException) return thrown.status; + throw thrown; + } + + return 200; +}; + +describe("withHttpErrors", () => { + it("passes a successful result through", async () => { + await expect( + withHttpErrors("create", async () => await Promise.resolve("ok")), + ).resolves.toBe("ok"); + }); + + it("maps a restricted delete to 409", async () => { + await expect(statusOf(pgError("23503"), "delete")).resolves.toBe(409); + }); + + it("maps a missing relation on write to 400", async () => { + await expect(statusOf(pgError("23503"), "create")).resolves.toBe(400); + await expect(statusOf(pgError("23503"), "update")).resolves.toBe(400); + }); + + it("maps a unique violation to 409", async () => { + await expect(statusOf(pgError("23505"), "create")).resolves.toBe(409); + }); + + it("maps a not-null violation to 400", async () => { + await expect(statusOf(pgError("23502"), "create")).resolves.toBe(400); + }); + + it("unwraps the code Drizzle hides behind `cause`", async () => { + // Drizzle throws `DrizzleQueryError`, whose own `code` is undefined - the + // real Postgres error sits on `cause`. + await expect(statusOf(drizzleWrapped("23503"), "delete")).resolves.toBe( + 409, + ); + await expect(statusOf(drizzleWrapped("23505"), "create")).resolves.toBe( + 409, + ); + }); + + it("never leaks the driver message", async () => { + try { + await withHttpErrors( + "delete", + async () => await reject(drizzleWrapped("23503")), + ); + } catch (error) { + expect((error as HTTPException).message).not.toContain("driver said no"); + } + }); + + it("rethrows anything it does not recognise, for the 500 handler", async () => { + const unknown = new Error("boom"); + + await expect( + withHttpErrors("create", async () => await reject(unknown)), + ).rejects.toBe(unknown); + }); + + it("passes an HTTPException through untouched", async () => { + const notFound = new HTTPException(404); + + await expect( + withHttpErrors("update", async () => await reject(notFound)), + ).rejects.toBe(notFound); + }); +}); diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts new file mode 100644 index 000000000..f21ac347f --- /dev/null +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -0,0 +1,67 @@ +import { HTTPException } from "hono/http-exception"; + +/** Postgres error codes the engine translates into a useful HTTP status. */ +const FOREIGN_KEY_VIOLATION = "23503"; +const UNIQUE_VIOLATION = "23505"; +const NOT_NULL_VIOLATION = "23502"; + +/** + * Digs the Postgres error code out of whatever the driver threw. + * + * Drizzle wraps driver failures in a `DrizzleQueryError` whose own `code` is + * undefined and whose `cause` holds the real error, so reading `error.code` + * alone would turn every constraint violation into a 500. + */ +const errorCode = (error: unknown, depth = 0): string | undefined => { + if (typeof error !== "object" || error === null || depth > 3) + return undefined; + + const { cause, code } = error as { cause?: unknown; code?: unknown }; + if (typeof code === "string" && code !== "") return code; + + return errorCode(cause, depth + 1); +}; + +/** + * 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. + */ +export const rethrowAsHttpError = ( + error: unknown, + { action }: { action: "create" | "delete" | "update" }, +): never => { + switch (errorCode(error)) { + case FOREIGN_KEY_VIOLATION: + throw new HTTPException(action === "delete" ? 409 : 400, { + message: + action === "delete" + ? "This record is still referenced by other content." + : "A related record does not exist.", + }); + 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.", + }); + default: + throw error; + } +}; + +/** Runs a write and maps any constraint failure onto an HTTP status. */ +export const withHttpErrors = async ( + action: "create" | "delete" | "update", + run: () => Promise, +): Promise => { + try { + return await run(); + } catch (error) { + if (error instanceof HTTPException) throw error; + + return rethrowAsHttpError(error, { action }); + } +}; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts new file mode 100644 index 000000000..cfa76be02 --- /dev/null +++ b/packages/vitnode/src/content/server/index.ts @@ -0,0 +1,45 @@ +/** + * Universal Content Engine - server surface. + * + * Imports Drizzle, so this must never be reachable from a client component. + * It must also never import `server-only`: that package's `default` export + * throws under plain Node, and both `apps/api` and `drizzle-kit` load these + * modules in plain Node. + */ +export { buildContentColumn, buildSystemColumns } from "./column-builders"; +export type { ColumnReferenceThunk } from "./column-builders"; +export { emitContentEvent } from "./emit"; +export { rethrowAsHttpError, withHttpErrors } from "./http-errors"; +export { createContentModel } from "./model"; +export type { ContentModel } from "./model"; +export { buildContentAdminModule } from "./module"; +export { + buildFilterCondition, + buildOrderColumn, + buildSearchCondition, + diffChangedFields, + escapeLikePattern, + toColumnValues, +} from "./query"; +export { buildContentRoutes } from "./routes"; +export { createContentService } from "./service"; +export type { + ContentDatabase, + ContentFindManyArgs, + ContentLabels, + ContentListRow, + ContentPageInfo, + ContentService, + ContentServiceOptions, + ContentUpdateResult, +} from "./service"; +export { contentTableColumns, createContentTable } from "./table"; +export type { + ContentColumnBuilder, + ContentColumnBuilders, + ContentColumnName, + ContentReferences, + ContentSystemColumnBuilders, + ContentTable, + ContentTableFor, +} from "./types"; diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts new file mode 100644 index 000000000..3fa304f7f --- /dev/null +++ b/packages/vitnode/src/content/server/model.ts @@ -0,0 +1,73 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import type { ContentSchemas } from "../schemas"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentService } from "./service"; +import type { + ContentColumnName, + ContentReferences, + ContentTableFor, +} from "./types"; + +import { createContentService } from "./service"; +import { contentTableColumns, createContentTable } from "./table"; + +export interface ContentModel { + /** Column name -> Drizzle column, for filters, ordering and custom queries. */ + columns: Record, PgColumn>; + definition: TDefinition; + /** The definition's schemas, re-typed for this concrete content type. */ + schemas: ContentSchemas; + /** Typed repository bound to the request's database handle. */ + service: (c: Context) => ContentService; + /** The generated `pgTable`. Export it so Drizzle Kit can find it. */ + table: ContentTableFor; +} + +/** + * Turns a content type definition into its database model. + * + * Belongs in the plugin's `src/database/.ts`, next to the table export + * Drizzle Kit globs: + * + * ```ts + * export const articleContent = createContentModel(articleContentType, { + * references: { category: () => example_categories.id }, + * }); + * + * export const example_articles = articleContent.table; + * ``` + * + * Server-only. Never import it from a client component - and never add + * `server-only` to this module either, since `apps/api` and `drizzle-kit` both + * load it in plain Node, where that package throws. + */ +export const createContentModel = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + options: { references?: ContentReferences } = {}, +): ContentModel => { + const table = createContentTable(definition, options); + const columns = contentTableColumns(definition, table); + + return { + columns, + definition, + // `ContentTypeDefinition` declares `schemas` against its own type + // parameters, and reading it through the `AnyContentTypeDefinition` + // constraint widens the row types back to the base field map. The object + // was built from this very definition, so this restores what TypeScript + // lost rather than asserting anything new. + schemas: definition.schemas, + service: (c: Context) => + createContentService({ + c, + columns, + definition, + table, + }), + table, + }; +}; diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts new file mode 100644 index 000000000..633ae0c27 --- /dev/null +++ b/packages/vitnode/src/content/server/module.ts @@ -0,0 +1,50 @@ +import type { BuildModuleReturn } from "../../api/lib/module"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; + +import { buildModule } from "../../api/lib/module"; +import { buildContentRoutes } from "./routes"; + +/** + * Builds the generated CRUD module for a plugin's content types. + * + * Nest it inside the plugin's own `admin` module - Hono only serves the last + * sub-app mounted at a given prefix, so the engine must never add a second + * `/admin` of its own: + * + * ```ts + * export const adminModule = buildModule({ + * pluginId: CONFIG_PLUGIN.pluginId, + * name: "admin", + * routes: [], + * modules: [buildContentAdminModule({ pluginId, contentTypes: [articleContent] })], + * }); + * ``` + * + * That yields `/api/{pluginId}/admin/content/{module}`. `buildApiPlugin` walks + * the module tree, so the content types registered here also drive the + * registry and the derived staff permissions - they are declared exactly once. + */ +export const buildContentAdminModule =

({ + contentTypes, + pluginId, +}: { + contentTypes: ContentModel[]; + pluginId: P; +}): BuildModuleReturn => { + const modules = contentTypes.map(model => + buildModule({ + pluginId, + name: model.definition.permissionModule, + routes: buildContentRoutes(model, { pluginId }), + }), + ); + + return buildModule({ + pluginId, + name: "content", + routes: [], + modules, + contentTypes: contentTypes.map(model => model.definition), + }); +}; diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts new file mode 100644 index 000000000..d5125c121 --- /dev/null +++ b/packages/vitnode/src/content/server/query.test.ts @@ -0,0 +1,229 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import { ContentEngineError } from "../errors"; +import { + buildFilterCondition, + buildOrderColumn, + buildSearchCondition, + diffChangedFields, + escapeLikePattern, + toColumnValues, +} from "./query"; +import { contentTableColumns, createContentTable } from "./table"; + +const categories = createContentTable( + testArticleContentType.fields.category.kind === "relation" + ? testArticleContentType.fields.category.target() + : testArticleContentType, +); +const table = createContentTable(testArticleContentType, { + references: { category: () => categories.id }, +}); +const columns = contentTableColumns(testArticleContentType, table); +const fields = testArticleContentType.fields; +const contentTypeId = testArticleContentType.id; + +/** Pulls the bound `ilike` patterns out of a built SQL condition. */ +const patternsIn = (condition: unknown): string[] => { + if (typeof condition === "string") return [condition]; + if ( + condition && + typeof condition === "object" && + "queryChunks" in condition && + Array.isArray(condition.queryChunks) + ) { + return condition.queryChunks.flatMap(patternsIn); + } + + return []; +}; + +describe("escapeLikePattern", () => { + it.each([ + ["100%", "100\\%"], + ["a_b", "a\\_b"], + ["back\\slash", "back\\\\slash"], + ["plain", "plain"], + ])("escapes %s", (input, expected) => { + expect(escapeLikePattern(input)).toBe(expected); + }); +}); + +describe("buildSearchCondition", () => { + it("returns nothing without a term or columns", () => { + expect(buildSearchCondition([columns.title], undefined)).toBeUndefined(); + expect(buildSearchCondition([columns.title], " ")).toBeUndefined(); + expect(buildSearchCondition([], "hello")).toBeUndefined(); + }); + + it("escapes wildcards so a literal % cannot match every row", () => { + expect(patternsIn(buildSearchCondition([columns.title], "100%"))).toEqual([ + "%100\\%%", + ]); + }); + + it("passes a plain term through unescaped", () => { + expect(patternsIn(buildSearchCondition([columns.title], "hello"))).toEqual([ + "%hello%", + ]); + }); + + it("searches every given column", () => { + expect( + patternsIn(buildSearchCondition([columns.title, columns.excerpt], "hi")), + ).toEqual(["%hi%", "%hi%"]); + }); +}); + +describe("buildFilterCondition", () => { + it("ignores undefined values", () => { + expect( + buildFilterCondition({ + columns, + contentTypeId, + fields, + filters: { status: undefined }, + }), + ).toBeUndefined(); + }); + + it("builds an equality condition per filter", () => { + const condition = buildFilterCondition({ + columns, + contentTypeId, + fields, + filters: { category: 3, status: "draft" }, + }); + + expect(condition).toBeDefined(); + }); + + it("coerces the string form of a boolean filter", () => { + expect( + buildFilterCondition({ + columns, + contentTypeId, + fields, + filters: { featured: "true" }, + }), + ).toBeDefined(); + }); + + it("rejects a filter that is not a declared field", () => { + expect(() => + buildFilterCondition({ + columns, + contentTypeId, + fields, + filters: { "id; drop table": 1 }, + }), + ).toThrow(ContentEngineError); + }); +}); + +describe("buildOrderColumn", () => { + const orderable = ["title", "status", "createdAt", "updatedAt", "id"]; + + it("falls back to the default when nothing is requested", () => { + expect( + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: undefined, + orderable, + }), + ).toBe(columns.updatedAt); + }); + + it("resolves an allowlisted column", () => { + expect( + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: "title", + orderable, + }), + ).toBe(columns.title); + }); + + it("rejects a column outside the allowlist", () => { + expect(() => + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: "views", + orderable, + }), + ).toThrow(/Cannot order by "views"/); + }); + + it("never lets a raw identifier through", () => { + expect(() => + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: "id) --", + orderable, + }), + ).toThrow(ContentEngineError); + }); +}); + +describe("diffChangedFields", () => { + const current = { + publishedAt: new Date("2026-01-01T00:00:00.000Z"), + status: "draft", + title: "Hello", + views: 3, + }; + + it("reports only the keys that actually moved", () => { + expect( + diffChangedFields(current, { status: "draft", title: "Changed" }), + ).toEqual(["title"]); + }); + + it("ignores undefined values", () => { + expect(diffChangedFields(current, { title: undefined })).toEqual([]); + }); + + it("compares dates by instant, not identity", () => { + expect( + diffChangedFields(current, { publishedAt: "2026-01-01T00:00:00.000Z" }), + ).toEqual([]); + expect( + diffChangedFields(current, { publishedAt: "2026-02-01T00:00:00.000Z" }), + ).toEqual(["publishedAt"]); + }); + + it("treats clearing a date as a change", () => { + expect(diffChangedFields(current, { publishedAt: null })).toEqual([ + "publishedAt", + ]); + }); +}); + +describe("toColumnValues", () => { + it("turns ISO strings into Dates for dateTime fields only", () => { + const result = toColumnValues(fields, { + publishedAt: "2026-08-02T10:00:00.000Z", + title: "2026-08-02T10:00:00.000Z", + }); + + expect(result.publishedAt).toBeInstanceOf(Date); + expect(result.title).toBe("2026-08-02T10:00:00.000Z"); + }); + + it("leaves null alone", () => { + expect( + toColumnValues(fields, { publishedAt: null }).publishedAt, + ).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts new file mode 100644 index 000000000..456d6d55e --- /dev/null +++ b/packages/vitnode/src/content/server/query.ts @@ -0,0 +1,147 @@ +import type { SQL } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; + +import { and, eq, ilike, or } from "drizzle-orm"; + +import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; + +import { ContentEngineError } from "../errors"; + +/** + * Escapes the `LIKE` wildcards so a search for "100%" matches the literal text + * rather than every row. Backslash is Postgres' default escape character. + */ +export const escapeLikePattern = (value: string): string => + value.replace(/[\\%_]/g, match => `\\${match}`); + +export const buildSearchCondition = ( + columns: readonly PgColumn[], + term: string | undefined, +): SQL | undefined => { + const trimmed = term?.trim(); + if (!columns.length || !trimmed) return undefined; + + const pattern = `%${escapeLikePattern(trimmed)}%`; + + return or(...columns.map(column => ilike(column, pattern))); +}; + +const filterValue = ( + fieldValue: ContentFieldDescriptor, + raw: unknown, +): unknown => { + if (fieldValue.kind === "boolean") return raw === "true" || raw === true; + + return raw; +}; + +/** + * Builds an equality filter from validated query parameters. + * + * Filter keys are looked up in the column map, so a request can never reach a + * SQL identifier: an unknown key is a hard error, not a silently ignored one. + */ +export const buildFilterCondition = ({ + columns, + contentTypeId, + fields, + filters, +}: { + columns: Record; + contentTypeId: string; + fields: ContentFieldMap; + filters: Record; +}): SQL | undefined => { + const conditions: SQL[] = []; + + for (const [name, raw] of Object.entries(filters)) { + if (raw === undefined) continue; + + const fieldValue = fields[name]; + const column = columns[name]; + if (!fieldValue || !column) { + throw new ContentEngineError(`Unknown filter "${name}".`, { + contentTypeId, + }); + } + + conditions.push(eq(column, filterValue(fieldValue, raw))); + } + + if (conditions.length === 0) return undefined; + + return conditions.length === 1 ? conditions[0] : and(...conditions); +}; + +/** + * Resolves `orderBy` against the allowlist. The request only ever picks a name + * from the list; the column object itself comes from the model. + */ +export const buildOrderColumn = ({ + columns, + contentTypeId, + fallback, + orderBy, + orderable, +}: { + columns: Record; + contentTypeId: string; + fallback: string; + orderable: readonly string[]; + orderBy: string | undefined; +}): PgColumn => { + const name = orderBy ?? fallback; + + if (!orderable.includes(name)) { + throw new ContentEngineError( + `Cannot order by "${name}". Allowed: ${orderable.join(", ")}.`, + { contentTypeId }, + ); + } + + const column = columns[name]; + if (!column) { + throw new ContentEngineError(`No column named "${name}".`, { + contentTypeId, + }); + } + + return column; +}; + +const sameValue = (current: unknown, next: unknown): boolean => { + if (current instanceof Date) { + if (next === null || next === undefined) return false; + + return current.getTime() === new Date(next as string).getTime(); + } + + return current === next; +}; + +/** + * The keys an update actually changes. Values equal to what is already stored + * are dropped, so `content.*.updated` never reports a field that did not move. + */ +export const diffChangedFields = ( + current: Record, + patch: Record, +): string[] => + Object.keys(patch).filter( + key => patch[key] !== undefined && !sameValue(current[key], patch[key]), + ); + +/** `dateTime` values arrive as ISO strings and have to become `Date` columns. */ +export const toColumnValues = ( + fields: ContentFieldMap, + values: Record, +): Record => + Object.fromEntries( + Object.entries(values).map(([name, value]) => { + if (fields[name]?.kind !== "dateTime" || typeof value !== "string") { + return [name, value]; + } + + return [name, new Date(value)]; + }), + ); diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts new file mode 100644 index 000000000..6c6ac5281 --- /dev/null +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -0,0 +1,431 @@ +// @vitest-environment node +import type { Context, MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; + +let permissionGranted = true; + +// `assertStaffPermission` reads roles out of the database. The routes' job is +// to *call* it with the right module and permission, so the check itself is +// replaced with a switchable verdict. +vi.mock("../../api/lib/check-staff-permission", () => ({ + assertStaffPermission: async () => { + if (!permissionGranted) { + const { HTTPException } = await import("hono/http-exception"); + throw new HTTPException(403, { message: "Forbidden" }); + } + }, +})); + +const categories = createContentModel(testCategoryContentType); +const articles = createContentModel(testArticleContentType, { + references: { category: () => categories.table.id }, +}); + +const PLUGIN_ID = "@vitnode/example"; + +const adminUser = { + avatarColor: "000000", + birthday: null, + createdAt: new Date(), + email: "test@test.com", + emailVerified: true, + id: 1, + language: "en", + name: "Test", + nameCode: "test", + newsletter: false, + roleId: 1, +}; + +interface Harness { + app: OpenAPIHono; + emitted: { name: string; payload: unknown }[]; + service: Record>; +} + +/** + * Mounts the generated routes with the service and permission check stubbed, + * so each test drives the real Hono pipeline (validation, status codes, error + * mapping) without a database. + */ +const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { + const emitted: Harness["emitted"] = []; + const service = { + create: vi.fn(), + delete: vi.fn(), + findById: vi.fn(), + findMany: vi.fn(), + options: vi.fn(), + update: vi.fn(), + }; + + permissionGranted = allow; + vi.spyOn(articles, "service").mockReturnValue(service); + + const app = new OpenAPIHono(); + + // Stands in for `globalMiddleware` + the admin session middleware. + const context: MiddlewareHandler = async (c, next) => { + c.set("events", { + emit: async (name: string, payload: unknown) => { + await Promise.resolve(); + emitted.push({ name, payload }); + }, + } as unknown as Context["var"]["events"]); + c.set("admin", allow ? { user: adminUser } : null); + await next(); + }; + app.use("*", context); + + for (const { handler, route } of buildContentRoutes(articles, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, emitted, service }; +}; + +const json = (body: unknown) => ({ + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, +}); + +const row = { + author: null, + category: 1, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + excerpt: null, + featured: false, + id: 7, + publishedAt: null, + status: "draft" as const, + title: "Hello world", + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + views: 0, +}; + +describe("generated content routes", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe("list", () => { + it("returns edges and pageInfo", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ + edges: [{ ...row, labels: { author: null, category: "News" } }], + pageInfo: { + count: 1, + endCursor: 7, + hasNextPage: false, + hasPreviousPage: false, + startCursor: 7, + totalCount: 1, + }, + }); + + const res = await app.request("/"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + edges: [{ id: 7, labels: { category: "News" } }], + }); + }); + + it("passes pagination and search through to the service", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ edges: [], pageInfo: {} }); + + await app.request("/?first=5&search=hello&cursor=3"); + + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + query: { cursor: "3", first: "5", last: undefined, search: "hello" }, + }), + ); + }); + + it("passes only declared filters through", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ edges: [], pageInfo: {} }); + + await app.request("/?status=published&nope=1"); + + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ filters: { status: "published" } }), + ); + }); + + it("rejects an order column outside the allowlist", async () => { + const { app } = harness(); + + const res = await app.request("/?orderBy=views"); + + expect(res.status).toBe(400); + }); + + it("accepts an allowlisted order column", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ edges: [], pageInfo: {} }); + + const res = await app.request("/?orderBy=title&order=asc"); + + expect(res.status).toBe(200); + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { column: "title", order: "asc" } }), + ); + }); + }); + + describe("detail", () => { + it("returns the row", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(row); + + const res = await app.request("/7"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ id: 7 }); + }); + + it("returns 404 for a missing row", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(null); + + expect((await app.request("/7")).status).toBe(404); + }); + + it("rejects a non-numeric identifier", async () => { + const { app } = harness(); + + expect((await app.request("/abc")).status).toBe(400); + }); + }); + + describe("create", () => { + it("returns 201 and emits after the write", async () => { + const { app, emitted, service } = harness(); + service.create.mockResolvedValue(row); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, title: "Hello world" }), + }); + + expect(res.status).toBe(201); + expect(emitted).toEqual([ + { name: "content.test.article.created", payload: { contentId: 7 } }, + ]); + }); + + it("returns 400 and emits nothing when validation fails", async () => { + const { app, emitted, service } = harness(); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, title: "no" }), + }); + + expect(res.status).toBe(400); + expect(service.create).not.toHaveBeenCalled(); + expect(emitted).toEqual([]); + }); + + it("rejects unknown keys", async () => { + const { app } = harness(); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, slug: "nope", title: "Hello world" }), + }); + + expect(res.status).toBe(400); + }); + + it("rejects an attempt to set a system column", async () => { + const { app } = harness(); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, id: 99, title: "Hello world" }), + }); + + expect(res.status).toBe(400); + }); + + it("maps a foreign key violation to 400 without leaking the driver message", async () => { + const { app, service } = harness(); + service.create.mockRejectedValue( + Object.assign( + new Error('insert violates "example_articles_category_fkey"'), + { + code: "23503", + }, + ), + ); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 999, title: "Hello world" }), + }); + + expect(res.status).toBe(400); + expect(await res.text()).not.toContain("fkey"); + }); + }); + + describe("update", () => { + it("returns 200 and emits the changed fields", async () => { + const { app, emitted, service } = harness(); + service.update.mockResolvedValue({ + changedFields: ["title"], + row: { ...row, title: "Changed" }, + }); + + const res = await app.request("/7", { + method: "PUT", + ...json({ title: "Changed" }), + }); + + expect(res.status).toBe(200); + expect(emitted).toEqual([ + { + name: "content.test.article.updated", + payload: { changedFields: ["title"], contentId: 7 }, + }, + ]); + }); + + it("rejects an empty payload", async () => { + const { app, service } = harness(); + + const res = await app.request("/7", { method: "PUT", ...json({}) }); + + expect(res.status).toBe(400); + expect(service.update).not.toHaveBeenCalled(); + }); + + it("returns 404 for a missing row", async () => { + const { app, service } = harness(); + service.update.mockResolvedValue(null); + + const res = await app.request("/7", { + method: "PUT", + ...json({ title: "Changed" }), + }); + + expect(res.status).toBe(404); + }); + + it("does not emit when nothing actually changed", async () => { + const { app, emitted, service } = harness(); + service.update.mockResolvedValue({ changedFields: [], row }); + + await app.request("/7", { + method: "PUT", + ...json({ title: "Hello world" }), + }); + + expect(emitted).toEqual([]); + }); + }); + + describe("delete", () => { + it("returns 200 and emits after the write", async () => { + const { app, emitted, service } = harness(); + service.delete.mockResolvedValue(row); + + expect((await app.request("/7", { method: "DELETE" })).status).toBe(200); + expect(emitted).toEqual([ + { name: "content.test.article.deleted", payload: { contentId: 7 } }, + ]); + }); + + it("returns 404 for a missing row", async () => { + const { app, service } = harness(); + service.delete.mockResolvedValue(null); + + expect((await app.request("/7", { method: "DELETE" })).status).toBe(404); + }); + + it("maps a restricted foreign key to 409", async () => { + const { app, service } = harness(); + service.delete.mockRejectedValue( + Object.assign(new Error("still referenced"), { code: "23503" }), + ); + + const res = await app.request("/7", { method: "DELETE" }); + + expect(res.status).toBe(409); + }); + }); + + describe("options", () => { + it("returns picker options", async () => { + const { app, service } = harness(); + service.options.mockResolvedValue([{ label: "News", value: 3 }]); + + const res = await app.request("/options/category?search=ne"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + items: [{ label: "News", value: 3 }], + }); + expect(service.options).toHaveBeenCalledWith("category", "ne"); + }); + }); + + describe("staff permissions", () => { + it.each([ + ["GET", "/"], + ["GET", "/7"], + ["GET", "/options/category"], + ["POST", "/"], + ["PUT", "/7"], + ["DELETE", "/7"], + ])("returns 403 for %s %s without permission", async (method, path) => { + const { app } = harness({ allow: false }); + + const res = await app.request(path, { + method, + ...(method === "POST" || method === "PUT" + ? json({ title: "Hello world", category: 1 }) + : {}), + }); + + expect(res.status).toBe(403); + }); + }); + + describe("OpenAPI", () => { + it("documents every operation", () => { + const { app } = harness(); + const doc = app.getOpenAPIDocument({ + info: { title: "t", version: "1" }, + openapi: "3.0.0", + }); + + expect(Object.keys(doc.paths).sort()).toEqual([ + "/", + "/options/{field}", + "/{id}", + ]); + expect(Object.keys(doc.paths["/{id}"]).sort()).toEqual([ + "delete", + "get", + "put", + ]); + }); + }); +}); diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts new file mode 100644 index 000000000..f6a4267e0 --- /dev/null +++ b/packages/vitnode/src/content/server/routes.ts @@ -0,0 +1,263 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; + +import { buildRoute } from "../../api/lib/route"; +import { + zodPaginationPageInfo, + zodPaginationQuery, +} from "../../api/lib/with-pagination"; +import { CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS } from "../const"; +import { orderableColumns } from "../registry"; +import { emitContentEvent } from "./emit"; +import { withHttpErrors } from "./http-errors"; + +const zodLabels = z.record(z.string(), z.string().nullable()); + +const zodOptions = z.object({ + items: z.array(z.object({ label: z.string(), value: z.number() })), +}); + +const notFound = (definition: AnyContentTypeDefinition): HTTPException => + new HTTPException(404, { + message: `${definition.admin.label.singular} not found.`, + }); + +const identifier = (c: Context): number => { + const value = Number(c.req.param("id")); + if (!Number.isInteger(value) || value <= 0) { + throw new HTTPException(400, { message: "Invalid identifier." }); + } + + return value; +}; + +/** + * The five CRUD routes plus the picker-options route for one content type. + * + * Every route carries an explicit `adminStaffPermission`, and every path sits + * under `/admin/` so the global admin session middleware runs - both are + * required for `assertStaffPermission` to have an admin to check. + */ +export const buildContentRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition, schemas } = model; + const module = definition.permissionModule; + const label = definition.admin.label; + + const listRow = schemas.selectObject.extend({ labels: zodLabels }); + + // `c.req.valid()` cannot infer through a generic route config, so each + // handler re-reads the validated payload through the very schema that + // produced it. That keeps the handlers cast-free and correctly typed. + const readJson = async ( + c: Context, + schema: z.ZodType, + ): Promise => schema.parse(await c.req.json()); + + // `orderBy` is an enum rather than a string so an unknown column is a 400 at + // validation time and shows up in the OpenAPI document. The service keeps its + // own allowlist check as defence in depth. + const orderable = orderableColumns(definition) as [string, ...string[]]; + const paginationQuery = zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(orderable).optional(), + search: z.string().optional(), + }); + const jsonBody = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, + }); + const jsonResponse = (schema: z.ZodType, description: string) => ({ + content: { "application/json": { schema } }, + description, + }); + + const list = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/", + description: `List ${label.plural}`, + request: { query: paginationQuery.extend(schemas.filters.shape) }, + responses: { + 200: jsonResponse( + z.object({ + edges: z.array(listRow), + pageInfo: zodPaginationPageInfo, + }), + `${label.plural} retrieved successfully`, + ), + }, + }, + handler: async c => { + const raw = c.req.query(); + const { cursor, first, last, order, orderBy, search } = + paginationQuery.parse(raw); + // Parsing through `schemas.filters` strips the pagination keys and + // coerces each declared filter; anything else never reaches the service. + const filters = schemas.filters.parse(raw); + + const data = await model.service(c).findMany({ + filters, + orderBy: { column: orderBy, order }, + query: { cursor, first, last, search }, + }); + + return c.json(data, 200); + }, + }); + + const options = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/options/{field}", + description: `Picker options for a ${label.singular} relation`, + request: { + params: z.object({ field: z.string() }), + query: z.object({ search: z.string().optional() }), + }, + responses: { + 200: jsonResponse(zodOptions, `Up to ${CONTENT_OPTIONS_LIMIT} options`), + }, + }, + handler: async c => { + const field = c.req.param("field"); + const search = c.req.query("search"); + + const items = await model.service(c).options(field, search); + + return c.json({ items }, 200); + }, + }); + + const detail = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}", + description: `Get one ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse(schemas.selectObject, `${label.singular} found`), + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + const row = await model.service(c).findById(identifier(c)); + if (!row) throw notFound(definition); + + return c.json(row, 200); + }, + }); + + const create = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.create }, + route: { + method: "post", + path: "/", + description: `Create a ${label.singular}`, + request: { body: jsonBody(schemas.create) }, + responses: { + 201: jsonResponse( + schemas.selectObject, + `${label.singular} created successfully`, + ), + 400: { description: "Invalid input data" }, + }, + }, + handler: async c => { + const values = await readJson(c, schemas.create); + + 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 }); + + return c.json(row, 201); + }, + }); + + const update = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + // PUT, not PATCH: the Next.js API route handler exports no PATCH. + method: "put", + path: "/{id}", + description: `Update a ${label.singular}`, + request: { params: schemas.params, body: jsonBody(schemas.update) }, + responses: { + 200: jsonResponse( + schemas.selectObject, + `${label.singular} updated successfully`, + ), + 400: { description: "Invalid or empty payload" }, + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + const values = await readJson(c, schemas.update); + + const result = await withHttpErrors("update", async () => + model.service(c).update(identifier(c), values), + ); + if (!result) throw notFound(definition); + + if (result.changedFields.length > 0) { + await emitContentEvent(c, definition, "updated", { + changedFields: result.changedFields, + contentId: result.row.id, + }); + } + + return c.json(result.row, 200); + }, + }); + + const remove = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, + route: { + method: "delete", + path: "/{id}", + description: `Delete a ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse( + schemas.selectObject, + `${label.singular} deleted successfully`, + ), + 404: { description: `${label.singular} not found` }, + 409: { description: "Still referenced by other content" }, + }, + }, + handler: async c => { + const row = await withHttpErrors("delete", async () => + model.service(c).delete(identifier(c)), + ); + if (!row) throw notFound(definition); + + await emitContentEvent(c, definition, "deleted", { contentId: row.id }); + + return c.json(row, 200); + }, + }); + + return [list, options, detail, create, update, remove]; +}; diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts new file mode 100644 index 000000000..edc246911 --- /dev/null +++ b/packages/vitnode/src/content/server/service.test.ts @@ -0,0 +1,275 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError } from "../errors"; +import { createContentModel } from "./model"; + +const categories = createContentModel(testCategoryContentType); +const articles = createContentModel(testArticleContentType, { + references: { category: () => categories.table.id }, +}); + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** + * A chainable stand-in for the Drizzle client. Each top-level `select`, + * `insert`, `update` or `delete` shifts the next queued result, and every + * builder call is recorded so tests can assert on the shape of the query. + */ +const createDbMock = (results: unknown[][]) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + + const chain = (rows: unknown[]) => { + const record = (op: string, arg: unknown) => { + calls.push({ arg, op }); + + return builder; + }; + + const builder = { + $dynamic: () => builder, + from: (value: unknown) => record("from", value), + leftJoin: (value: unknown) => record("leftJoin", value), + limit: (value: unknown) => record("limit", value), + orderBy: (value: unknown) => record("orderBy", value), + 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) => { + calls.push({ arg, op }); + + return chain(queue.shift() ?? []); + }; + + const db = { + delete: start("delete"), + insert: start("insert"), + select: start("select"), + update: start("update"), + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as Context; + + return { c, calls }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +describe("content service", () => { + describe("create", () => { + it("inserts the values and returns the created row", async () => { + const { c, calls } = createDbMock([[{ id: 1, title: "Hello" }]]); + + const row = await articles.service(c).create({ + category: 2, + title: "Hello", + }); + + expect(row).toEqual({ id: 1, title: "Hello" }); + expect(opsOf(calls, "values")[0]).toEqual({ + category: 2, + title: "Hello", + }); + }); + + it("converts an ISO dateTime string into a Date column value", async () => { + const { c, calls } = createDbMock([[{ id: 1 }]]); + + await articles.service(c).create({ + category: 2, + publishedAt: "2026-08-02T10:00:00.000Z", + title: "Hello", + }); + + const values = opsOf(calls, "values")[0] as { publishedAt: Date }; + expect(values.publishedAt).toBeInstanceOf(Date); + expect(values.publishedAt.toISOString()).toBe("2026-08-02T10:00:00.000Z"); + }); + }); + + describe("findById", () => { + it("returns the row when it exists", async () => { + const { c } = createDbMock([[{ id: 7, title: "Hello" }]]); + + await expect(articles.service(c).findById(7)).resolves.toEqual({ + id: 7, + title: "Hello", + }); + }); + + it("returns null rather than throwing when it does not", async () => { + const { c } = createDbMock([[]]); + + await expect(articles.service(c).findById(7)).resolves.toBeNull(); + }); + }); + + describe("update", () => { + it("returns null for a row that does not exist", async () => { + const { c, calls } = createDbMock([[]]); + + await expect( + articles.service(c).update(7, { title: "Changed" }), + ).resolves.toBeNull(); + expect(opsOf(calls, "update")).toHaveLength(0); + }); + + it("reports only the fields that actually changed", async () => { + const { c, calls } = createDbMock([ + [{ id: 7, status: "draft", title: "Hello" }], + [{ id: 7, status: "draft", title: "Changed" }], + ]); + + const result = await articles + .service(c) + .update(7, { status: "draft", title: "Changed" }); + + expect(result?.changedFields).toEqual(["title"]); + expect(opsOf(calls, "set")[0]).toEqual({ title: "Changed" }); + }); + + it("skips the write entirely when nothing moved", async () => { + const { c, calls } = createDbMock([[{ id: 7, title: "Hello" }]]); + + const result = await articles.service(c).update(7, { title: "Hello" }); + + expect(result?.changedFields).toEqual([]); + expect(opsOf(calls, "update")).toHaveLength(0); + }); + }); + + describe("delete", () => { + it("returns the deleted row", async () => { + const { c } = createDbMock([[{ id: 7, title: "Hello" }]]); + + await expect(articles.service(c).delete(7)).resolves.toEqual({ + id: 7, + title: "Hello", + }); + }); + + it("returns null when nothing was deleted", async () => { + const { c } = createDbMock([[]]); + + await expect(articles.service(c).delete(7)).resolves.toBeNull(); + }); + }); + + describe("findMany", () => { + const page = (rows: unknown[]) => [[{ count: rows.length }], rows]; + + it("joins once per reference field instead of querying per row", async () => { + const { c, calls } = createDbMock( + page([ + { id: 1, label__author: "Ada", label__category: "News" }, + { id: 2, label__author: null, label__category: "News" }, + ]), + ); + + await articles.service(c).findMany(); + + // `author` and `category` - one join each, and no extra round trips. + expect(opsOf(calls, "leftJoin")).toHaveLength(2); + expect(opsOf(calls, "select")).toHaveLength(2); // count + page + }); + + it("splits the joined labels out of the row", async () => { + const { c } = createDbMock( + page([{ id: 1, label__author: "Ada", label__category: "News" }]), + ); + + const { edges } = await articles.service(c).findMany(); + + expect(edges[0]).toEqual({ + id: 1, + labels: { author: "Ada", category: "News" }, + }); + }); + + it("reports a missing label as null", async () => { + const { c } = createDbMock( + page([{ id: 1, label__author: null, label__category: "News" }]), + ); + + const { edges } = await articles.service(c).findMany(); + + expect(edges[0].labels.author).toBeNull(); + }); + + it("rejects an order column outside the allowlist", async () => { + const { c } = createDbMock(page([])); + + await expect( + articles.service(c).findMany({ orderBy: { column: "views" } }), + ).rejects.toThrow(ContentEngineError); + }); + + it("rejects an unknown filter", async () => { + const { c } = createDbMock(page([])); + + await expect( + articles.service(c).findMany({ filters: { nope: 1 } }), + ).rejects.toThrow(ContentEngineError); + }); + }); + + describe("options", () => { + it("returns picker options for a reference field", async () => { + const { c } = createDbMock([[{ label: "News", value: 3 }]]); + + await expect(articles.service(c).options("category")).resolves.toEqual([ + { label: "News", value: 3 }, + ]); + }); + + it("falls back to the identifier when the label is null", async () => { + const { c } = createDbMock([[{ label: null, value: 3 }]]); + + await expect(articles.service(c).options("category")).resolves.toEqual([ + { label: "3", value: 3 }, + ]); + }); + + it("rejects a field that is not a relation or user", async () => { + const { c } = createDbMock([[]]); + + await expect(articles.service(c).options("title")).rejects.toThrow( + /not a relation or user field/, + ); + }); + }); + + describe("transactions", () => { + it("uses the supplied transaction handle", async () => { + const { c } = createDbMock([]); + const outer = createDbMock([[{ id: 1 }]]); + const tx = outer.c.get("db"); + + await articles.service(c).create({ category: 1, title: "Hello" }, { tx }); + + expect(opsOf(outer.calls, "insert")).toHaveLength(1); + }); + }); +}); diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts new file mode 100644 index 000000000..de88b03a2 --- /dev/null +++ b/packages/vitnode/src/content/server/service.ts @@ -0,0 +1,399 @@ +import type { ColumnBaseConfig, SQL } from "drizzle-orm"; +import type { + PgColumn, + PgTable, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq } from "drizzle-orm"; +import { alias, getTableConfig } from "drizzle-orm/pg-core"; + +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentSelect, + ContentUpdateInput, +} from "../types"; + +import { withPagination } from "../../api/lib/with-pagination"; +import { CONTENT_DEFAULT_PAGE_SIZE, CONTENT_OPTIONS_LIMIT } from "../const"; +import { ContentEngineError } from "../errors"; +import { orderableColumns } from "../registry"; +import { + buildFilterCondition, + buildOrderColumn, + buildSearchCondition, + diffChangedFields, + toColumnValues, +} from "./query"; + +/** Display labels for `user` and `relation` values, keyed by field name. */ +export type ContentLabels = Record; + +export type ContentListRow = ContentSelect & { + labels: ContentLabels; +}; + +export interface ContentPageInfo { + count: number; + endCursor: null | number; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: null | number; + totalCount: number; +} + +export interface ContentFindManyArgs { + /** Equality filters, keyed by field name. */ + filters?: Record; + orderBy?: { column?: string; order?: "asc" | "desc" }; + /** Raw pagination query (`cursor`, `first`, `last`, `search`). */ + query?: { cursor?: string; first?: string; last?: string; search?: string }; + where?: SQL; +} + +/** The Drizzle client, or a transaction handle standing in for it. */ +export type ContentDatabase = Context["var"]["db"]; + +export interface ContentServiceOptions { + /** Run inside an existing transaction. */ + tx?: ContentDatabase; +} + +export interface ContentUpdateResult { + changedFields: string[]; + row: ContentSelect; +} + +export interface ContentService { + create: ( + values: ContentCreateInput, + options?: ContentServiceOptions, + ) => Promise>; + delete: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; + findById: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; + findMany: (args?: ContentFindManyArgs) => Promise<{ + edges: ContentListRow[]; + pageInfo: ContentPageInfo; + }>; + /** Options for a `user` or `relation` picker, filtered by a search term. */ + options: ( + field: string, + search?: string, + ) => Promise<{ label: string; value: number }[]>; + update: ( + id: number, + values: ContentUpdateInput, + options?: ContentServiceOptions, + ) => Promise | null>; +} + +interface ReferenceTarget { + /** Aliased, so two relations pointing at the same table can both be joined. */ + aliased: PgTable; + idColumn: PgColumn; + labelColumn: PgColumn; + owner: PgColumn; +} + +const LABEL_PREFIX = "label__"; + +/** + * Turns a joined label column value into display text. Only the shapes a title + * column can actually hold are handled - anything else becomes `null` rather + * than "[object Object]". + */ +const toLabel = (value: unknown): null | string => { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) return value.toISOString(); + + return null; +}; + +/** + * Works out which table and column supply the display label for each + * `user`/`relation` field. + * + * The target comes from the foreign keys Drizzle already resolved on the table, + * so the engine needs no separate table registry - and because the FK thunk is + * evaluated here, circular content type references stay safe. + */ +const resolveReferenceTargets = ( + definition: AnyContentTypeDefinition, + table: PgTableWithColumns, + columns: Record, +): Record => { + const fields = definition.fields; + const byOwnerColumn = new Map( + getTableConfig(table) + .foreignKeys.map(foreignKey => foreignKey.reference()) + .map(reference => [reference.columns[0]?.name, reference]), + ); + + const targets: Record = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "relation" && fieldValue.kind !== "user") continue; + + const reference = byOwnerColumn.get(name); + if (!reference) { + throw new ContentEngineError( + `Field "${name}" has no foreign key on "${definition.tableName}".`, + { contentTypeId: definition.id }, + ); + } + + // `user` labels come from the core users table; a relation uses the target + // content type's own `admin.titleField`. + const labelName = + fieldValue.kind === "user" + ? "name" + : (fieldValue.target().admin.titleField ?? "id"); + + const aliased = alias(reference.foreignTable, `${LABEL_PREFIX}${name}`); + const aliasedColumns = aliased as unknown as Record; + + targets[name] = { + aliased, + idColumn: aliasedColumns.id, + labelColumn: aliasedColumns[labelName] ?? aliasedColumns.id, + owner: columns[name], + }; + } + + return targets; +}; + +/** + * A typed repository bound to one request's database handle. + * + * Deliberately thin: it owns column allowlisting, pagination and label joins, + * and leaves everything else to Drizzle. `model.table` stays public so advanced + * plugin code can drop down to the query builder at any point. + */ +export const createContentService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + columns, + definition, + table, +}: { + c: Context; + columns: Record; + definition: TDefinition; + table: PgTableWithColumns; +}): ContentService => { + const fields = definition.fields; + const contentTypeId = definition.id; + // `buildSystemColumns` always makes `id` a `serial`, which is what + // `withPagination` needs to type its cursor. + const primaryCursor = columns.id as PgColumn< + ColumnBaseConfig<"number", string> + >; + const orderable = orderableColumns(definition); + const ownColumnNames = [ + "id", + "createdAt", + "updatedAt", + ...Object.keys(fields), + ]; + const references = resolveReferenceTargets(definition, table, columns); + const searchColumns = definition.admin.list.searchableFields.map( + name => columns[name], + ); + + const db = (options?: ContentServiceOptions): ContentDatabase => + options?.tx ?? c.get("db"); + + const ownSelection = (): Record => + Object.fromEntries(ownColumnNames.map(name => [name, columns[name]])); + + const toRow = (row: Record): ContentSelect => + row as ContentSelect; + + const splitLabels = ( + row: Record, + ): ContentListRow => { + const labels: ContentLabels = {}; + const values: Record = {}; + + for (const [key, value] of Object.entries(row)) { + if (key.startsWith(LABEL_PREFIX)) { + labels[key.slice(LABEL_PREFIX.length)] = toLabel(value); + continue; + } + values[key] = value; + } + + return { ...values, labels } as ContentListRow; + }; + + 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; + }; + + return { + create: async (values, options) => { + const [row] = await db(options) + .insert(table) + .values(toColumnValues(fields, values as Record)) + .returning(ownSelection()); + + return toRow(row); + }, + + delete: async (id, options) => { + const [row] = await db(options) + .delete(table) + .where(eq(primaryCursor, id)) + .returning(ownSelection()); + + return row ? toRow(row) : null; + }, + + findById: async (id, options) => { + const row = await readOne(id, db(options)); + + return row ? toRow(row) : null; + }, + + findMany: async ({ filters = {}, orderBy, query = {}, where } = {}) => { + const conditions = [ + where, + buildFilterCondition({ columns, contentTypeId, fields, filters }), + buildSearchCondition(searchColumns, query.search), + ].filter((item): item is SQL => item !== undefined); + + const combined = + conditions.length > 1 ? and(...conditions) : conditions[0]; + + const data = await withPagination({ + c, + // The search term is folded into `where` above so it can be escaped; + // handing it to `withPagination` would build an unescaped `ilike`. + params: { query: { ...query, search: undefined } }, + primaryCursor, + orderBy: { + column: buildOrderColumn({ + columns, + contentTypeId, + fallback: definition.admin.list.defaultOrderBy, + orderBy: orderBy?.column, + orderable, + }), + order: orderBy?.order ?? definition.admin.list.defaultOrder, + }, + table, + where: combined, + query: async ({ limit, orderBy: order, where: rowWhere }) => { + // One LEFT JOIN per reference field resolves every label in the same + // round trip - there is no per-row lookup anywhere. + const selection: Record = { + ...ownSelection(), + ...Object.fromEntries( + Object.entries(references).map(([name, target]) => [ + `${LABEL_PREFIX}${name}`, + target.labelColumn, + ]), + ), + }; + + let builder = c.get("db").select(selection).from(table).$dynamic(); + + for (const target of Object.values(references)) { + builder = builder.leftJoin( + target.aliased, + eq(target.owner, target.idColumn), + ); + } + + return await builder + .where(rowWhere) + .orderBy(order) + .limit( + typeof limit === "number" ? limit : CONTENT_DEFAULT_PAGE_SIZE, + ); + }, + }); + + return { + edges: data.edges.map(splitLabels), + pageInfo: data.pageInfo, + }; + }, + + options: async (fieldName, search) => { + const target = references[fieldName]; + if (!target) { + throw new ContentEngineError( + `Field "${fieldName}" is not a relation or user field.`, + { contentTypeId }, + ); + } + + const rows = await c + .get("db") + .select({ label: target.labelColumn, value: target.idColumn }) + .from(target.aliased) + .where(buildSearchCondition([target.labelColumn], search)) + .orderBy(target.labelColumn) + .limit(CONTENT_OPTIONS_LIMIT); + + return rows.map(row => { + const value = Number(row.value); + + return { label: toLabel(row.label) ?? String(value), value }; + }); + }, + + update: async (id, values, options) => { + const database = db(options); + const current = await readOne(id, database); + if (!current) return null; + + const patch = values as Record; + const changedFields = diffChangedFields(current, patch); + + // Nothing actually moved - skip the write so `updatedAt` and the + // `content.*.updated` event both stay honest. + if (changedFields.length === 0) { + return { changedFields, row: toRow(current) }; + } + + const [row] = await database + .update(table) + .set( + toColumnValues( + fields, + Object.fromEntries(changedFields.map(key => [key, patch[key]])), + ), + ) + .where(eq(primaryCursor, id)) + .returning(ownSelection()); + + return { changedFields, row: toRow(row) }; + }, + }; +}; diff --git a/packages/vitnode/src/content/server/table.test-d.ts b/packages/vitnode/src/content/server/table.test-d.ts new file mode 100644 index 000000000..10709e708 --- /dev/null +++ b/packages/vitnode/src/content/server/table.test-d.ts @@ -0,0 +1,96 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { ContentSelect } from "../types"; + +import { createContentTable } from "./table"; + +const categories = createContentTable(testCategoryContentType); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- read as a type +const articles = createContentTable(testArticleContentType, { + references: { category: () => categories.id }, +}); + +type Select = (typeof articles)["$inferSelect"]; +type Insert = (typeof articles)["$inferInsert"]; + +describe("createContentTable inference", () => { + describe("$inferSelect", () => { + it("matches the descriptor-derived row type", () => { + expectTypeOf + + + +

+ + {t.rich("expires", { + when: () => , + })} + + +
+ +

+ {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..997db2d3f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx @@ -0,0 +1,106 @@ +"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("desc", { title })} + + + }> + + + + + + {label} + + + ); +}; 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..fa2f74439 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx @@ -0,0 +1,314 @@ +// 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; + 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 ( +
  • + + {t(`actions.${schedule.action}`)} + + + + + + + + {t(`status.${schedule.status}`)} + {schedule.actorName ? ` · ${schedule.actorName}` : null} + + + {overdue ? ( + + {t("overdue")} + + ) : null} + + {schedule.lastError ? ( + + {schedule.lastError} + + ) : null} + + {pending ? ( + + ) : ( + + )} +
  • + ); +}; + +/** + * 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); + + 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 ; + + const pending = state.edges.filter(entry => entry.status === "pending"); + + const onSubmit: AutoFormOnSubmit = 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 ( +
    + {!state.hasCronAdapter ? ( + + + {t("no_cron.title")} + {t("no_cron.desc")} + + ) : null} + + {state.edges.length > 0 ? ( +
      + {state.edges.map(schedule => ( + { + 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} + /> + ))} +
    + ) : ( +

    {t("empty")}

    + )} + + ( + + ), + }, + { + id: "scheduledFor", + + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + /> + +

    + + {t("precision")} +

    +
    + ); +}; 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.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx index 55fcfcf9c..9aa4576ca 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 ? ( + + ) : null} + {definition.editorial.scheduling.enabled ? ( + + ) : null} + {definition.editorial.enabled ? ( + + ) : null} {definition.publication.enabled ? ( { ? "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")} /> + {t("content_preview.not_configured")} + ) : !data.contentPreview.secure ? ( + + {t("content_preview.insecure")} + + ) : ( + + {t("content_preview.content_types", { + count: data.contentPreview.contentTypes, + })} + + ) + } + readMoreLabel={t("read_more")} + status={contentPreviewStatus} + statusLabel={statusLabel(contentPreviewStatus)} + title={t("content_preview.title")} + /> + ( "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; email?: { diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 295c58e5e..730288363 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -13,4 +13,11 @@ 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", ]; 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..17aebf617 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -1,10 +1,12 @@ import type { ContentSearchOperation } from "@vitnode/core/content/server"; import type { Context } from "hono"; +import { ContentVersionConflict } from "@vitnode/core/content"; import { createContentSearchIndexer, 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,6 +129,34 @@ 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; let context: Context; let db: ReturnType; @@ -166,6 +196,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( @@ -211,7 +242,45 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { db = drizzle(sql, { casing: "camelCase" }); context = { - get: (key: string) => (key === "db" ? db : undefined), + get: (key: string) => { + if (key === "db") return db; + // Stands 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. + if (key === "queue") { + return { + dispatch: async ({ + availableAt, + name, + payload, + pluginId, + tx, + }: { + availableAt?: Date; + name: string; + payload?: Record; + pluginId?: string; + tx?: typeof db; + }) => { + const [queued] = await (tx ?? db) + .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; }, 60_000); @@ -1003,6 +1072,509 @@ 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 }); + + 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("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 schedules = () => { + const build = articleContent.editorialService; + if (!build) throw new Error("example.article has no editorial service"); + + const model = build(context, { + pluginId: CONFIG_PLUGIN.pluginId, + }).schedules; + if (!model) throw new Error("example.article has no scheduling"); + + return model; + }; + + 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); + }); + }); + 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 +1596,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", From 3a839169bd139a6c1ae8d75c48e20dcca7efefdf Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 16:34:49 +0200 Subject: [PATCH 038/123] fix: Fix final Stage 4 blockers --- apps/api/.env.example | 11 +- apps/docs/.env.example | 11 +- apps/docs/content/docs/dev/advanced/queue.mdx | 44 +- .../docs/dev/content-engine/admincp.mdx | 34 + .../docs/dev/content-engine/caching.mdx | 12 +- .../database-and-migrations.mdx | 11 + .../docs/dev/content-engine/editorial.mdx | 30 +- .../docs/dev/content-engine/limitations.mdx | 24 +- .../docs/dev/content-engine/preview.mdx | 111 +- .../docs/dev/content-engine/publication.mdx | 23 +- .../docs/dev/content-engine/revisions.mdx | 77 +- .../docs/dev/content-engine/scheduling.mdx | 99 +- ...028_add_content_schedule_effects_error.sql | 1 + apps/docs/migrations/meta/0028_snapshot.json | 2919 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 9 +- .../src/api/middlewares/global.middleware.ts | 9 + .../admin/debug/routes/integrations.route.ts | 16 +- .../src/api/modules/content/content.module.ts | 9 +- .../helpers/execute-content-schedule.test.ts | 252 +- .../helpers/execute-content-schedule.ts | 292 +- .../tasks/content-schedule-effects.task.ts | 38 + packages/vitnode/src/content/const.ts | 16 + packages/vitnode/src/content/schedules.ts | 8 + .../content/server/editorial-service.test.ts | 68 +- .../src/content/server/editorial-service.ts | 38 +- packages/vitnode/src/content/server/index.ts | 26 +- .../src/content/server/preview-config.test.ts | 199 ++ .../src/content/server/preview-config.ts | 124 + .../src/content/server/preview-route.test.ts | 60 +- .../src/content/server/preview-token.test.ts | 5 +- .../src/content/server/public-routes.ts | 15 +- .../content/server/revisions-model.test.ts | 217 ++ .../src/content/server/revisions-model.ts | 52 +- .../vitnode/src/content/server/routes.test.ts | 302 +- packages/vitnode/src/content/server/routes.ts | 196 +- .../content/server/schedule-effects.test.ts | 281 ++ .../src/content/server/schedule-effects.ts | 191 ++ .../src/content/server/schedules-model.ts | 62 +- packages/vitnode/src/database/content.ts | 16 + packages/vitnode/src/lib/config.ts | 28 + packages/vitnode/src/locales/en.json | 15 +- .../views/content/actions/delete-action.tsx | 24 +- .../actions/history/revision-history.test.tsx | 273 ++ .../actions/history/revision-history.tsx | 157 +- .../content/actions/mutation-api.server.ts | 51 +- .../content/actions/mutation-api.test.ts | 107 +- .../views/content/actions/preview-action.tsx | 20 + .../actions/schedule/schedule-panel.tsx | 9 + .../content/table/content-table-view.test.tsx | 56 + .../content/table/content-table-view.tsx | 8 + plugins/example/src/const.ts | 1 + plugins/example/src/database/postgres.test.ts | 524 ++- 52 files changed, 6771 insertions(+), 410 deletions(-) create mode 100644 apps/docs/migrations/0028_add_content_schedule_effects_error.sql create mode 100644 apps/docs/migrations/meta/0028_snapshot.json create mode 100644 packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts create mode 100644 packages/vitnode/src/content/server/preview-config.test.ts create mode 100644 packages/vitnode/src/content/server/preview-config.ts create mode 100644 packages/vitnode/src/content/server/revisions-model.test.ts create mode 100644 packages/vitnode/src/content/server/schedule-effects.test.ts create mode 100644 packages/vitnode/src/content/server/schedule-effects.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/history/revision-history.test.tsx diff --git a/apps/api/.env.example b/apps/api/.env.example index 9fbae80d8..af3fdeccf 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -11,10 +11,15 @@ 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. Left unset, it falls back to a well-known default and the -# AdminCP integrations panel flags it - at which point anyone can forge a link. +# 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=your-secure-content-preview-secret +CONTENT_PREVIEW_SECRET= # === AI (Vercel AI SDK) === # Gateway (default): one key for Anthropic, OpenAI, Google, etc. via `provider/model` diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 6fe5b8e01..726860ed7 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -9,10 +9,15 @@ 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. Left unset, it falls back to a well-known default and the -# AdminCP integrations panel flags it - at which point anyone can forge a link. +# 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=your-secure-content-preview-secret +CONTENT_PREVIEW_SECRET= # === Docker Database Postgres === POSTGRES_USER=root diff --git a/apps/docs/content/docs/dev/advanced/queue.mdx b/apps/docs/content/docs/dev/advanced/queue.mdx index 6d0579548..4ba30014e 100644 --- a/apps/docs/content/docs/dev/advanced/queue.mdx +++ b/apps/docs/content/docs/dev/advanced/queue.mdx @@ -168,17 +168,49 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; ## Tasks core ships -| Task | What it does | -| --- | --- | -| `send-email` | Delivers a rendered email through the configured provider | -| `rebuild-search-index` | Clears and rebuilds the [search](/docs/dev/content-engine/search) index, whole or per collection | -| `content-schedule` | Runs one [scheduled publication](/docs/dev/content-engine/scheduling). A cancelled, rescheduled or already-executed schedule is a no-op | +| 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. +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. Delivery is at-least-once: the +search write and the cache expiry are idempotent, but a listener can see one +`published` twice. + + + 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 diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index 7b6f9ff8b..f006e0ab7 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -106,6 +106,13 @@ happened rather than the number: | `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 @@ -172,6 +179,17 @@ 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: @@ -213,6 +231,16 @@ 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 @@ -225,6 +253,12 @@ above a two-field form: what should happen, and when. 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. diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx index d26a39971..0d148cbc3 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -201,8 +201,16 @@ 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. -It is best effort and logged, never fatal. The reasoning, the auth and the -failure mode are all in +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 when no origin accepted +the request, which is what triggers the retry. Delivery is at-least-once, and +expiring an already-expired tag is a no-op. The reasoning, the auth and the +payload are all in [Scheduled publishing](/docs/dev/content-engine/scheduling#the-cache-bridge). ### The slug change 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 f07a2c987..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 @@ -298,6 +298,17 @@ 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 diff --git a/apps/docs/content/docs/dev/content-engine/editorial.mdx b/apps/docs/content/docs/dev/content-engine/editorial.mdx index 4c9da8661..5a961777a 100644 --- a/apps/docs/content/docs/dev/content-engine/editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/editorial.mdx @@ -90,6 +90,13 @@ editorial: { - [`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 @@ -158,15 +165,24 @@ envelope.** is validated by `schemas.update` - a strict object of declared fields, which a transport concern has no business appearing in. -Nothing else moves. `POST`, `DELETE`, `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. +`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 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. + 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 diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index 4854570e8..37fbddd18 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -17,6 +17,7 @@ other 20%, so you find out here rather than halfway through building. | 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 | @@ -191,18 +192,23 @@ 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 cache invalidation is best effort +## 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. Two attempts, then the failure is logged with a -`[content-revalidate]` prefix and dropped. - -Failing the task instead would retry the *publish*, which is idempotent: the -second run would find nothing changed and skip the invalidation entirely, which -is strictly worse. So a public page can be stale for up to its own `cacheLife` -after a background transition. Same kind of eventual consistency the search -index already has, and bounded the same way. +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 diff --git a/apps/docs/content/docs/dev/content-engine/preview.mdx b/apps/docs/content/docs/dev/content-engine/preview.mdx index afcc7d96d..eb387bbc9 100644 --- a/apps/docs/content/docs/dev/content-engine/preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/preview.mdx @@ -60,23 +60,53 @@ the honest default: linking to a page nobody has written yet would just be a you cast past it. -## `CONTENT_PREVIEW_SECRET` +## `CONTENT_PREVIEW_SECRET` is required -```bash title=".env" -CONTENT_PREVIEW_SECRET=a-long-random-string +**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 ``` -This one value is the entire authorization story. Leave it unset and it falls -back to a well-known default, which means anyone who has read the VitNode source -can mint a link to any draft on your site. **AdminCP → System → Integrations** -flags that as a warning, next to the same flag for `CRON_SECRET`. +```bash title=".env" +CONTENT_PREVIEW_SECRET=Q0hBTkdFLU1FLXRoaXMtaXMtYW4tZXhhbXBsZS12YWx1ZQ== +``` -Generate one the usual way: +A secret is acceptable when all three are true: -```bash -openssl rand -base64 32 +```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 @@ -101,15 +131,33 @@ and it is asserted by a test rather than left to review. // POST /{id}/preview { "token": "eyJhdWQiOiJjb250ZW50LXByZXZpZXci…", - "url": "/articles/preview/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. +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 @@ -228,7 +276,9 @@ rather than showing you one that may already have expired. | 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 @@ -253,10 +303,35 @@ an accident: 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 -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. The first edit fixes it. +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. -If retention prunes the revision a link pointed at, the link 404s. Links are -minted against the newest revision, which is the last one to be pruned, so this -takes a busy record and a patient reviewer. +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/publication.mdx b/apps/docs/content/docs/dev/content-engine/publication.mdx index 5163450fb..96dccbcc1 100644 --- a/apps/docs/content/docs/dev/content-engine/publication.mdx +++ b/apps/docs/content/docs/dev/content-engine/publication.mdx @@ -295,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 - -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*. - +## Publishing later, and going back + +`publish()` on its own means *now*. +[`editorial`](/docs/dev/content-engine/editorial) adds the two things that +change: + +- [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 index 29b37a9d2..20582e857 100644 --- a/apps/docs/content/docs/dev/content-engine/revisions.mdx +++ b/apps/docs/content/docs/dev/content-engine/revisions.mdx @@ -56,15 +56,48 @@ 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 | -| `DELETE /{id}` | not accepted | 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 @@ -333,6 +366,36 @@ 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 @@ -361,6 +424,11 @@ 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 @@ -430,3 +498,10 @@ 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 index 10238510b..a6a376262 100644 --- a/apps/docs/content/docs/dev/content-engine/scheduling.mdx +++ b/apps/docs/content/docs/dev/content-engine/scheduling.mdx @@ -105,6 +105,30 @@ the engine. 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 @@ -135,6 +159,57 @@ 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. + +### 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 | + +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. + ### Nobody is impersonated A scheduled run's actor is `{ type: "system", userId: null }`, because that is @@ -172,14 +247,14 @@ export { POST } from "@vitnode/core/content/next/revalidate-route"; | 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 | **Best effort.** Two attempts, then logged with a `[content-revalidate]` prefix and dropped | - - - Failing the queue task would retry the *publish* - which is idempotent, so the - second run finds nothing changed and skips the invalidation **entirely**. - Strictly worse than a stale page. The residual risk is bounded by the tag's - own `cacheLife`, and it is the same eventual consistency the search index - already has. +| 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 if no 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. ### `immediate` from a Route Handler @@ -209,10 +284,14 @@ 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. +- 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 @@ -233,4 +312,6 @@ days, and rows whose content type is no longer registered at all. | 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/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/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 5ed30260f..b9fbeb52a 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "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/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 74cfe590e..f1efc4cd4 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -22,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"; @@ -253,6 +254,14 @@ 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( 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 1dfcddba5..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 @@ -7,8 +7,8 @@ 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_CONTENT_PREVIEW_SECRET, INSECURE_DEFAULT_CRON_SECRET, + isSecureContentPreviewSecret, } from "@/lib/config"; import { isRealtimePubSubEnabled, isWebSocketEnabled } from "@/ws/registry"; @@ -47,9 +47,10 @@ export const integrationsDebugAdminRoute = buildRoute({ active: z.boolean(), // How many content types can mint preview links. contentTypes: z.number(), - // `false` when `CONTENT_PREVIEW_SECRET` is left at its - // well-known default, which makes every preview link forgeable - // by anyone who has read the source. + // `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({ @@ -172,10 +173,9 @@ export const integrationsDebugAdminRoute = buildRoute({ contentPreview: { active: previewContentTypes > 0, contentTypes: previewContentTypes, - secure: - !!core.contentPreviewSecret && - core.contentPreviewSecret !== - INSECURE_DEFAULT_CONTENT_PREVIEW_SECRET, + // 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, diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts index 66b858bba..682152133 100644 --- a/packages/vitnode/src/api/modules/content/content.module.ts +++ b/packages/vitnode/src/api/modules/content/content.module.ts @@ -2,6 +2,7 @@ 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"; /** @@ -16,11 +17,17 @@ import { contentScheduleQueueTask } from "./tasks/content-schedule.task"; * 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], + queueTasks: [contentScheduleQueueTask, contentScheduleEffectsQueueTask], }); 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 index 47611f09d..7f2b98ac8 100644 --- 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 @@ -7,21 +7,11 @@ import { testEditorialPostContentType } from "@/tests/content-fixtures"; const claimContentSchedule = vi.fn(); const settleContentSchedule = vi.fn(); -const contentEditorialEffects = vi.fn(); -const dispatchContentRevalidation = vi.fn(); vi.mock("@/content/server/schedules-model", () => ({ claimContentSchedule: (...args: unknown[]) => claimContentSchedule(...args), settleContentSchedule: (...args: unknown[]) => settleContentSchedule(...args), })); -vi.mock("@/content/server/editorial-effects", () => ({ - contentEditorialEffects: (...args: unknown[]) => - contentEditorialEffects(...args), -})); -vi.mock("@/content/server/revalidate-bridge", () => ({ - dispatchContentRevalidation: (...args: unknown[]) => - dispatchContentRevalidation(...args), -})); const { executeContentSchedule } = await import("./execute-content-schedule"); @@ -37,11 +27,13 @@ const claimed = { }; 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, }; @@ -65,50 +57,60 @@ const harness = ({ } = {}) => { const publish = vi.fn().mockResolvedValue(outcome); const unpublish = vi.fn().mockResolvedValue(outcome); - const findById = vi.fn().mockResolvedValue({ - ...row, - publishedAt: null, - status: "draft", - }); const model = { definition: testEditorialPostContentType, editorialService: () => ({ publish, unpublish, ...editorial }), - service: () => ({ findById }), }; + const dispatch = vi.fn().mockResolvedValue({ id: 1 }); + let committed = false; + const db = { - transaction: async (fn: (tx: unknown) => Promise) => - await fn({ tx: true }), + 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 === "core" - ? { - contentModels: registered ? [{ model, pluginId: PLUGIN_ID }] : [], - } - : undefined, + : key === "queue" + ? { dispatch } + : key === "core" + ? { + contentModels: registered + ? [{ model, pluginId: PLUGIN_ID }] + : [], + } + : undefined, } as unknown as Context; - return { c, findById, publish, unpublish }; + 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(); - contentEditorialEffects.mockResolvedValue({ search: null }); - dispatchContentRevalidation.mockResolvedValue({ - attempted: 1, - delivered: 1, - }); + settleContentSchedule.mockResolvedValue(true); }); describe("executeContentSchedule", () => { - it("publishes, settles the schedule, and tells everyone once", async () => { + it("publishes, settles the schedule, and queues the announcements", async () => { claimContentSchedule.mockResolvedValue(claimed); - const { c, publish } = harness(); + const { c, dispatch, publish } = harness(); const result = await executeContentSchedule(c, { generation: 1, @@ -117,8 +119,82 @@ describe("executeContentSchedule", () => { expect(result.status).toBe("executed"); expect(publish).toHaveBeenCalledTimes(1); - expect(contentEditorialEffects).toHaveBeenCalledTimes(1); - expect(dispatchContentRevalidation).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 () => { @@ -132,32 +208,71 @@ describe("executeContentSchedule", () => { }); }); - it("names the person who booked it in the event", 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 } = harness(); + 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 }); + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); - expect(contentEditorialEffects.mock.calls[0][3]).toEqual({ - pluginId: PLUGIN_ID, - scheduledBy: 3, + expect(dispatchedPayload(dispatch).payload).toMatchObject({ + contentTypeId: testEditorialPostContentType.id, + itemId: 7, + operation: "publish", + pluginId: PLUGIN_ID, + revisionId: 90, + scheduleId: 55, + scheduledBy: 3, + version: 4, + }); }); - }); - it("expires both the old and the new slug", async () => { - claimContentSchedule.mockResolvedValue(claimed); - const { c, findById } = harness(); - findById.mockResolvedValue({ ...row, slug: "old-slug", status: "draft" }); + 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 }); + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); - expect(dispatchContentRevalidation.mock.calls[0][1]).toMatchObject({ - isPublic: true, - mode: "immediate", - slugs: ["old-slug", "hello-world"], - wasPublic: false, + 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"); }); }); @@ -166,7 +281,7 @@ describe("executeContentSchedule", () => { // 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, publish } = harness(); + const { c, dispatch, publish } = harness(); const result = await executeContentSchedule(c, { generation: 1, @@ -177,13 +292,13 @@ describe("executeContentSchedule", () => { 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(contentEditorialEffects).not.toHaveBeenCalled(); - expect(dispatchContentRevalidation).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + expect(settleContentSchedule).not.toHaveBeenCalled(); }); it("does nothing more when the record is already published", async () => { claimContentSchedule.mockResolvedValue(claimed); - const { c } = harness({ + const { c, dispatch } = harness({ editorial: { publish: vi.fn().mockResolvedValue({ ...outcome, changed: false }), }, @@ -195,20 +310,23 @@ describe("executeContentSchedule", () => { }); expect(result.status).toBe("skipped"); - expect(contentEditorialEffects).not.toHaveBeenCalled(); - expect(dispatchContentRevalidation).not.toHaveBeenCalled(); + 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, - { lastError: null, status: "completed" }, + { + expectedStatus: "pending", + lastError: null, + status: "completed", + }, ); }); it("does nothing when the record was deleted first", async () => { claimContentSchedule.mockResolvedValue(claimed); - const { c } = harness({ + const { c, dispatch } = harness({ editorial: { publish: vi.fn().mockResolvedValue(null) }, }); @@ -218,7 +336,7 @@ describe("executeContentSchedule", () => { }); expect(result.status).toBe("skipped"); - expect(contentEditorialEffects).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); }); }); @@ -226,7 +344,7 @@ describe("executeContentSchedule", () => { // 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 } = harness({ registered: false }); + const { c, dispatch } = harness({ registered: false }); const result = await executeContentSchedule(c, { generation: 1, @@ -237,9 +355,12 @@ describe("executeContentSchedule", () => { expect(settleContentSchedule).toHaveBeenCalledWith( expect.anything(), 55, - expect.objectContaining({ status: "cancelled" }), + expect.objectContaining({ + expectedStatus: "pending", + status: "cancelled", + }), ); - expect(dispatchContentRevalidation).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); }); it("records the error and rethrows a real failure", async () => { @@ -256,6 +377,7 @@ describe("executeContentSchedule", () => { ).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. 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 index 700a6639e..b76415c59 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts @@ -1,14 +1,12 @@ import type { Context } from "hono"; import type { ContentEditorialOutcome } from "@/content/server/editorial-service"; -import type { AnyContentModel } from "@/content/server/model"; +import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects"; import type { AnyContentTypeDefinition } from "@/content/types"; -import { isContentPubliclyVisible } from "@/content/cache"; +import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const"; import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor"; -import { contentEditorialEffects } from "@/content/server/editorial-effects"; import { findContentModel } from "@/content/server/model"; -import { dispatchContentRevalidation } from "@/content/server/revalidate-bridge"; import { claimContentSchedule, settleContentSchedule, @@ -20,35 +18,111 @@ export interface ContentScheduleOutcome { status: "executed" | "skipped" | "unregistered"; } -const publicStateOf = (row: null | Record | undefined) => - isContentPubliclyVisible({ - publishedAt: row?.publishedAt as Date | null | string | undefined, - status: row?.status as string | undefined, - }); +/** + * 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.`, + ); -const slugsOf = ( - model: AnyContentModel, - ...rows: (null | Record | undefined)[] -): string[] => { - if (!model.definition.publicApi.enabled) return []; + this.name = "ContentScheduleSettlementError"; + } +} - const field = model.definition.publicApi.slugField; +type ScheduleTransaction = + | { contentTypeId: string; kind: "unregistered" } + | { effects: ContentScheduleEffectsPayload; kind: "executed" } + | { kind: "skipped"; reason: string }; - return rows - .map(row => row?.[field]) - .filter((slug): slug is string => typeof slug === "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. - * - * The one thing that does throw is a real failure of the transition itself, so - * the queue's existing backoff applies and the row is retried. */ export const executeContentSchedule = async ( c: Context, @@ -56,72 +130,99 @@ export const executeContentSchedule = async ( ): Promise => { const db = c.get("db"); - // Claimed in its own short transaction. Holding the row lock across the whole - // publish would keep it open for the duration of a search sync and an HTTP - // hop; the status flip to `completed` is what stops a second run, and that - // happens inside the write transaction below. - const claimed = await db.transaction( - async tx => await claimContentSchedule(tx, { generation, scheduleId }), - ); - - if (!claimed) { - return { - reason: "not pending, superseded, or not yet due", - status: "skipped", - }; - } - - 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(db, claimed.id, { - lastError: `Content type "${claimed.contentTypeId}" is no longer registered with an editorial workflow.`, - status: "cancelled", - }); - - return { reason: claimed.contentTypeId, status: "unregistered" }; - } - - const { model, pluginId } = entry; - const editorial = editorialService(c, { pluginId }); - - // Read before the write, for the same reason the AdminCP action does: the old - // slug is gone once the transition returns, and expiring the wrong tag leaves - // a moved URL resolving. - const before = (await model - .service(c) - .findById(claimed.itemId)) as null | Record; - const wasPublic = publicStateOf(before); - - let outcome: ContentEditorialOutcome | null; + let result: ScheduleTransaction; try { - outcome = await db.transaction(async tx => { - const result = await editorial[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, + 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, }); - // Settled either way. An already-published record means the schedule got - // what it wanted; leaving it pending would retry it forever. - await settleContentSchedule(tx, claimed.id, { - lastError: null, - status: "completed", + // 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 result; + return { effects, kind: "executed" }; }); } catch (error) { - await settleContentSchedule(db, claimed.id, { + // 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", }); @@ -130,33 +231,12 @@ export const executeContentSchedule = async ( throw error; } - // The record was deleted between the schedule and its execution. - if (!outcome) return { reason: "record no longer exists", status: "skipped" }; - if (!outcome.changed) { - return { reason: "already in that state", status: "skipped" }; + if (result.kind === "unregistered") { + return { reason: result.contentTypeId, status: "unregistered" }; + } + if (result.kind === "skipped") { + return { reason: result.reason, status: "skipped" }; } - - // Post-commit, through the same helper the interactive routes use - so a - // scheduled publish and a clicked one are indistinguishable to every listener - // and to the search index. - await contentEditorialEffects(c, model.definition, outcome, { - pluginId, - scheduledBy: claimed.createdBy, - }); - - const row = outcome.row as Record; - - // The cache is the one effect this process cannot perform, so it goes over - // the bridge. Best effort: a failure must not re-run the publish. - await dispatchContentRevalidation(c, { - contentTypeId: model.definition.id, - id: claimed.itemId, - isPublic: publicStateOf(row), - mode: "immediate", - // Both, because an unpublish has to expire the URL it used to answer to. - slugs: slugsOf(model, before, row), - wasPublic, - }); 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/content/const.ts b/packages/vitnode/src/content/const.ts index 80ede5fd7..3e4ea6ffb 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -238,6 +238,22 @@ export const CONTENT_SCHEDULE_RETENTION_DAYS = 30; */ 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", diff --git a/packages/vitnode/src/content/schedules.ts b/packages/vitnode/src/content/schedules.ts index 3390e123c..8959ee612 100644 --- a/packages/vitnode/src/content/schedules.ts +++ b/packages/vitnode/src/content/schedules.ts @@ -21,6 +21,14 @@ export interface ContentSchedule { 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; diff --git a/packages/vitnode/src/content/server/editorial-service.test.ts b/packages/vitnode/src/content/server/editorial-service.test.ts index 5e3cb2efb..78a66b761 100644 --- a/packages/vitnode/src/content/server/editorial-service.test.ts +++ b/packages/vitnode/src/content/server/editorial-service.test.ts @@ -111,6 +111,24 @@ const createDbMock = ( 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 @@ -387,7 +405,10 @@ describe("editorial service", () => { 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 }); + 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 @@ -398,10 +419,51 @@ describe("editorial service", () => { ).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 () => { - const { c } = createDbMock([[]]); + // 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(); - await expect(service(c).delete(1, { actor: STAFF })).resolves.toBeNull(); + expect(opsOf(calls, "values")).toHaveLength(0); }); }); diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index e81c28443..d802826df 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -80,9 +80,17 @@ export interface ContentEditorialService { 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: ContentEditorialOptions, + options: ContentEditorialWriteOptions, ) => Promise | null>; publish: ( id: number, @@ -398,12 +406,36 @@ export const createContentEditorialService = < 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(eq(primaryCursor, id)) + .where( + and( + eq(primaryCursor, id), + eq(versionColumn, options.expectedVersion), + ), + ) .returning(ownSelection()); - if (!row) return null; + 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 diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 7c068551e..4c8d7ea00 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -79,12 +79,36 @@ export { contentSnapshotRow, projectRevisionSnapshot, } from "./revision-snapshot"; -export { createContentRevisionsModel } from "./revisions-model"; +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"; 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 index cef782b65..01fee88a3 100644 --- a/packages/vitnode/src/content/server/preview-route.test.ts +++ b/packages/vitnode/src/content/server/preview-route.test.ts @@ -6,12 +6,16 @@ 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"; -const SECRET = "preview-secret"; +// 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); @@ -42,7 +46,7 @@ const snapshot = ( * 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 = () => { +const harness = ({ secret = SECRET }: { secret?: string } = {}) => { const findById = vi.fn(); const selections: Record[] = []; const liveRows: Record[] = []; @@ -66,7 +70,7 @@ const harness = () => { const app = new OpenAPIHono(); app.use("*", async (c, next) => { c.set("db", db as never); - c.set("core", { contentPreviewSecret: SECRET } as never); + c.set("core", { contentPreviewSecret: secret } as never); await next(); }); for (const { handler, route } of buildContentPublicRoutes(posts, { @@ -208,6 +212,56 @@ describe("the public preview route", () => { 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); diff --git a/packages/vitnode/src/content/server/preview-token.test.ts b/packages/vitnode/src/content/server/preview-token.test.ts index d47ffa72c..656b47f48 100644 --- a/packages/vitnode/src/content/server/preview-token.test.ts +++ b/packages/vitnode/src/content/server/preview-token.test.ts @@ -10,7 +10,10 @@ import { verifyContentPreviewToken, } from "./preview-token"; -const SECRET = "preview-secret"; +// 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"); diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 3463ca35b..66ef5507d 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -19,7 +19,7 @@ import { zodPaginationPageInfo, zodPaginationQuery, } from "../../api/lib/with-pagination"; -import { CONFIG } from "../../lib/config"; +import { CONFIG, isSecureContentPreviewSecret } from "../../lib/config"; import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const"; import { ContentEngineError } from "../errors"; import { publicOrderableColumns } from "../registry"; @@ -229,11 +229,20 @@ export const buildContentPublicRoutes = < }, }, 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: - c.get("core")?.contentPreviewSecret ?? CONFIG.contentPreviewSecret, + secret, token: c.req.param("token"), }); if (!payload) throw notFound(); 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 index f1a33d8aa..dc022336a 100644 --- a/packages/vitnode/src/content/server/revisions-model.ts +++ b/packages/vitnode/src/content/server/revisions-model.ts @@ -1,6 +1,6 @@ import type { Context } from "hono"; -import { and, desc, eq, lte, notInArray, sql } from "drizzle-orm"; +import { and, desc, eq, lt, lte, notInArray, sql } from "drizzle-orm"; import type { ContentActor, @@ -48,11 +48,27 @@ export interface ContentRevisionsModel { list: ( itemId: number, args?: { cursor?: number; limit?: number }, - ) => Promise; + ) => Promise; } -const DEFAULT_PAGE_SIZE = 25; -const MAX_PAGE_SIZE = 100; +/** + * 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. @@ -164,6 +180,11 @@ export const createContentRevisionsModel = ({ }, 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 @@ -177,12 +198,27 @@ export const createContentRevisionsModel = ({ .where( cursor === undefined ? scope(itemId) - : and(scope(itemId), lte(core_content_revisions.version, cursor)), + : // 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)) - .limit(Math.min(limit ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE)); - - return rows; + // 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, + }, + }; }, }; }; diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index c4181e3da..07f896313 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -11,11 +11,14 @@ import { 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"; @@ -43,6 +46,32 @@ const posts = createContentModel(testPostContentType, { 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", @@ -425,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", () => { @@ -609,7 +648,10 @@ describe("generated content routes", () => { ...overrides, }); - const editorialHarness = ({ allow = true }: { allow?: boolean } = {}) => { + const editorialHarness = ({ + allow = true, + previewSecret = PREVIEW_SECRET, + }: { allow?: boolean; previewSecret?: string } = {}) => { const emitted: Harness["emitted"] = []; const searched: unknown[] = []; const editorial = { @@ -679,7 +721,12 @@ describe("generated content routes", () => { error: async () => Promise.resolve(), } as unknown as Context["var"]["log"]); c.set("admin", allow ? { user: adminUser } : null); - c.set("core", { hasCronAdapter: true } as never); + 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(); }); @@ -693,6 +740,37 @@ describe("generated content routes", () => { 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(); @@ -813,9 +891,13 @@ describe("generated content routes", () => { }); describe("revision history", () => { - it("returns metadata only", async () => { - const { app, editorial } = editorialHarness(); - editorial.revisions.list.mockResolvedValue([ + const revisionPage = ( + pageInfo: { endCursor: null | number; hasNextPage: boolean } = { + endCursor: 5, + hasNextPage: false, + }, + ) => ({ + edges: [ { actorName: "Test", actorType: "staff", @@ -827,7 +909,13 @@ describe("generated content routes", () => { 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[] }; @@ -839,6 +927,48 @@ describe("generated content routes", () => { 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({ @@ -868,6 +998,99 @@ describe("generated content routes", () => { }); }); + 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(); @@ -1020,12 +1243,75 @@ describe("generated content routes", () => { 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. + // 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( - `/editorial/preview/${encodeURIComponent(body.token)}`, + `${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. diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 1e3b8be5c..ff8955aa3 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -35,8 +35,10 @@ 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()); @@ -167,19 +169,52 @@ export const buildContentRoutes = < c.get("core")?.contentPreviewSecret ?? CONFIG.contentPreviewSecret; /** - * Where the link points. + * Where the link points, as something a person can paste into a browser. * - * With a `pathTemplate` it is a page in the web app, which is what an editor - * wants to send a reviewer. Without one it is the JSON endpoint - honest - * rather than a link to a page nobody has written yet. + * 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 previewUrl = (token: string): string => - definition.editorial.preview.pathTemplate - ? definition.editorial.preview.pathTemplate.replace( - CONTENT_PREVIEW_TOKEN_PLACEHOLDER, - encodeURIComponent(token), - ) - : `/api/${pluginId}/content/${definition.publicApi.path}/preview/${encodeURIComponent(token)}`; + 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({ pluginId, @@ -556,6 +591,21 @@ export const buildContentRoutes = < 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 }, @@ -563,33 +613,37 @@ export const buildContentRoutes = < method: "get", path: "/{id}/revisions", description: `History of one ${label.singular}`, - request: { - params: schemas.params, - query: z.object({ - cursor: z.coerce.number().optional(), - first: z.coerce.number().optional(), - }), - }, + request: { params: schemas.params, query: revisionQuery }, responses: { 200: jsonResponse( - z.object({ edges: z.array(zodRevisionMeta) }), + 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: invalidIdentifier, + 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 edges = await editorialService(c).revisions.list(identifier(c), { - cursor: c.req.query("cursor") - ? Number(c.req.query("cursor")) - : undefined, - limit: c.req.query("first") ? Number(c.req.query("first")) : undefined, + const page = await editorialService(c).revisions.list(identifier(c), { + cursor, + limit: first, }); - return c.json({ edges }, 200); + return c.json(page, 200); }, }); @@ -703,16 +757,25 @@ export const buildContentRoutes = < /** `0` when the record predates its content type opting in. */ revisionId: z.number(), token: z.string(), - url: 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); @@ -753,6 +816,8 @@ export const buildContentRoutes = < 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()]), @@ -936,14 +1001,31 @@ export const buildContentRoutes = < }, }); - const remove = buildRoute({ + /** 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 }, + request: { params: schemas.params, body: jsonBody(deleteEnvelope) }, responses: { 200: jsonResponse( schemas.selectObject, @@ -951,29 +1033,55 @@ export const buildContentRoutes = < ), 400: invalidIdentifier, 404: { description: `${label.singular} not found` }, - 409: { description: "Still referenced by other content" }, + 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. - if (editorial) { - const result = await withHttpErrors( - "delete", - async () => - await editorialService(c).delete(id, { - actor: resolveContentActor(c), - }), - { contentTypeId: definition.id, itemId: id, structured: true }, - ); - if (!result) throw notFound(definition); + 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 }); + await contentEditorialEffects(c, definition, result, { pluginId }); - return c.json(result.row, 200); - } + return c.json(result.row, 200); + }, + }); + + const remove = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, + route: { + method: "delete", + path: "/{id}", + description: `Delete a ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse( + schemas.selectObject, + `${label.singular} deleted successfully`, + ), + 400: invalidIdentifier, + 404: { description: `${label.singular} not found` }, + 409: { description: "Still referenced by other content" }, + }, + }, + handler: async c => { + const id = identifier(c); const row = await withHttpErrors("delete", async () => model.service(c).delete(id), @@ -1003,7 +1111,7 @@ export const buildContentRoutes = < // Same method and path either way; only the body shape differs, so exactly // one of the two is ever mounted. editorial ? editorialUpdate : update, - remove, + editorial ? editorialRemove : remove, ...(definition.publication.enabled ? [publicationRoute("publish"), publicationRoute("unpublish")] : []), 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..2e49aab71 --- /dev/null +++ b/packages/vitnode/src/content/server/schedule-effects.test.ts @@ -0,0 +1,281 @@ +// @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 }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + contentEditorialEffects.mockResolvedValue({ 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, + }); + }); + + 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({ + 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({ + 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("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..7e12196d6 --- /dev/null +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -0,0 +1,191 @@ +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. + * + * **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 needs its own + * idempotency key. 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 { search } = await contentEditorialEffects(c, definition, outcome, { + pluginId: payload.pluginId, + scheduledBy: payload.scheduledBy, + }); + + 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[] = []; + if (search?.error) failures.push(`search: ${search.error.message}`); + // `attempted: 0` is "there was nothing to tell" - no tags, or no web origin + // configured - which is a decision, not an outage. + if (revalidation.attempted > 0 && revalidation.delivered === 0) { + failures.push( + `cache: none of the ${revalidation.attempted} configured web origin(s) 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 index 0fd1c4611..5a94b7385 100644 --- a/packages/vitnode/src/content/server/schedules-model.ts +++ b/packages/vitnode/src/content/server/schedules-model.ts @@ -2,7 +2,11 @@ import type { Context } from "hono"; import { and, desc, eq, inArray, lt, notInArray, sql } from "drizzle-orm"; -import type { ContentSchedule, ContentScheduleAction } from "../schedules"; +import type { + ContentSchedule, + ContentScheduleAction, + ContentScheduleStatus, +} from "../schedules"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDatabase } from "./service"; @@ -81,19 +85,66 @@ export const claimContentSchedule = async ( }; }; -/** Records how a claimed schedule ended. Also id-keyed, and for the same reason. */ +/** + * 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: { lastError?: null | string; status?: "cancelled" | "completed" }, -): Promise => { - await db + 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)); }; @@ -198,6 +249,7 @@ export const createContentSchedulesModel = ({ 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, diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts index 99151f34f..5ed075993 100644 --- a/packages/vitnode/src/database/content.ts +++ b/packages/vitnode/src/database/content.ts @@ -74,6 +74,12 @@ export const core_content_revisions = pgTable( // 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, @@ -137,6 +143,16 @@ export const core_content_schedules = pgTable( 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 diff --git a/packages/vitnode/src/lib/config.ts b/packages/vitnode/src/lib/config.ts index 94966c946..c2c7222e8 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -18,6 +18,34 @@ export const INSECURE_DEFAULT_CRON_SECRET = 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 diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 9ddcba842..cf3c9e032 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -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}", @@ -481,6 +485,8 @@ "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", @@ -506,7 +512,9 @@ "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." + "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}", @@ -540,7 +548,8 @@ "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", 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/revision-history.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.test.tsx new file mode 100644 index 000000000..5cfe728e3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.test.tsx @@ -0,0 +1,273 @@ +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 listContentRevisionsAction = vi.fn(); +const restoreContentRevisionAction = vi.fn(); +vi.mock("../mutation-api.server", () => ({ + getContentRevisionAction: vi.fn().mockResolvedValue({ revision: undefined }), + listContentRevisionsAction: (...args: unknown[]) => + listContentRevisionsAction(...args), + restoreContentRevisionAction: (...args: unknown[]) => + restoreContentRevisionAction(...args), +})); + +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 }, +}); + +const view = () => + render( + , + ); + +beforeEach(() => { + vi.clearAllMocks(); + canRestore = true; +}); + +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); + }); + + 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 index 0e79b4b39..7d2079080 100644 --- 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 @@ -79,7 +79,7 @@ const RevisionRow = ({ currentVersion: number; id: number; isCurrent: boolean; - onRestored: () => void; + onRestored: (nextVersion?: number) => void; previousId: null | number; revision: ContentRevisionMeta; singular: string; @@ -179,7 +179,9 @@ const RevisionRow = ({ }), }); onClose(); - onRestored(); + // 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 })} @@ -223,6 +225,22 @@ const RevisionRow = ({ ); }; +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, @@ -236,7 +254,12 @@ export const RevisionHistory = ({ const t = useTranslations("core.content.history"); const { push } = useRouter(); const pathname = usePathname(); - const [edges, setEdges] = React.useState(null); + 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, @@ -247,7 +270,15 @@ export const RevisionHistory = ({ let active = true; void listContentRevisionsAction(contentTypeId, id).then(result => { - if (active) setEdges(result.edges); + if (!active) return; + + setState({ + edges: result.edges, + endCursor: result.pageInfo.endCursor, + error: result.error ?? null, + hasNextPage: result.pageInfo.hasNextPage, + loaded: true, + }); }); return () => { @@ -255,40 +286,112 @@ export const RevisionHistory = ({ }; }, [contentTypeId, id]); - if (edges === null) return ; + /** 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); - if (edges.length === 0) { + 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 (

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

    ); } return ( -
      - {edges.map((revision, index) => ( - { - push(pathname); +
      +
        + {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 ? ( +
    + type="button" + variant="outline" + > + {loadingMore ? t("loading_more") : t("load_more")} + + ) : 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 1610f05c6..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 @@ -264,14 +264,29 @@ export const reloadContentRowAction = async ( const zodRevisionList = z.object({ edges: z.array(z.object({ id: z.number() }).loose()), + pageInfo: z.object({ + endCursor: z.number().nullable(), + hasNextPage: z.boolean(), + }), }); -/** The history list. Metadata only - snapshots load one at a time. */ +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<{ edges: ContentRevisionMeta[]; error?: string }> => { +): Promise => { const { definition, pluginId } = resolve(contentTypeId); const result = await contentApiFetch({ @@ -283,12 +298,15 @@ export const listContentRevisionsAction = async ( schema: zodRevisionList, }); - if (result.status !== 200) { - return { edges: [], error: result.error ?? "" }; + 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[], + edges: result.data.edges as unknown as ContentRevisionMeta[], + pageInfo: result.data.pageInfo, }; }; @@ -312,12 +330,19 @@ export const getContentRevisionAction = async ( 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 => { +): Promise => { const { definition, pluginId } = resolve(contentTypeId); // Same as an edit: the old slug has to be known before the write, or a @@ -340,7 +365,9 @@ export const restoreContentRevisionAction = async ( // may have, and `invalidate` compares both rows to work out which. invalidate(definition, id, before, result.data?.row); - return {}; + const version = result.data?.row.version; + + return { version: typeof version === "number" ? version : undefined }; }; export interface ContentPreviewLink { @@ -485,10 +512,20 @@ export const cancelContentScheduleAction = 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}`, 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 52c6b9c1d..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 @@ -445,7 +445,15 @@ describe("editorial", () => { }); it("lists revisions", async () => { - responses = [{ data: { edges: [{ id: 20, version: 5 }] }, status: 200 }]; + responses = [ + { + data: { + edges: [{ id: 20, version: 5 }], + pageInfo: { endCursor: 5, hasNextPage: false }, + }, + status: 200, + }, + ]; const result = await listContentRevisionsAction("test.editorial", 7); @@ -453,6 +461,103 @@ describe("editorial", () => { 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 }, 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 index bbcf3da0a..1672aef88 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx @@ -122,6 +122,16 @@ export const PreviewContentAction = ({ 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 @@ -185,6 +195,16 @@ export const PreviewContentAction = ({ /> + {/* `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/schedule-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx index fa2f74439..e92a1aeda 100644 --- 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 @@ -78,6 +78,15 @@ const ScheduleRow = ({ ) : 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 ? ( + + {t("effects_failed")} + + ) : null} + {pending ? ( + } + /> + } + /> + + + + {label} + {t("desc")} + + + }> + + + + + + {label} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/delivery-api.server.ts new file mode 100644 index 000000000..e751c0514 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery-api.server.ts @@ -0,0 +1,74 @@ +"use server"; + +import { z } from "zod"; + +import { findFrontendContentType } from "@/content/admin/config"; +import { contentApiFetch } from "@/content/admin/fetch.server"; + +/** + * One address a record has answered to. + * + * Exactly what the admin route publishes and not one field more: the storage + * columns behind it - `languageId`, `pluginId`, the row id - are details of + * `core_content_slug_history`, and a panel that displayed them would make them + * part of a contract nobody meant to sign. + */ +const zodDeliveryEntry = z.object({ + createdAt: z.coerce.date(), + path: z.string(), + /** `null` while this is the record's current address. */ + retiredAt: z.coerce.date().nullable(), + slug: z.string(), +}); + +const zodDelivery = z.object({ + canonicalPath: z.string().nullable(), + history: z.array(zodDeliveryEntry), + isPublic: z.boolean(), + locale: z.string().nullable(), +}); + +export type ContentDeliveryPanelData = z.infer; + +export interface ContentDeliveryPanelResult { + data?: ContentDeliveryPanelData; + error?: string; +} + +/** + * Reads one record's delivery state for the AdminCP panel. + * + * A Server Action rather than a fetch in the page, because the panel is lazy: it + * loads when somebody opens the dialog, and a record's URL history is not worth a + * query on every row of a 25-row table. + * + * `can_view` is enforced by the route it calls, not here - the AdminCP's session + * cookie travels with the request and the generated route carries the permission, + * which is the same arrangement every other content action uses. There is + * deliberately no `can_manage_redirects`: this screen manages nothing. + */ +export const readContentDeliveryAction = async ( + contentTypeId: string, + id: number, + locale?: string, +): Promise => { + const entry = findFrontendContentType(contentTypeId); + if (!entry) return { error: "Unknown content type." }; + + const result = await contentApiFetch({ + definition: entry.definition, + method: "get", + path: `/${id}/delivery`, + pluginId: entry.pluginId, + query: locale === undefined ? undefined : { locale }, + schema: zodDelivery, + }); + + if (result.status !== 200 || !result.data) { + return { + error: result.error ?? "This record's delivery state could not be read.", + }; + } + + return { data: result.data }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx new file mode 100644 index 000000000..ce69d4c5c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { CheckIcon, LinkIcon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Loader } from "@/components/ui/loader"; + +import type { ContentDeliveryPanelData } from "../delivery-api.server"; + +import { readContentDeliveryAction } from "../delivery-api.server"; + +/** + * The read-only delivery panel: where a record lives, and where it used to. + * + * Read-only on purpose, and it is the deliberate scope of Stage 8. A redirect is + * somebody else's incoming link, so deleting one silently breaks traffic nobody in + * this dialog can see - that is a destructive action, and a destructive action needs + * a permission of its own, a confirmation that explains the consequence, and an + * audit trail. Displaying the history is useful today; managing it is a product, + * not a button. + */ +export const DeliveryPanel = ({ + contentTypeId, + id, + locale, +}: { + contentTypeId: string; + id: number; + /** The language whose URLs to show, on a content type with localized slugs. */ + locale?: string; +}) => { + const t = useTranslations("core.content.delivery"); + const [state, setState] = React.useState< + | { data: ContentDeliveryPanelData; status: "ready" } + | { message: string; status: "error" } + | { status: "loading" } + >({ status: "loading" }); + + React.useEffect(() => { + let active = true; + + void readContentDeliveryAction(contentTypeId, id, locale).then(result => { + if (!active) return; + + setState( + result.data + ? { data: result.data, status: "ready" } + : { message: result.error ?? t("load_failed"), status: "error" }, + ); + }); + + return () => { + active = false; + }; + }, [contentTypeId, id, locale, t]); + + if (state.status === "loading") return ; + + if (state.status === "error") { + return ( +

    + {state.message} +

    + ); + } + + const { canonicalPath, history, isPublic } = state.data; + const historical = history.filter(entry => entry.retiredAt !== null); + + return ( +
    +
    +

    {t("canonical")}

    + + {canonicalPath === null ? ( +

    + {t("no_canonical")} +

    + ) : ( +

    + + {canonicalPath} +

    + )} + + + {isPublic ? ( + + ) : ( + + )} + {isPublic ? t("states.published") : t("states.not_published")} + +
    + +
    +

    {t("historical")}

    + + {historical.length === 0 ? ( +

    + {t("no_history")} +

    + ) : ( +
      + {historical.map(entry => ( +
    • + + {entry.path} + + → + + + {canonicalPath === null + ? t("redirect_inactive") + : t("redirect_active")} + + + + {entry.retiredAt === null ? null : ( + + {t("retired_at")} + + + )} +
    • + ))} +
    + )} + + {/* Said out loud, because it is the one thing about this screen somebody + will assume is wrong: an unpublished record's old URLs stop redirecting + and start again when it comes back. */} + {historical.length > 0 && canonicalPath === null ? ( +

    + {t("inactive_note")} +

    + ) : null} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts index fb2b46f56..bc0e88fc5 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import type { + ContentDeliveryConflict, ContentTranslationConflict, ContentUnprocessable, } from "@/content/conflicts"; @@ -16,6 +17,7 @@ import type { import { findFrontendContentType } from "@/content/admin/config"; import { contentApiFetch } from "@/content/admin/fetch.server"; import { + parseContentDeliveryConflict, parseContentTranslationConflict, parseContentUnprocessable, } from "@/content/conflicts"; @@ -44,6 +46,16 @@ const CONTENT_PAGE_PATH = */ export interface TranslationMutationResult { conflict?: ContentTranslationConflict; + /** + * `CONTENT_DELIVERY_SLUG_RESERVED`, when a localized address is owned by another + * record's URL history. + * + * Its own field rather than a sixth arm of `conflict`, because it is a fact about + * *delivery* rather than about translations - the base routes answer with the same + * shape, and one code for one condition is what lets the AdminCP say the same + * sentence wherever the address was typed. + */ + delivery?: ContentDeliveryConflict; error?: string; status?: number; unprocessable?: ContentUnprocessable; @@ -54,6 +66,7 @@ const failure = (result: { status: number; }): TranslationMutationResult => ({ conflict: parseContentTranslationConflict(result.error) ?? undefined, + delivery: parseContentDeliveryConflict(result.error) ?? undefined, error: result.error ?? "", status: result.status, unprocessable: parseContentUnprocessable(result.error) ?? undefined, diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx index 5646c0dab..d47fb00c5 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx @@ -184,7 +184,12 @@ export const TranslationPanel = ({ const report = (result: TranslationMutationResult): boolean => { if (result.error === undefined) return true; - const key = conflictMessage(result.conflict); + // The delivery reservation first: it shares a status with the unique clash and + // says a different thing - "that address still redirects to another record" + // rather than "another record holds it now". + const key = result.delivery + ? "slug_reserved" + : conflictMessage(result.conflict); if (result.conflict?.code === "CONTENT_TRANSLATION_VERSION_CONFLICT") { // The form keeps every value the translator typed. Nothing is retried and 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 c019f1bca..647dc2f6f 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,5 +1,6 @@ import type { ContentConflict, + ContentDeliveryConflict, ContentUnprocessable, } from "@/content/conflicts"; @@ -9,6 +10,7 @@ export type ContentErrorKey = | "forbidden" | "not_found" | "not_restorable" + | "slug_reserved" | "unique_conflict" | "validation" | "version_conflict"; @@ -31,9 +33,16 @@ export const contentErrorKey = ( status: number | undefined, structured?: { conflict?: ContentConflict; + delivery?: ContentDeliveryConflict; unprocessable?: ContentUnprocessable; }, ): ContentErrorKey | null => { + // Before the plain conflict, because the two share a status and mean different + // things: a unique clash is "another record holds that address now, so you cannot + // have it", and a reservation is "another record *used* to hold it and it still + // redirects" - which is a different sentence and possibly a different decision. + if (structured?.delivery) return "slug_reserved"; + if (structured?.conflict) { return structured.conflict.code === "CONTENT_VERSION_CONFLICT" ? "version_conflict" 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 216d64770..4adc5f130 100644 --- a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx @@ -13,6 +13,7 @@ import { orderableColumns } from "@/content/registry"; import type { ContentRowData } from "./cells"; import { DeleteContentAction } from "../actions/delete-action"; +import { DeliveryContentAction } from "../actions/delivery-action"; import { EditContentAction } from "../actions/edit-action"; import { HistoryContentAction } from "../actions/history-action"; import { PreviewContentAction } from "../actions/preview-action"; @@ -170,6 +171,7 @@ export const ContentTableView = async ({ definition.editorial.enabled ? "w-36" : "", definition.editorial.preview.enabled ? "w-44" : "", definition.editorial.scheduling.enabled ? "w-52" : "", + definition.delivery.enabled ? "w-60" : "", ] .filter(Boolean) .at(-1), @@ -181,6 +183,16 @@ export const ContentTableView = async ({ return ( <> + {definition.delivery.enabled ? ( + + ) : null} {definition.editorial.preview.enabled ? ( Date: Sat, 8 Aug 2026 17:10:31 +0200 Subject: [PATCH 093/123] feat(example): add the delivery reference fixtures Two shapes, because the interesting cases differ: `example.article` is the nonlocalized reference - no locale segment, one reservation for the one URL it has, and SEO from two fields the public API already exposes. `example.advanced-article` is the localized one: a localized slug, so each language gets its own reservation and changing the English URL creates no Polish redirect; SEO from a localized group with a fallback to the localized `title`; and an `x-default` that appears only when the default locale is genuinely published. `syndication.noIndex` is added as a **shared** boolean so the fixture exercises the one field that drives two consumers - sitemap exclusion and the `robots` directive - which is why it has to be shared: a per-locale value would let the two disagree. The three Stage 6 assertions that named the group's leaves exactly are updated rather than loosened; a new leaf appearing in a partial-update assertion is precisely what those tests are for. Co-Authored-By: Claude Opus 5 (1M context) --- .../0033_add_example_article_no_index.sql | 1 + apps/docs/migrations/meta/0033_snapshot.json | 4036 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 14 + plugins/example/src/const.ts | 8 + .../example/src/content/advanced-article.ts | 43 + plugins/example/src/content/article.ts | 30 + .../src/database/advanced-postgres.test.ts | 5 + .../src/database/advanced-routes.test.ts | 9 +- 8 files changed, 4143 insertions(+), 3 deletions(-) create mode 100644 apps/docs/migrations/0033_add_example_article_no_index.sql create mode 100644 apps/docs/migrations/meta/0033_snapshot.json diff --git a/apps/docs/migrations/0033_add_example_article_no_index.sql b/apps/docs/migrations/0033_add_example_article_no_index.sql new file mode 100644 index 000000000..cab8ed154 --- /dev/null +++ b/apps/docs/migrations/0033_add_example_article_no_index.sql @@ -0,0 +1 @@ +ALTER TABLE "example_advanced_articles" ADD COLUMN "syndicationNoIndex" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/docs/migrations/meta/0033_snapshot.json b/apps/docs/migrations/meta/0033_snapshot.json new file mode 100644 index 000000000..e1ce93871 --- /dev/null +++ b/apps/docs/migrations/meta/0033_snapshot.json @@ -0,0 +1,4036 @@ +{ + "id": "318c5944-3dbe-4646-a80e-fd047f44db84", + "prevId": "b7094309-91e5-43f2-b9f9-d5666d73f0f4", + "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 + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_content_slug_history": { + "name": "core_content_slug_history", + "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 + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_advanced_articles": { + "name": "example_advanced_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 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationNoIndex": { + "name": "syndicationNoIndex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "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()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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 + }, + "public.example_localized_articles": { + "name": "example_localized_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 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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 230eb9f15..1f44b074f 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -225,6 +225,20 @@ "when": 1786181800826, "tag": "0031_add_example_advanced_articles", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1786194085698, + "tag": "0032_add_content_slug_history", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1786195724174, + "tag": "0033_add_example_article_no_index", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 7e7ff31b6..122c10184 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -35,4 +35,12 @@ export const EXAMPLE_MIGRATIONS = [ // table - each with the constraints that make its ordering and its integrity // facts about the database rather than about the service. "0031_add_example_advanced_articles.sql", + // Stage 8. Core again: `core_content_slug_history` is what makes an old public + // URL keep working, and the delivery suites write reservations for both example + // content types - so the table has to exist before either of them publishes. + "0032_add_content_slug_history.sql", + // The shared boolean `delivery.seo.noIndexField` reads, which drives the sitemap + // exclusion and the `robots` metadata together. Additive and defaulted, so every + // existing row becomes indexable rather than silently disappearing from a sitemap. + "0033_add_example_article_no_index.sql", ]; diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index b3f02af42..86abca97e 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -98,6 +98,15 @@ export const advancedArticleContentType = defineContentType({ syndication: field.group({ fields: { indexable: field.boolean({ defaultValue: true }), + /** + * The Stage 8 `noIndexField`, and shared rather than localized on purpose. + * + * Sitemap exclusion and the `robots` metadata are driven by the same + * boolean, so they cannot disagree - and a per-locale value would give one + * record one answer per language while it has a single canonical decision. + * `delivery` refuses a localized field here for exactly that reason. + */ + noIndex: field.boolean({ defaultValue: false }), priority: field.number({ integer: true, min: 0, @@ -142,6 +151,10 @@ export const advancedArticleContentType = defineContentType({ "seo.title", "seo.description", "syndication.priority", + // Public because delivery projects it: `robots: { index: false }` is rendered + // into the page, so the field it comes from has to be something the public API + // would already have said out loud. + "syndication.noIndex", "faq.question", "faq.answer", "publishedAt", @@ -166,6 +179,36 @@ export const advancedArticleContentType = defineContentType({ pathTemplate: "/{locale}/advanced-articles/{slug}", }, + /** + * The Stage 8 reference for a **localized** content type. + * + * Its canonical path carries the locale - `/pl/advanced-articles/moj-artykul` - + * and so does its slug history: the slug is `localized: true`, so each language + * gets its own reservation and changing the English URL creates no Polish + * redirect. + * + * `seo` reads the localized group, so every language has its own title and + * description, with `fallbackTitleField: "title"` filling in when `seo.title` is + * empty - which it usually is, because nobody writes one twice. + * + * `hreflang.xDefault` points at the default locale's canonical path, and only when + * that language is genuinely published: an `x-default` pointing at a translation + * this record does not have would be a hint to crawl a 404. + */ + delivery: { + enabled: true, + redirects: { enabled: true }, + hreflang: { xDefault: "defaultLocale" }, + seo: { + titleField: "seo.title", + fallbackTitleField: "title", + descriptionField: "seo.description", + noIndexField: "syndication.noIndex", + openGraph: { titleField: "seo.title", descriptionField: "seo.description" }, + }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + }, + // Leaf paths, materialised against the generated columns: this compiles to an // index on `syndicationPriority`, exactly as `{ on: ["priority"] }` would have // if `priority` were a top-level field. diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index ab6c9574d..ae2fa389c 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -45,6 +45,36 @@ export const articleContentType = defineContentType({ pathTemplate: "/articles/{slug}", }, + /** + * The Stage 8 reference for a **nonlocalized** content type. + * + * Its canonical path has no locale segment - `/articles/my-article` - and its slug + * history has no language either: `languageId` is `NULL`, so one reservation + * covers the one URL the record has. + * + * `redirects` is what makes an old address keep working. Change the slug of a + * *published* article and `/articles/old-slug` answers 308 to the new one, for as + * long as the article stays published; change it while it is still a draft and + * nothing is recorded, because the URL was never live. + * + * `seo` projects two fields the public API already exposes. There is no + * `fallbackTitleField` here because `title` is the primary and it is + * `required: true` - a fallback would never be reached. + */ + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { + titleField: "title", + descriptionField: "excerpt", + // Same fields in both slots, which is the common case: an author who wants a + // different social title names a different field, and one who does not says + // so in two lines rather than four. + openGraph: { titleField: "title", descriptionField: "excerpt" }, + }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + }, + editorial: { enabled: true, revisions: { retention: 20 }, diff --git a/plugins/example/src/database/advanced-postgres.test.ts b/plugins/example/src/database/advanced-postgres.test.ts index 632cacc54..e81cd796c 100644 --- a/plugins/example/src/database/advanced-postgres.test.ts +++ b/plugins/example/src/database/advanced-postgres.test.ts @@ -663,6 +663,9 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { expect(row?.syndication).toStrictEqual({ indexable: false, + // Stage 8 added a third leaf to the group. It is untouched by a write that + // named only `priority`, which is exactly what "partial group update" means. + noIndex: false, priority: 3, }); }); @@ -1406,6 +1409,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { // Nested, never the flattened column names. expect(snapshot.fields.syndication).toStrictEqual({ indexable: true, + noIndex: false, priority: 5, }); }); @@ -1525,6 +1529,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { expect(row?.syndication).toStrictEqual({ indexable: true, + noIndex: false, priority: 7, }); }); diff --git a/plugins/example/src/database/advanced-routes.test.ts b/plugins/example/src/database/advanced-routes.test.ts index 9c0782d4b..9e1443cff 100644 --- a/plugins/example/src/database/advanced-routes.test.ts +++ b/plugins/example/src/database/advanced-routes.test.ts @@ -107,13 +107,16 @@ describe("advanced article: generated routes", () => { "title", ]); // A private collection is absent from the contract as well as from the - // response - and `syndication` carries only the leaf that was exposed. + // response - and `syndication` carries only the leaves that were exposed. + // `indexable` is still absent, which is the whole point of leaf-level + // allowlisting: `noIndex` joined it in Stage 8 because delivery projects the + // value into a public `robots` directive, and `indexable` did not. expect(shape.relatedArticles).toBeUndefined(); expect( Object.keys( (shape.syndication as unknown as { shape: Record }) .shape, - ), - ).toStrictEqual(["priority"]); + ).sort(), + ).toStrictEqual(["noIndex", "priority"]); }); }); From ef6ee9ca19d98e9046cb92e6c4b59e4439ea0078 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 17:10:58 +0200 Subject: [PATCH 094/123] test(content): cover delivery, redirects and the cache boundary Type tests for the rules an author gets wrong while typing - a private field as an SEO title, prose in a title slot, delivery without a public API - because each is a mistake the editor should catch before the file is saved. Plus the assignability check every stage repeats: an eleventh type parameter on `ContentTypeDefinition` must not break the erased form every relation thunk and route builder is written against. The resolver tests run against the **real** service with only its two reads stubbed, rather than against a copy of its logic: the decision it makes is where a mistake becomes a permanent 308 to the wrong page. The cache tests assert exact tag lists rather than "some revalidation happened", because the whole of Stage 8's opt-in claim at that layer is that an existing content type's tags do not move - and only a byte comparison shows it. `findBasePublication` is added to the five translation-model mocks so the suites that predate it keep exercising what they were written for. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/cache.delivery.test.ts | 251 +++++++ .../vitnode/src/content/delivery.test-d.ts | 285 ++++++++ packages/vitnode/src/content/delivery.test.ts | 652 +++++++++++++++++ .../server/delivery-admin-route.test.ts | 199 ++++++ .../content/server/delivery-effects.test.ts | 225 ++++++ .../content/server/delivery-routes.test.ts | 261 +++++++ .../content/server/delivery-service.test.ts | 658 ++++++++++++++++++ .../content/server/delivery-writes.test.ts | 406 +++++++++++ .../server/localized-preview-routes.test.ts | 6 + .../translation-advanced-revisions.test.ts | 6 + .../translation-editorial-service.test.ts | 6 + .../translation-publication-routes.test.ts | 6 + .../content/server/translation-routes.test.ts | 6 + packages/vitnode/src/content/sitemap.test.ts | 199 ++++++ .../vitnode/src/tests/content-fixtures.ts | 97 +++ 15 files changed, 3263 insertions(+) create mode 100644 packages/vitnode/src/content/cache.delivery.test.ts create mode 100644 packages/vitnode/src/content/delivery.test-d.ts create mode 100644 packages/vitnode/src/content/delivery.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-admin-route.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-effects.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-routes.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-service.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-writes.test.ts create mode 100644 packages/vitnode/src/content/sitemap.test.ts diff --git a/packages/vitnode/src/content/cache.delivery.test.ts b/packages/vitnode/src/content/cache.delivery.test.ts new file mode 100644 index 000000000..7e77ec0fc --- /dev/null +++ b/packages/vitnode/src/content/cache.delivery.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; + +import { + contentDeliveryRedirectTag, + contentDeliverySitemapTag, + contentDeliveryTag, + contentInvalidationTags, + contentPublicItemTag, + contentPublicListTag, + contentPublicSlugTag, +} from "./cache"; + +/** + * The delivery cache tags, and the promise that a content type without `delivery` + * produces exactly the tags it always produced. + * + * That second half is the important one and is why the assertions are exact strings + * rather than "some revalidation happened": the whole of Stage 8's opt-in claim at + * this layer is that an existing content type's tag list does not move, and only a + * byte comparison can show it. + */ + +const ID = "example.article"; + +describe("delivery tag builders", () => { + it("follows the existing namespace, with the locale after the scope", () => { + expect(contentDeliveryTag(ID, 42)).toBe( + "content:example.article:delivery:42", + ); + expect(contentDeliveryTag(ID, 42, "pl")).toBe( + "content:example.article:delivery:pl:42", + ); + + expect(contentDeliveryRedirectTag(ID, "old-slug")).toBe( + "content:example.article:redirect:old-slug", + ); + expect(contentDeliveryRedirectTag(ID, "stary-slug", "pl")).toBe( + "content:example.article:redirect:pl:stary-slug", + ); + + expect(contentDeliverySitemapTag(ID)).toBe( + "content:example.article:sitemap", + ); + expect(contentDeliverySitemapTag(ID, "pl")).toBe( + "content:example.article:sitemap:pl", + ); + }); + + it("normalizes the locale, so PL and pl expire together", () => { + for (const locale of ["PL", "pl", " pl "]) { + expect(contentDeliveryTag(ID, 1, locale)).toBe( + "content:example.article:delivery:pl:1", + ); + expect(contentDeliverySitemapTag(ID, locale)).toBe( + "content:example.article:sitemap:pl", + ); + } + }); +}); + +describe("contentInvalidationTags without delivery", () => { + it("is byte-identical to the Stage 1-7 output for a flat mutation", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + id: 42, + isPublic: true, + slugs: ["old", "new"], + wasPublic: true, + }), + ).toStrictEqual([ + contentPublicListTag(ID), + contentPublicItemTag(ID, 42), + contentPublicSlugTag(ID, "old"), + contentPublicSlugTag(ID, "new"), + ]); + }); + + it("is byte-identical for a localized mutation", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + id: 7, + isPublic: true, + locales: [ + { + isPublic: true, + locale: "pl", + slugs: ["stary", "nowy"], + wasPublic: true, + }, + ], + slugs: [], + wasPublic: true, + }), + ).toStrictEqual([ + contentPublicListTag(ID, "pl"), + contentPublicItemTag(ID, 7, "pl"), + contentPublicSlugTag(ID, "stary", "pl"), + contentPublicSlugTag(ID, "nowy", "pl"), + ]); + }); + + it("still returns nothing for a draft edited into another draft", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + id: 1, + isPublic: false, + slugs: ["a", "b"], + wasPublic: false, + }), + ).toStrictEqual([]); + }); +}); + +describe("contentInvalidationTags with delivery", () => { + it("adds the delivery metadata tag and one redirect tag per slug", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 42, + isPublic: true, + slugs: ["old", "new"], + wasPublic: true, + }), + ).toStrictEqual([ + contentPublicListTag(ID), + contentPublicItemTag(ID, 42), + contentPublicSlugTag(ID, "old"), + contentPublicSlugTag(ID, "new"), + contentDeliveryTag(ID, 42), + contentDeliveryRedirectTag(ID, "old"), + contentDeliveryRedirectTag(ID, "new"), + ]); + }); + + it("adds the sitemap tag only when the set of listed URLs changed", () => { + const withSitemap = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: true }, + id: 42, + isPublic: true, + slugs: ["new"], + wasPublic: false, + }); + + expect(withSitemap).toContain(contentDeliverySitemapTag(ID)); + + const withoutSitemap = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 42, + isPublic: true, + slugs: ["new"], + wasPublic: true, + }); + + expect(withoutSitemap).not.toContain(contentDeliverySitemapTag(ID)); + }); + + it("emits the sitemap tag once for a nonlocalized content type", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: true }, + id: 42, + isPublic: true, + slugs: ["new"], + wasPublic: false, + }); + + expect( + tags.filter(tag => tag === contentDeliverySitemapTag(ID)), + ).toHaveLength(1); + }); + + it("expires each locale's sitemap and the index that lists them", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: true }, + id: 7, + isPublic: true, + locales: [ + { isPublic: true, locale: "en", slugs: ["hello"], wasPublic: true }, + { isPublic: true, locale: "pl", slugs: ["witaj"], wasPublic: false }, + ], + slugs: [], + wasPublic: true, + }); + + expect(tags).toContain(contentDeliverySitemapTag(ID, "en")); + expect(tags).toContain(contentDeliverySitemapTag(ID, "pl")); + // The locale-less one too: a localized content type's index enumerates its + // per-locale files, so a language gaining a page changes the index. + expect(tags).toContain(contentDeliverySitemapTag(ID)); + }); + + it("keeps one locale's delivery tags out of another's", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 7, + isPublic: true, + locales: [ + { + isPublic: true, + locale: "pl", + slugs: ["stary", "nowy"], + wasPublic: true, + }, + ], + slugs: [], + wasPublic: true, + }); + + expect(tags).toContain(contentDeliveryTag(ID, 7, "pl")); + expect(tags).toContain(contentDeliveryRedirectTag(ID, "stary", "pl")); + expect(tags).not.toContain(contentDeliveryTag(ID, 7, "en")); + expect(tags).not.toContain(contentDeliveryRedirectTag(ID, "stary", "en")); + }); + + it("touches nothing at all for a draft that stayed a draft", () => { + // The delivery tags follow the public ones: a mutation that changed no public + // response should not throw away a warm cache for a feature it did not reach. + expect( + contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 1, + isPublic: false, + slugs: ["a", "b"], + wasPublic: false, + }), + ).toStrictEqual([]); + }); + + it("drops an empty slug rather than tagging a redirect for it", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 42, + isPublic: true, + slugs: ["", "new"], + wasPublic: false, + }); + + expect(tags).not.toContain(contentDeliveryRedirectTag(ID, "")); + expect(tags).toContain(contentDeliveryRedirectTag(ID, "new")); + }); +}); diff --git a/packages/vitnode/src/content/delivery.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts new file mode 100644 index 000000000..2c6416fe6 --- /dev/null +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -0,0 +1,285 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import type { ContentEventsFor } from "./events"; +import type { + AnyContentTypeDefinition, + ContentSitemapChangeFrequency, + DeliverableContentTypeDefinition, + ResolvedContentDeliveryConfig, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +/** + * Stage 8 at the type level. + * + * The rules worth a compile error rather than a boot-time one are the ones an author + * gets wrong while typing: naming a private field as an SEO title, putting prose in a + * title slot, or reaching for a delivery service a content type does not have. Every + * `@ts-expect-error` below is a mistake the editor catches before the file is saved. + */ + +const fields = { + excerpt: field.textarea({ maxLength: 500, nullable: true }), + /** Declared but never exposed - the private half of every check below. */ + internalCode: field.text({ nullable: true }), + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + nullable: true, + }), + slug: field.slug({ source: "title" }), + title: field.text({ maxLength: 200, required: true }), + views: field.number({ integer: true, defaultValue: 0 }), +}; + +const shared = { + admin: { label: { plural: "Articles", singular: "Article" } }, + fields, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "excerpt", "seo.title", "seo.description"], + path: "articles", + }, +} as const; + +const deliveredType = defineContentType({ + ...shared, + id: "typed.delivered", + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { + descriptionField: "seo.description", + fallbackDescriptionField: "excerpt", + fallbackTitleField: "title", + openGraph: { descriptionField: "excerpt", titleField: "title" }, + titleField: "seo.title", + }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }, + tableName: "typed_delivered", +}); + +const plainType = defineContentType({ + ...shared, + id: "typed.plain", + tableName: "typed_plain", +}); + +describe("delivery configuration", () => { + it("keeps the `enabled` literal, so every conditional resolves", () => { + expectTypeOf(deliveredType.delivery.enabled).toEqualTypeOf(); + expectTypeOf(plainType.delivery.enabled).toEqualTypeOf(); + }); + + // The whole Stage 8 type design rests on this: an eleventh type parameter on + // `ContentTypeDefinition` must not break the erased form every relation thunk, + // registry and route builder is written against. + it("stays assignable to AnyContentTypeDefinition", () => { + expectTypeOf().toExtend(); + assertType(deliveredType); + assertType(plainType); + }); + + it("narrows to DeliverableContentTypeDefinition only with delivery", () => { + expectTypeOf< + typeof deliveredType + >().toExtend(); + expectTypeOf< + typeof plainType + >().not.toExtend(); + }); + + it("accepts a group leaf and a plain field in the SEO slots", () => { + expectTypeOf(deliveredType.delivery.seo.titleField).toEqualTypeOf< + null | string + >(); + expectTypeOf( + deliveredType.delivery.sitemap.changeFrequency, + ).toEqualTypeOf(); + }); + + it("records the slug scope", () => { + expectTypeOf(deliveredType.delivery.slugScope).toEqualTypeOf< + "localized" | "none" | "shared" + >(); + }); +}); + +describe("delivery requires a public API", () => { + it("refuses `enabled: true` without one", () => { + defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "typed.no-public", + // @ts-expect-error - delivery needs `publicApi: { enabled: true }`: without a + // public allowlist there is no canonical URL for delivery to be about. + delivery: { enabled: true }, + fields, + publication: { enabled: true }, + tableName: "typed_no_public", + }); + }); + + it("still accepts an explicit `enabled: false`", () => { + const off = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "typed.off", + delivery: { enabled: false }, + fields, + publication: { enabled: true }, + tableName: "typed_off", + }); + + expectTypeOf(off.delivery.enabled).toEqualTypeOf(); + }); +}); + +describe("SEO field references", () => { + it("refuses a field the public allowlist withholds", () => { + defineContentType({ + ...shared, + id: "typed.private-seo", + delivery: { + enabled: true, + // @ts-expect-error - `internalCode` is a text field, but it is not in + // `publicApi.fields`, and a `` is rendered into a public page. + seo: { titleField: "internalCode" }, + }, + tableName: "typed_private_seo", + }); + }); + + it("refuses prose in a title slot", () => { + defineContentType({ + ...shared, + id: "typed.prose-title", + delivery: { + enabled: true, + // @ts-expect-error - `excerpt` is a textarea. A `<title>` is one line, and a + // paragraph in a browser tab is not a heading. + seo: { titleField: "excerpt" }, + }, + tableName: "typed_prose_title", + }); + }); + + it("refuses a number in a description slot", () => { + defineContentType({ + ...shared, + id: "typed-bad.description", + delivery: { + enabled: true, + // @ts-expect-error - `views` is a number, and it is private besides. + seo: { descriptionField: "views" }, + }, + tableName: "typed_bad_description", + }); + }); + + it("refuses a nested path the content type does not declare", () => { + defineContentType({ + ...shared, + id: "typed.bad-path", + delivery: { + enabled: true, + // @ts-expect-error - `seo.heading` is not a leaf of the `seo` group. + seo: { titleField: "seo.heading" }, + }, + tableName: "typed_bad_path", + }); + }); + + it("accepts a valid nested group path", () => { + const nested = defineContentType({ + ...shared, + id: "typed.nested", + delivery: { + enabled: true, + seo: { descriptionField: "seo.description", titleField: "seo.title" }, + }, + tableName: "typed_nested", + }); + + expectTypeOf(nested.delivery.enabled).toEqualTypeOf<true>(); + }); + + it("refuses an unknown change frequency", () => { + defineContentType({ + ...shared, + id: "typed.bad-freq", + delivery: { + enabled: true, + // @ts-expect-error - not one of the seven values the protocol defines. + sitemap: { changeFrequency: "fortnightly", enabled: true }, + }, + tableName: "typed_bad_freq", + }); + }); +}); + +describe("the resolved config is generic over `enabled`", () => { + it("pins `true` for a delivered content type", () => { + expectTypeOf(deliveredType.delivery).toExtend< + ResolvedContentDeliveryConfig<true> + >(); + }); + + it("pins `false` for one without", () => { + expectTypeOf(plainType.delivery).toExtend< + ResolvedContentDeliveryConfig<false> + >(); + }); +}); + +describe("Stage 1-7 backward compatibility", () => { + it("leaves the existing fixtures assignable and unchanged", () => { + assertType<AnyContentTypeDefinition>(testArticleContentType); + assertType<AnyContentTypeDefinition>(testPostContentType); + expectTypeOf( + testArticleContentType.delivery.enabled, + ).toEqualTypeOf<false>(); + expectTypeOf(testPostContentType.delivery.enabled).toEqualTypeOf<false>(); + }); +}); + +describe("delivery events", () => { + it("adds both keys for a content type with redirects", () => { + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.delivery_slug_changed", + ); + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.delivery_redirect_created", + ); + }); + + it("adds neither for a content type without delivery", () => { + // The keys are gated on `delivery: { enabled: true }`, so a listener for one + // cannot even be registered - which is what keeps every Stage 1-7 event map + // byte-identical. + expectTypeOf<ContentEventsFor<typeof plainType>>().not.toHaveProperty( + "content.typed.plain.delivery_slug_changed", + ); + expectTypeOf<ContentEventsFor<typeof plainType>>().not.toHaveProperty( + "content.typed.plain.delivery_redirect_created", + ); + }); + + it("keeps the ordinary events in place alongside them", () => { + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.updated", + ); + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.published", + ); + }); +}); diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts new file mode 100644 index 000000000..0386ad949 --- /dev/null +++ b/packages/vitnode/src/content/delivery.test.ts @@ -0,0 +1,652 @@ +import { describe, expect, it } from "vitest"; + +import { defineContentType } from "./define"; +import { + contentDeliveryHreflang, + contentDeliveryOpenGraph, + contentDeliveryPath, + contentDeliveryRobots, + contentDeliverySeo, + contentDeliveryUrl, + contentSitemapDefaults, + isDeliverableContentType, + listDeliveryContentTypes, + parseContentDeliveryPath, +} from "./delivery"; +import { field } from "./fields"; + +/** + * Stage 8 definition validation and the pure delivery projections. + * + * Everything here runs without a database, because everything here is a rule about + * a *definition* or a pure function over a public row - and the rules are the half + * of Stage 8 that has to fail loudly at boot rather than quietly at request time. + */ + +const base = { + admin: { label: { plural: "Articles", singular: "Article" } }, + publication: { enabled: true } as const, + tableName: "delivery_articles", +} as const; + +const publicApi = { + enabled: true, + fields: ["id", "title", "slug", "excerpt", "publishedAt"], + path: "articles", +} as const; + +const fields = { + excerpt: field.textarea({ maxLength: 500, nullable: true }), + hidden: field.boolean({ defaultValue: false }), + /** A text field the public allowlist deliberately withholds. */ + internalCode: field.text({ nullable: true }), + slug: field.slug({ source: "title" }), + title: field.text({ maxLength: 200, required: true }), + views: field.number({ integer: true, defaultValue: 0 }), +}; + +const articleType = defineContentType({ + ...base, + id: "delivery.article", + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }, + fields, + publicApi, +}); + +const plainType = defineContentType({ + ...base, + id: "delivery.plain", + fields, + publicApi, + tableName: "delivery_plain", +}); + +describe("delivery definition validation", () => { + it("defaults to disabled, so a Stage 1-7 content type is unchanged", () => { + expect(plainType.delivery).toStrictEqual({ + enabled: false, + hreflang: { xDefault: null }, + redirects: { enabled: false }, + seo: { + descriptionField: null, + fallbackDescriptionField: null, + fallbackTitleField: null, + noIndexField: null, + openGraph: null, + titleField: null, + }, + sitemap: { changeFrequency: null, enabled: false, priority: null }, + slugScope: "none", + }); + }); + + it("refuses delivery without a public API", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.private", + // Refused by the types too - see `delivery.test-d.ts`. Cast here because + // this asserts the *runtime* guard, which a JavaScript caller still reaches. + delivery: { enabled: true as never }, + fields, + tableName: "delivery_private", + }), + ).toThrow(/delivery needs `publicApi/); + }); + + it("refuses an SEO field that is not publicly exposed", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.private-seo", + // A text field, so the kind check passes - and absent from + // `publicApi.fields`, so a `<title>` built from it would publish something + // the public API deliberately withholds. + delivery: { + enabled: true, + seo: { titleField: "internalCode" as never }, + }, + fields, + publicApi, + tableName: "delivery_private_seo", + }), + ).toThrow(/not in publicApi.fields/); + }); + + it("refuses an unsupported SEO field kind", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-kind", + // `excerpt` is a textarea, which is a description and never a title. + delivery: { enabled: true, seo: { titleField: "excerpt" as never } }, + fields, + publicApi, + tableName: "delivery_bad_kind", + }), + ).toThrow(/of kind "textarea"/); + }); + + it("refuses an unknown SEO field", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.unknown-seo", + delivery: { enabled: true, seo: { titleField: "nope" as never } }, + fields, + publicApi, + tableName: "delivery_unknown_seo", + }), + ).toThrow(/references unknown field "nope"/); + }); + + it("refuses a repeatable leaf as a title", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.repeatable-seo", + delivery: { + enabled: true, + seo: { titleField: "faq.question" as never }, + }, + fields: { + ...fields, + faq: field.repeatable({ + fields: { question: field.text({ required: true }) }, + }), + }, + publicApi: { + ...publicApi, + fields: [...publicApi.fields, "faq.question"], + }, + tableName: "delivery_repeatable_seo", + }), + ).toThrow(/many values rather than one/); + }); + + it("accepts a group leaf as a title and a description", () => { + const withGroup = defineContentType({ + ...base, + id: "delivery.group-seo", + delivery: { + enabled: true, + seo: { + descriptionField: "seo.description", + fallbackTitleField: "title", + titleField: "seo.title", + }, + }, + fields: { + ...fields, + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + nullable: true, + }), + }, + publicApi: { + ...publicApi, + fields: [...publicApi.fields, "seo.title", "seo.description"], + }, + tableName: "delivery_group_seo", + }); + + expect(withGroup.delivery.seo).toMatchObject({ + descriptionField: "seo.description", + fallbackTitleField: "title", + titleField: "seo.title", + }); + }); + + it("refuses a fallback with no primary, which would never be read", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.orphan-fallback", + delivery: { + enabled: true, + seo: { fallbackTitleField: "title" as never }, + }, + fields, + publicApi, + tableName: "delivery_orphan_fallback", + }), + ).toThrow(/without `titleField`/); + }); + + it("refuses a sitemap priority outside 0-1", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-priority", + delivery: { enabled: true, sitemap: { enabled: true, priority: 7 } }, + fields, + publicApi, + tableName: "delivery_bad_priority", + }), + ).toThrow(/between 0 and 1/); + }); + + it("refuses an unknown change frequency", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-freq", + delivery: { + enabled: true, + sitemap: { + // A crawler ignores an unknown value silently, so a typo has to be + // caught here or it is a hint nobody ever receives. + changeFrequency: "fortnightly" as never, + enabled: true, + }, + }, + fields, + publicApi, + tableName: "delivery_bad_freq", + }), + ).toThrow(/sitemap protocol defines/); + }); + + it("refuses a non-boolean noIndexField", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-noindex", + delivery: { enabled: true, seo: { noIndexField: "title" as never } }, + fields, + publicApi, + tableName: "delivery_bad_noindex", + }), + ).toThrow(/Expected one of: boolean/); + }); + + it("refuses x-default without localization", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-xdefault", + delivery: { enabled: true, hreflang: { xDefault: "defaultLocale" } }, + fields, + publicApi, + tableName: "delivery_bad_xdefault", + }), + ).toThrow(/delivery.hreflang needs `localization/); + }); + + it("records the slug scope so history knows which language owns a URL", () => { + expect(articleType.delivery.slugScope).toBe("shared"); + expect(localizedType.delivery.slugScope).toBe("localized"); + }); + + it("refuses redirects on a localized content type with a shared slug", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.shared-slug", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + body: field.textarea({ localized: true, required: true }), + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + fields: ["title", "slug", "body"], + path: "articles", + }, + tableName: "delivery_shared_slug", + }), + ).toThrow(/needs a localized slug field/); + }); + + it("refuses a localized noIndexField", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.localized-noindex", + delivery: { + enabled: true, + seo: { noIndexField: "flags.noIndex" as never }, + }, + fields: { + // A localized group's leaves live on the translation table, so the value + // would differ per language while the record has one canonical decision. + flags: field.group({ + fields: { noIndex: field.boolean({ defaultValue: false }) }, + localized: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + fields: ["title", "slug", "flags.noIndex"], + path: "articles", + }, + tableName: "delivery_localized_noindex", + }), + ).toThrow(/has to be shared/); + }); +}); + +const localizedType = defineContentType({ + ...base, + id: "delivery.localized", + delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + redirects: { enabled: true }, + seo: { + descriptionField: "seo.description", + fallbackTitleField: "title", + titleField: "seo.title", + }, + sitemap: { changeFrequency: "daily", enabled: true, priority: 0.5 }, + }, + fields: { + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + localized: true, + nullable: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "seo.title", "seo.description"], + path: "articles", + }, + tableName: "delivery_localized", +}); + +describe("contentDeliveryPath", () => { + it("has no locale segment for a nonlocalized content type", () => { + expect( + contentDeliveryPath({ definition: articleType, slug: "my-article" }), + ).toBe("/articles/my-article"); + }); + + it("prefixes the locale for a localized content type", () => { + expect( + contentDeliveryPath({ + definition: localizedType, + locale: "pl", + slug: "moj-artykul", + }), + ).toBe("/pl/articles/moj-artykul"); + }); + + it("normalizes the locale, so one URL has one cache key", () => { + const paths = ["PL", "pl", " pl "].map(locale => + contentDeliveryPath({ definition: localizedType, locale, slug: "witaj" }), + ); + + expect(new Set(paths).size).toBe(1); + expect(paths[0]).toBe("/pl/articles/witaj"); + }); + + it("refuses to build a localized path with no locale", () => { + expect( + contentDeliveryPath({ definition: localizedType, slug: "witaj" }), + ).toBeNull(); + }); + + it("is null for an empty slug rather than pointing at the list page", () => { + expect( + contentDeliveryPath({ definition: articleType, slug: " " }), + ).toBeNull(); + }); + + it("percent-encodes a slug that was written straight into the database", () => { + expect( + contentDeliveryPath({ definition: articleType, slug: "a b/c" }), + ).toBe("/articles/a%20b%2Fc"); + }); +}); + +describe("contentDeliveryUrl", () => { + it("resolves a path against an origin, with or without a trailing slash", () => { + for (const origin of ["https://example.com", "https://example.com/"]) { + expect(contentDeliveryUrl({ origin, path: "/articles/x" })).toBe( + "https://example.com/articles/x", + ); + } + }); + + it("is null for a malformed origin rather than a URL with two schemes", () => { + expect(contentDeliveryUrl({ origin: "not a url", path: "/x" })).toBeNull(); + }); + + it("passes a null path straight through", () => { + expect( + contentDeliveryUrl({ origin: "https://example.com", path: null }), + ).toBeNull(); + }); +}); + +describe("parseContentDeliveryPath", () => { + it("round-trips the path it builds", () => { + expect( + parseContentDeliveryPath(articleType, "/articles/my-article"), + ).toStrictEqual({ locale: null, slug: "my-article" }); + + expect( + parseContentDeliveryPath(localizedType, "/pl/articles/moj-artykul"), + ).toStrictEqual({ locale: "pl", slug: "moj-artykul" }); + }); + + it("decodes the slug and normalizes the locale", () => { + expect( + parseContentDeliveryPath(localizedType, "/PL/articles/a%20b"), + ).toStrictEqual({ locale: "pl", slug: "a b" }); + }); + + it("strips a query string and a fragment", () => { + expect( + parseContentDeliveryPath(articleType, "/articles/x?utm=1#top"), + ).toStrictEqual({ locale: null, slug: "x" }); + }); + + it("refuses a path that belongs to another content type", () => { + expect(parseContentDeliveryPath(articleType, "/news/x")).toBeNull(); + }); + + it("refuses the wrong number of segments", () => { + for (const path of ["/articles", "/articles/a/b", "/pl/articles/a"]) { + expect(parseContentDeliveryPath(articleType, path)).toBeNull(); + } + }); + + it("refuses a traversal and a malformed escape", () => { + expect(parseContentDeliveryPath(articleType, "/articles/..")).toBeNull(); + expect(parseContentDeliveryPath(articleType, "/articles/%zz")).toBeNull(); + }); + + it("refuses a path longer than the stored column", () => { + expect( + parseContentDeliveryPath(articleType, `/articles/${"a".repeat(600)}`), + ).toBeNull(); + }); +}); + +describe("SEO projection", () => { + it("reads the configured fields off a public row", () => { + expect( + contentDeliverySeo(articleType, { + excerpt: "A summary.", + title: "My article", + }), + ).toStrictEqual({ description: "A summary.", title: "My article" }); + }); + + it("falls back only when the primary is empty", () => { + expect( + contentDeliverySeo(localizedType, { + seo: { description: null, title: " " }, + title: "The heading", + }), + ).toStrictEqual({ description: null, title: "The heading" }); + + expect( + contentDeliverySeo(localizedType, { + seo: { description: "d", title: "SEO heading" }, + title: "The heading", + }), + ).toStrictEqual({ description: "d", title: "SEO heading" }); + }); + + it("never invents a description from other content", () => { + expect( + contentDeliverySeo(articleType, { excerpt: null, title: "T" }), + ).toStrictEqual({ description: null, title: "T" }); + }); + + it("cannot read a field the public row does not carry", () => { + // The row is the public projection, so a private field is absent from the + // object entirely rather than merely skipped. + expect(contentDeliverySeo(articleType, { views: 9 })).toStrictEqual({ + description: null, + title: null, + }); + }); + + it("is a stable shape for a content type that configured nothing", () => { + expect(contentDeliverySeo(plainType, { title: "T" })).toStrictEqual({ + description: null, + title: null, + }); + }); +}); + +describe("Open Graph projection", () => { + it("is null when the content type configured none", () => { + expect(contentDeliveryOpenGraph(articleType, { title: "T" })).toBeNull(); + }); + + it("falls back to the ordinary SEO slots", () => { + const withOg = defineContentType({ + ...base, + id: "delivery.og", + delivery: { + enabled: true, + seo: { openGraph: {}, titleField: "title" }, + }, + fields, + publicApi, + tableName: "delivery_og", + }); + + expect( + contentDeliveryOpenGraph(withOg, { title: "Shared heading" }), + ).toStrictEqual({ description: null, title: "Shared heading" }); + }); +}); + +describe("robots projection", () => { + it("is null without a noIndexField", () => { + expect(contentDeliveryRobots(articleType, {})).toBeNull(); + }); + + it("reads the boolean and always allows following", () => { + const withNoIndex = defineContentType({ + ...base, + id: "delivery.noindex", + delivery: { enabled: true, seo: { noIndexField: "hidden" } }, + fields, + publicApi: { ...publicApi, fields: [...publicApi.fields, "hidden"] }, + tableName: "delivery_noindex", + }); + + expect(contentDeliveryRobots(withNoIndex, { hidden: true })).toStrictEqual({ + follow: true, + index: false, + }); + expect(contentDeliveryRobots(withNoIndex, { hidden: false })).toStrictEqual( + { + follow: true, + index: true, + }, + ); + }); +}); + +describe("contentDeliveryHreflang", () => { + const alternates = [ + { locale: "en", path: "/en/articles/my-article" }, + { locale: "pl", path: "/pl/articles/moj-artykul" }, + ]; + + it("maps alternates to a language map", () => { + expect( + contentDeliveryHreflang({ alternates, definition: localizedType }), + ).toStrictEqual({ + languages: { + en: "/en/articles/my-article", + pl: "/pl/articles/moj-artykul", + }, + xDefault: "/en/articles/my-article", + }); + }); + + it("omits x-default when the default locale is not published", () => { + expect( + contentDeliveryHreflang({ + alternates: [alternates[1]], + definition: localizedType, + }), + ).toStrictEqual({ languages: { pl: "/pl/articles/moj-artykul" } }); + }); + + it("emits no x-default when the content type did not ask for one", () => { + expect( + contentDeliveryHreflang({ alternates, definition: articleType }), + ).toStrictEqual({ + languages: { + en: "/en/articles/my-article", + pl: "/pl/articles/moj-artykul", + }, + }); + }); +}); + +describe("registry helpers", () => { + it("lists only delivery-enabled content types, in a stable order", () => { + const entries = [ + { definition: localizedType, pluginId: "b" }, + { definition: plainType, pluginId: "a" }, + { definition: articleType, pluginId: "a" }, + ]; + + expect( + listDeliveryContentTypes(entries).map(entry => entry.definition.id), + ).toStrictEqual(["delivery.article", "delivery.localized"]); + }); + + it("narrows a definition to a deliverable one", () => { + expect(isDeliverableContentType(articleType)).toBe(true); + expect(isDeliverableContentType(plainType)).toBe(false); + }); + + it("reports the sitemap defaults, or nothing", () => { + expect(contentSitemapDefaults(articleType)).toStrictEqual({ + changeFrequency: "weekly", + priority: 0.7, + }); + expect(contentSitemapDefaults(plainType)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-admin-route.test.ts b/packages/vitnode/src/content/server/delivery-admin-route.test.ts new file mode 100644 index 000000000..b47a659ed --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-admin-route.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment node +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + testDeliveredPostContentType, + testEditorialPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; + +let permissionGranted = true; +let requestedPermission: null | { module: string; permission: string } = null; + +// `assertStaffPermission` reads roles out of the database. What matters here is that +// the route *asks* for `can_view` and nothing narrower, so the check itself is +// replaced with a recorder plus a switchable verdict. +vi.mock("../../api/lib/check-staff-permission", () => ({ + assertStaffPermission: async ( + _c: unknown, + args: { module: string; permission: string }, + ) => { + requestedPermission = { module: args.module, permission: args.permission }; + if (!permissionGranted) { + const { HTTPException } = await import("hono/http-exception"); + throw new HTTPException(403, { message: "Forbidden" }); + } + }, +})); + +const delivered = createContentModel(testDeliveredPostContentType); +const editorialPosts = createContentModel(testEditorialPostContentType); + +const PLUGIN_ID = "@vitnode/example"; + +const harness = () => { + const service = { + alternates: vi.fn(), + findById: vi.fn(), + history: vi.fn().mockResolvedValue([]), + resolvePath: vi.fn(), + resolveSlug: vi.fn(), + sitemap: vi.fn(), + }; + + vi.spyOn(delivered, "deliveryService", "get").mockReturnValue(() => service); + + const app = new OpenAPIHono(); + for (const { handler, route } of buildContentRoutes(delivered, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, service }; +}; + +beforeEach(() => { + permissionGranted = true; + requestedPermission = null; +}); + +describe("route generation", () => { + it("adds the delivery route only for a delivery-enabled content type", () => { + const withDelivery = buildContentRoutes(delivered, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + const without = buildContentRoutes(editorialPosts, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + expect(withDelivery).toContain("/{id}/delivery"); + expect(without).not.toContain("/{id}/delivery"); + }); +}); + +describe("admin delivery route", () => { + it("is gated by can_view rather than a permission of its own", async () => { + const { app } = harness(); + + await app.request("/42/delivery"); + + // Read-only, so the permission that allowed the slug mutation is the only one it + // needs. A `can_manage_redirects` would be a permission every install has to + // configure for no decision this screen can make. + expect(requestedPermission).toStrictEqual({ + module: testDeliveredPostContentType.permissionModule, + permission: "can_view", + }); + }); + + it("refuses a request without the permission", async () => { + permissionGranted = false; + const { app } = harness(); + + expect((await app.request("/42/delivery")).status).toBe(403); + }); + + it("reports the canonical URL and the historical ones", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue({ + canonicalPath: "/delivered-posts/current", + locale: null, + }); + service.history.mockResolvedValue([ + { + createdAt: new Date("2026-01-01T00:00:00.000Z"), + itemId: 42, + languageId: null, + path: "/delivered-posts/current", + retiredAt: null, + slug: "current", + }, + { + createdAt: new Date("2025-12-01T00:00:00.000Z"), + itemId: 42, + languageId: null, + path: "/delivered-posts/old", + retiredAt: new Date("2026-01-01T00:00:00.000Z"), + slug: "old", + }, + ]); + + const response = await app.request("/42/delivery"); + const body = (await response.json()) as { + canonicalPath: string; + history: Record<string, unknown>[]; + isPublic: boolean; + }; + + expect(response.status).toBe(200); + expect(body.canonicalPath).toBe("/delivered-posts/current"); + expect(body.isPublic).toBe(true); + expect(body.history).toHaveLength(2); + }); + + it("exposes no storage columns of the history table", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue({ + canonicalPath: "/delivered-posts/current", + locale: null, + }); + service.history.mockResolvedValue([ + { + createdAt: new Date(0), + itemId: 42, + languageId: 2, + path: "/delivered-posts/old", + retiredAt: new Date(0), + slug: "old", + }, + ]); + + const body = (await (await app.request("/42/delivery")).json()) as { + history: Record<string, unknown>[]; + }; + + // `languageId`, `pluginId` and the row id are details of + // `core_content_slug_history`, not part of this contract. + expect(Object.keys(body.history[0]).sort()).toStrictEqual([ + "createdAt", + "path", + "retiredAt", + "slug", + ]); + }); + + it("reports a draft as having no canonical URL rather than inventing one", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(null); + + const body = (await (await app.request("/42/delivery")).json()) as { + canonicalPath: null | string; + isPublic: boolean; + }; + + // "This is where it *would* live" is a different claim from "this is where it + // lives", and the panel must not make the first one look like the second. + expect(body.canonicalPath).toBeNull(); + expect(body.isPublic).toBe(false); + }); + + it("scopes the read to one language when asked", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue({ canonicalPath: null, locale: "pl" }); + + await app.request("/42/delivery?locale=pl"); + + expect(service.findById).toHaveBeenCalledWith(42, { locale: "pl" }); + expect(service.history).toHaveBeenCalledWith(42, { locale: "pl" }); + }); + + it("rejects an invalid identifier", async () => { + const { app } = harness(); + + expect((await app.request("/abc/delivery")).status).toBe(400); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts new file mode 100644 index 000000000..099477e8c --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -0,0 +1,225 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { ContentDeliveryOutcome } from "./delivery-writes"; + +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { + contentDeliveryEffects, + contentDeliveryInvalidation, +} from "./delivery-effects"; + +/** + * Which delivery events one mutation emits, and which it deliberately does not. + * + * Both events are gated on a *fact* rather than on an operation: the URL moved, and + * the old address had been live. A listener that warms a CDN or writes an edge + * redirect table acts on the second one, so emitting it for a corrected draft would + * make it act on a URL nobody ever visited. + */ + +const articleType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "effects.article", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "effects_articles", +}); + +const plainType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "effects.plain", + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "effects_plain", +}); + +const outcome = ( + overrides: Partial<ContentDeliveryOutcome> = {}, +): ContentDeliveryOutcome => ({ + canonicalPath: "/articles/new", + itemId: 42, + locale: null, + previousPath: "/articles/old", + previousSlug: "old", + redirectCreated: true, + sitemapChanged: true, + slug: "new", + slugChanged: true, + ...overrides, +}); + +const buildContext = () => { + const emitted: { name: string; payload: Record<string, unknown> }[] = []; + + const c = { + get: (key: string) => { + if (key === "events") { + return { + emit: async (name: string, payload: Record<string, unknown>) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ failures: [] }); + }, + }; + } + + return undefined; + }, + } as unknown as Context; + + return { c, emitted }; +}; + +describe("contentDeliveryEffects", () => { + it("emits both events when a live URL moves", async () => { + const { c, emitted } = buildContext(); + + const result = await contentDeliveryEffects(c, articleType, outcome(), { + pluginId: "@vitnode/test", + }); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.effects.article.delivery_slug_changed", + "content.effects.article.delivery_redirect_created", + ]); + expect(emitted[0].payload).toStrictEqual({ + canonicalPath: "/articles/new", + contentId: 42, + locale: null, + previousPath: "/articles/old", + previousSlug: "old", + slug: "new", + }); + expect(emitted[1].payload).toStrictEqual({ + canonicalPath: "/articles/new", + contentId: 42, + locale: null, + previousPath: "/articles/old", + previousSlug: "old", + }); + expect(result.events).toHaveLength(2); + }); + + it("emits only the slug event when the old address was never live", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects( + c, + articleType, + outcome({ redirectCreated: false }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.effects.article.delivery_slug_changed", + ]); + }); + + it("emits nothing when no URL moved", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects( + c, + articleType, + outcome({ + previousPath: null, + previousSlug: null, + redirectCreated: false, + slugChanged: false, + }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted).toStrictEqual([]); + }); + + it("emits nothing for a mutation that reported no delivery outcome", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects(c, articleType, undefined, { + pluginId: "@vitnode/test", + }); + + expect(emitted).toStrictEqual([]); + }); + + it("emits nothing when the canonical path cannot be built", async () => { + const { c, emitted } = buildContext(); + + // A slug written straight into the database, or a localized content type with a + // shared slug: no single canonical path, so no delivery fact to announce. + await contentDeliveryEffects( + c, + articleType, + outcome({ canonicalPath: null }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted).toStrictEqual([]); + }); + + it("carries the locale on a localized move", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects( + c, + articleType, + outcome({ + canonicalPath: "/pl/articles/nowy", + locale: "pl", + previousPath: "/pl/articles/stary", + previousSlug: "stary", + slug: "nowy", + }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted[0].payload).toMatchObject({ locale: "pl" }); + }); +}); + +describe("contentDeliveryInvalidation", () => { + it("is undefined for a content type without delivery", () => { + expect(contentDeliveryInvalidation(plainType, outcome())).toBeUndefined(); + }); + + it("reports the sitemap only when the set of listed URLs changed", () => { + expect(contentDeliveryInvalidation(articleType, outcome())).toStrictEqual({ + sitemap: true, + }); + expect( + contentDeliveryInvalidation( + articleType, + outcome({ sitemapChanged: false }), + ), + ).toStrictEqual({ sitemap: false }); + }); + + it("still expires the delivery metadata when no URL moved", () => { + // A shared SEO field moving changes what every locale's `<head>` renders even + // though nothing was added to or removed from the sitemap. + expect(contentDeliveryInvalidation(articleType, undefined)).toStrictEqual({ + sitemap: false, + }); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-routes.test.ts b/packages/vitnode/src/content/server/delivery-routes.test.ts new file mode 100644 index 000000000..a49ba15c3 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-routes.test.ts @@ -0,0 +1,261 @@ +// @vitest-environment node +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import { + testDeliveredPostContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { buildContentPublicRoutes } from "./public-routes"; + +/** + * The generated public delivery routes. + * + * Two things are being asserted, and only one of them is about delivery: + * + * 1. The routes answer without any session at all, and their bodies match the + * schemas the OpenAPI document publishes - including the discriminated union, + * whose whole purpose is that a client can branch on `type` rather than guess. + * 2. A content type **without** `delivery` gains no routes whatsoever. That is the + * Stage 1-7 regression assertion at the routing layer: the path list of an + * existing public content type does not move. + */ + +const delivered = createContentModel(testDeliveredPostContentType); + +const PLUGIN_ID = "@vitnode/example"; + +const metadata = { + alternates: [], + canonicalPath: "/delivered-posts/hello-world", + hreflang: { languages: {} }, + isFallback: false, + itemId: 42, + locale: null, + openGraph: { description: "Prose", title: "Hello world" }, + requestedLocale: null, + robots: { follow: true, index: true }, + seo: { description: "Prose", title: "Hello world" }, +}; + +const harness = () => { + const service = { + alternates: vi.fn(), + findById: vi.fn(), + history: vi.fn(), + resolvePath: vi.fn(), + resolveSlug: vi.fn(), + sitemap: vi.fn(), + }; + + vi.spyOn(delivered, "deliveryService", "get").mockReturnValue(() => service); + // The public service is never reached by a delivery route - the delivery service + // is - but the route builder still asks the model for it. + vi.spyOn(delivered, "publicService", "get").mockReturnValue(() => ({ + findById: vi.fn(), + findBySlug: vi.fn(), + findMany: vi.fn(), + })); + + const app = new OpenAPIHono(); + for (const { handler, route } of buildContentPublicRoutes(delivered, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, service }; +}; + +describe("route generation", () => { + it("adds three delivery routes under a static `delivery` segment", () => { + const paths = buildContentPublicRoutes(delivered, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + expect(paths).toContain("/delivery/resolve/{slug}"); + expect(paths).toContain("/delivery/item/{id}"); + expect(paths).toContain("/delivery/sitemap"); + }); + + it("adds none at all to a content type without delivery", () => { + const posts = createContentModel(testPostContentType, { + references: { category: () => delivered.table.id }, + }); + + const paths = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID }).map( + entry => entry.route.path, + ); + + // The Stage 1-7 path list, unchanged. + expect(paths).toStrictEqual(["/", "/{slug}"]); + }); + + it("cannot be shadowed by a record whose slug is `delivery`", () => { + // `/{slug}` is one segment and every delivery path is two or three, so the two + // can never both match whatever order they are registered in. + const paths = buildContentPublicRoutes(delivered, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + expect(paths.filter(path => path === "/{slug}")).toHaveLength(1); + expect(paths.every(path => path.split("/").length <= 4)).toBe(true); + }); +}); + +describe("resolve route", () => { + it("answers without any session at all", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ ...metadata, type: "content" }); + + const response = await app.request("/delivery/resolve/hello-world"); + + expect(response.status).toBe(200); + }); + + it("returns the canonical arm for a current slug", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ ...metadata, type: "content" }); + + const response = await app.request("/delivery/resolve/hello-world"); + + expect(await response.json()).toMatchObject({ + canonicalPath: "/delivered-posts/hello-world", + itemId: 42, + type: "content", + }); + }); + + it("returns the redirect arm with its status in the body", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ + location: "/delivered-posts/new", + status: 308, + type: "redirect", + }); + + const response = await app.request("/delivery/resolve/old"); + + // A 200 carrying a redirect, not an HTTP redirect: the *frontend* issues the + // 308, because it owns the URL the reader is on. + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual({ + location: "/delivered-posts/new", + status: 308, + type: "redirect", + }); + }); + + it("returns the not_found arm as a 200 with a body", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ type: "not_found" }); + + const response = await app.request("/delivery/resolve/nope"); + + // A 200, so a caller distinguishes "this URL resolves to nothing" from "the + // delivery API is unreachable" - and so a negative is not cached as a 404. + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual({ type: "not_found" }); + }); + + it("exposes no internal storage fields", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ ...metadata, type: "content" }); + + const body = (await ( + await app.request("/delivery/resolve/hello-world") + ).json()) as Record<string, unknown>; + + for (const internal of ["languageId", "pluginId", "retiredAt"]) { + expect(body).not.toHaveProperty(internal); + } + }); +}); + +describe("item route", () => { + it("returns the delivery metadata of one record", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(metadata); + + const response = await app.request("/delivery/item/42"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + canonicalPath: "/delivered-posts/hello-world", + seo: { description: "Prose", title: "Hello world" }, + }); + expect(service.findById).toHaveBeenCalledWith(42, { locale: undefined }); + }); + + it("is a 404 for a record with no public version", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(null); + + expect((await app.request("/delivery/item/42")).status).toBe(404); + }); + + it("rejects a non-numeric identifier at validation time", async () => { + const { app } = harness(); + + expect((await app.request("/delivery/item/abc")).status).toBe(400); + }); +}); + +describe("sitemap route", () => { + it("serializes lastModified as an ISO string, matching its schema", async () => { + const { app, service } = harness(); + service.sitemap.mockResolvedValue({ + entries: [ + { + changeFrequency: "weekly", + itemId: 42, + lastModified: new Date("2026-01-02T03:04:05.000Z"), + locale: null, + path: "/delivered-posts/hello-world", + priority: 0.7, + }, + ], + nextCursor: null, + }); + + const response = await app.request("/delivery/sitemap"); + + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual({ + entries: [ + { + changeFrequency: "weekly", + itemId: 42, + lastModified: "2026-01-02T03:04:05.000Z", + locale: null, + path: "/delivered-posts/hello-world", + priority: 0.7, + }, + ], + nextCursor: null, + }); + }); + + it("passes the cursor and limit through", async () => { + const { app, service } = harness(); + service.sitemap.mockResolvedValue({ entries: [], nextCursor: null }); + + await app.request("/delivery/sitemap?cursor=99&limit=10"); + + expect(service.sitemap).toHaveBeenCalledWith({ + cursor: 99, + limit: 10, + locale: undefined, + }); + }); + + it("rejects a limit above the protocol ceiling", async () => { + const { app } = harness(); + + expect((await app.request("/delivery/sitemap?limit=50001")).status).toBe( + 400, + ); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-service.test.ts b/packages/vitnode/src/content/server/delivery-service.test.ts new file mode 100644 index 000000000..0d7835ca8 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-service.test.ts @@ -0,0 +1,658 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; + +import { core_content_slug_history } from "../../database/content"; +import { core_languages } from "../../database/languages"; +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { createContentDeliveryService } from "./delivery-service"; + +/** + * The delivery resolver, against the real service, without a database. + * + * The two reads it performs - the public projection and the slug-history lookup - + * are stubbed, and nothing else is: `createContentDeliveryService` is the code under + * test, so the decision it makes (canonical, redirect, or nothing) is the thing + * being asserted rather than a copy of it. That decision is where a mistake becomes + * a permanent 308 to the wrong page, which is exactly why it is worth testing + * without the ceremony of a database. + * + * The queries themselves are covered by the Postgres suite in `plugins/example`. + */ + +const PLUGIN = "@vitnode/test"; + +const articleType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "delivery.article", + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { enabled: true, priority: 0.7 }, + }, + fields: { + excerpt: field.textarea({ nullable: true }), + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "excerpt"], + path: "articles", + }, + tableName: "delivery_articles", +}); + +const withoutRedirects = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "delivery.no-redirects", + delivery: { enabled: true, sitemap: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "delivery_no_redirects", +}); + +const localizedType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "delivery.localized", + delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + redirects: { enabled: true }, + seo: { fallbackTitleField: "title", titleField: "seo.title" }, + sitemap: { enabled: true }, + }, + fields: { + seo: field.group({ + fields: { title: field.text({ nullable: true }) }, + localized: true, + nullable: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "seo.title"], + path: "articles", + }, + tableName: "delivery_localized_articles", +}); + +/** One retired or current address, as `core_content_slug_history` stores it. */ +interface HistoryRow { + itemId: number; + languageId: null | number; + path: string; + retiredAt: Date | null; + slug: string; +} + +/** One public row, and the language it is in. */ +interface PublicRow { + locale?: string; + values: Record<string, unknown>; +} + +type QueryRows = Record<string, unknown>[]; + +/** + * A Drizzle query builder that resolves to whatever the table asks for. + * + * A thenable rather than a promise-returning `limit()`, because the two reads this + * file needs end differently: the language registry awaits straight off `.from()` + * and the history lookup chains `.where().limit(1)` (and sometimes `.for("update")`). + * One thenable satisfies both without the stub having to know which. + */ +const buildDatabase = (rowsFor: (table: unknown) => QueryRows): unknown => { + const select = () => { + let table: unknown; + + const builder = { + for: () => builder, + from: (value: unknown) => { + table = value; + + return builder; + }, + limit: () => builder, + orderBy: () => builder, + then: async ( + resolve: (rows: QueryRows) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(rowsFor(table)).then(resolve, reject), + where: () => builder, + }; + + return builder; + }; + + return { select }; +}; + +/** + * A model whose public service is a map and whose history table is an array. + * + * `findById` mimics the Stage 5 fallback rule rather than re-deriving it: a locale + * with no row of its own is served the default one, and the row says which language + * it is actually in. That is the contract `createContentLocalizedPublicService` + * holds, and reading through it is the whole reason delivery inherits the + * publication predicate and the field allowlist for free. + */ +const buildService = ({ + byId = {}, + bySlug = {}, + definition, + history = [], + languages = [ + { code: "en", id: 1 }, + { code: "pl", id: 2 }, + ], +}: { + byId?: Record<number, PublicRow[]>; + bySlug?: Record<string, { itemId: number; locale?: string }>; + definition: AnyContentTypeDefinition; + history?: HistoryRow[]; + languages?: { code: string; id: number }[]; +}) => { + const localized = definition.localization.enabled; + const defaultLocale = definition.localization.defaultLocale; + + const rowFor = ( + itemId: number, + locale: string | undefined, + ): null | Record<string, unknown> => { + const rows = byId[itemId] ?? []; + if (!localized) return rows[0]?.values ?? null; + + const wanted = (locale ?? defaultLocale).toLowerCase(); + const exact = rows.find(entry => entry.locale === wanted); + if (exact) return { ...exact.values, locale: exact.locale }; + + if (definition.localization.fallback !== "default") return null; + + const fallback = rows.find(entry => entry.locale === defaultLocale); + + return fallback ? { ...fallback.values, locale: fallback.locale } : null; + }; + + const publicService = { + findById: async (id: number, options?: { locale?: string }) => + await Promise.resolve(rowFor(id, options?.locale)), + findBySlug: async (slug: string, options?: { locale?: string }) => { + const hit = bySlug[slug]; + if (!hit) return await Promise.resolve(null); + // Strict-locale, exactly as the real service is: a URL belongs to the + // language it was published under. + if ( + localized && + hit.locale !== (options?.locale ?? defaultLocale).toLowerCase() + ) { + return await Promise.resolve(null); + } + + return await Promise.resolve(rowFor(hit.itemId, hit.locale)); + }, + findMany: async () => + await Promise.resolve({ edges: [], pageInfo: {} as never }), + }; + + const database = buildDatabase(table => { + if (table === core_languages) { + return languages.map(language => ({ + code: language.code, + id: language.id, + isDefault: language.code === defaultLocale, + })); + } + + if (table === core_content_slug_history) { + // The resolver asks for one address at a time, so the stub returns the whole + // set and relies on the service having narrowed it - which it cannot here. + // Each test therefore supplies at most one row. + return history.map(row => ({ createdAt: new Date(0), ...row })); + } + + return []; + }); + + const c = { + get: (key: string) => { + if (key === "db") return database; + if (key === "core") return { i18n: { locales: [] } }; + + return undefined; + }, + } as unknown as Context; + + const model = { + columns: {}, + definition, + publicService: () => publicService, + table: {}, + translationColumns: null, + translationTable: null, + } as unknown as ContentModel<AnyContentTypeDefinition>; + + return createContentDeliveryService({ c, model, pluginId: PLUGIN }); +}; + +const article = (slug: string, id = 42): PublicRow => ({ + values: { excerpt: null, id, slug, title: "T" }, +}); + +const translation = (locale: string, slug: string, id = 7): PublicRow => ({ + locale, + values: { id, seo: { title: null }, slug, title: "T" }, +}); + +describe("createContentDeliveryService", () => { + it("refuses a content type with no delivery block", () => { + const plain = defineContentType({ + admin: { label: { plural: "P", singular: "P" } }, + id: "delivery.none", + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["title", "slug"], path: "p" }, + tableName: "delivery_none", + }); + + expect(() => buildService({ definition: plain })).toThrow( + /no `delivery` block/, + ); + }); +}); + +describe("resolveSlug", () => { + it("answers the current slug as canonical content", async () => { + const service = buildService({ + byId: { 42: [article("current")] }, + bySlug: { current: { itemId: 42 } }, + definition: articleType, + }); + + expect(await service.resolveSlug("current")).toMatchObject({ + canonicalPath: "/articles/current", + itemId: 42, + type: "content", + }); + }); + + it("redirects a retired slug to the current canonical path", async () => { + const service = buildService({ + byId: { 42: [article("current")] }, + bySlug: { current: { itemId: 42 } }, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/old", + retiredAt: new Date(), + slug: "old", + }, + ], + }); + + expect(await service.resolveSlug("old")).toStrictEqual({ + location: "/articles/current", + status: 308, + type: "redirect", + }); + }); + + it("collapses a chain: both a and b resolve straight to c", async () => { + for (const retired of ["a", "b"]) { + const service = buildService({ + byId: { 42: [article("c")] }, + bySlug: { c: { itemId: 42 } }, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: `/articles/${retired}`, + retiredAt: new Date(), + slug: retired, + }, + ], + }); + + // One hop, not two: the resolver reads the record's *current* slug rather + // than the next entry in the chain. + expect(await service.resolveSlug(retired)).toStrictEqual({ + location: "/articles/c", + status: 308, + type: "redirect", + }); + } + }); + + it("is not_found when the destination is no longer public", async () => { + const service = buildService({ + // No public row: an unpublished or deleted record looks like this from here. + byId: {}, + bySlug: {}, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/old", + retiredAt: new Date(), + slug: "old", + }, + ], + }); + + expect(await service.resolveSlug("old")).toStrictEqual({ + type: "not_found", + }); + }); + + it("is not_found for a slug nothing has ever used", async () => { + const service = buildService({ definition: articleType }); + + expect(await service.resolveSlug("never-existed")).toStrictEqual({ + type: "not_found", + }); + }); + + it("never redirects a slug to itself", async () => { + const service = buildService({ + byId: { 42: [article("same")] }, + // The live lookup misses - a stale reservation - and the destination equals + // the address asked for. A redirect loop is worse than a 404. + bySlug: {}, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/same", + retiredAt: null, + slug: "same", + }, + ], + }); + + expect(await service.resolveSlug("same")).toStrictEqual({ + type: "not_found", + }); + }); + + it("never reads the history without redirects", async () => { + const service = buildService({ + byId: { 42: [{ values: { id: 42, slug: "current", title: "T" } }] }, + bySlug: {}, + definition: withoutRedirects, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/old", + retiredAt: new Date(), + slug: "old", + }, + ], + }); + + expect(await service.resolveSlug("old")).toStrictEqual({ + type: "not_found", + }); + }); +}); + +describe("localized resolveSlug", () => { + it("keeps a locale's redirect inside its own language", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello-world")] }, + bySlug: {}, + definition: localizedType, + history: [ + { + itemId: 7, + languageId: 1, + path: "/en/articles/hello", + retiredAt: new Date(), + slug: "hello", + }, + ], + }); + + expect(await service.resolveSlug("hello", { locale: "en" })).toStrictEqual({ + location: "/en/articles/hello-world", + status: 308, + type: "redirect", + }); + }); + + it("refuses to point one locale's URL at another language's page", async () => { + const service = buildService({ + // Published in English only. A Polish historical URL must not 308 to the + // English page: that is the wrong language under a URL that says otherwise, + // declared permanent. + byId: { 7: [translation("en", "hello")] }, + bySlug: {}, + definition: localizedType, + history: [ + { + itemId: 7, + languageId: 2, + path: "/pl/articles/witaj", + retiredAt: new Date(), + slug: "witaj", + }, + ], + }); + + expect(await service.resolveSlug("witaj", { locale: "pl" })).toStrictEqual({ + type: "not_found", + }); + }); + + it("resolves a slug strictly, never through the fallback", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello")] }, + bySlug: { hello: { itemId: 7, locale: "en" } }, + definition: localizedType, + }); + + // `/pl/articles/hello` is not the English article, even though the content type + // falls back to English for a *read*. + expect(await service.resolveSlug("hello", { locale: "pl" })).toStrictEqual({ + type: "not_found", + }); + expect(await service.resolveSlug("hello", { locale: "en" })).toMatchObject({ + canonicalPath: "/en/articles/hello", + type: "content", + }); + }); +}); + +describe("findById", () => { + it("reports the served locale, not the requested one, on a fallback", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello")] }, + definition: localizedType, + }); + + const metadata = await service.findById(7, { locale: "pl" }); + + // `/pl/articles/hello` would be a self-declared canonical that answers 404. + expect(metadata).toMatchObject({ + canonicalPath: "/en/articles/hello", + isFallback: true, + locale: "en", + requestedLocale: "pl", + }); + }); + + it("is not a fallback when the locale differs only in casing", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello")] }, + definition: localizedType, + }); + + expect(await service.findById(7, { locale: "EN" })).toMatchObject({ + isFallback: false, + locale: "en", + }); + }); + + it("projects the SEO fallback field when the primary is empty", async () => { + const service = buildService({ + byId: { + 7: [ + { + locale: "en", + values: { + id: 7, + seo: { title: null }, + slug: "hello", + title: "The heading", + }, + }, + ], + }, + definition: localizedType, + }); + + expect((await service.findById(7, { locale: "en" }))?.seo).toStrictEqual({ + description: null, + title: "The heading", + }); + }); + + it("is null for a record with no public version", async () => { + const service = buildService({ definition: articleType }); + + expect(await service.findById(99)).toBeNull(); + }); + + it("adds an absolute URL only when an origin is supplied", async () => { + const service = buildService({ + byId: { 42: [article("hello")] }, + definition: articleType, + }); + + expect(await service.findById(42)).not.toHaveProperty("canonicalUrl"); + expect( + await service.findById(42, { origin: "https://example.com" }), + ).toMatchObject({ canonicalUrl: "https://example.com/articles/hello" }); + }); + + it("carries no alternates for a nonlocalized content type", async () => { + const service = buildService({ + byId: { 42: [article("hello")] }, + definition: articleType, + }); + + expect(await service.findById(42)).toMatchObject({ + alternates: [], + hreflang: { languages: {} }, + }); + }); +}); + +describe("resolvePath", () => { + it("refuses a path that belongs to another content type", async () => { + const service = buildService({ definition: articleType }); + + expect(await service.resolvePath("/news/hello")).toStrictEqual({ + type: "not_found", + }); + }); + + it("resolves a canonical path through the public read", async () => { + const service = buildService({ + byId: { 42: [article("hello")] }, + bySlug: { hello: { itemId: 42 } }, + definition: articleType, + }); + + expect(await service.resolvePath("/articles/hello")).toMatchObject({ + canonicalPath: "/articles/hello", + type: "content", + }); + }); + + it("splits the locale out of a localized path", async () => { + const service = buildService({ + byId: { 7: [translation("pl", "witaj")] }, + bySlug: { witaj: { itemId: 7, locale: "pl" } }, + definition: localizedType, + }); + + expect(await service.resolvePath("/pl/articles/witaj")).toMatchObject({ + canonicalPath: "/pl/articles/witaj", + locale: "pl", + type: "content", + }); + }); + + it("redirects a retired localized path", async () => { + const service = buildService({ + byId: { 7: [translation("pl", "nowy-slug")] }, + bySlug: {}, + definition: localizedType, + history: [ + { + itemId: 7, + languageId: 2, + path: "/pl/articles/stary-slug", + retiredAt: new Date(), + slug: "stary-slug", + }, + ], + }); + + expect(await service.resolvePath("/pl/articles/stary-slug")).toStrictEqual({ + location: "/pl/articles/nowy-slug", + status: 308, + type: "redirect", + }); + }); +}); + +describe("sitemap", () => { + it("is an empty page for a content type that lists nothing", async () => { + const noSitemap = defineContentType({ + admin: { label: { plural: "A", singular: "A" } }, + id: "delivery.no-sitemap", + delivery: { enabled: true }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "a" }, + tableName: "delivery_no_sitemap", + }); + + // An empty page rather than a throw: a site-level index enumerates every + // delivery-enabled content type, and one of them opting out is a choice. + expect( + await buildService({ definition: noSitemap }).sitemap(), + ).toStrictEqual({ entries: [], nextCursor: null }); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts new file mode 100644 index 000000000..b0c042f58 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; +import type { + ContentSlugHistoryModel, + ContentSlugHistoryTarget, +} from "./slug-history-model"; + +import { defineContentType } from "../define"; +import { ContentDeliverySlugReserved } from "../errors"; +import { field } from "../fields"; +import { applyContentDeliveryWrite } from "./delivery-writes"; + +/** + * When slug history is written, and when it deliberately is not. + * + * The rule this file exists to pin down is the one in §10 of the Stage 8 brief: a + * slug becomes redirectable only if it was **previously used by an addressable + * public version**. That is what separates "a live URL moved and needs a redirect" + * from "somebody fixed a typo in a draft three times before publishing" - and + * getting it wrong means either a pile of redirects nobody asked for, or a moved + * page that 404s. + */ + +const articleType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "writes.article", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "writes_articles", +}); + +const localizedType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "writes.localized", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "writes_localized", +}); + +interface Call { + args: ContentSlugHistoryTarget | Omit<ContentSlugHistoryTarget, "locale">; + kind: "assertAvailable" | "reserve" | "retire"; +} + +/** + * A history model that records what it was asked to do. + * + * `retired` is the interesting knob: it is the answer to "was that URL ever live", + * and the whole redirect decision hangs off it. + */ +const recorder = ({ + reserved = null, + retired = true, +}: { reserved?: null | string; retired?: boolean } = {}) => { + const calls: Call[] = []; + + const model: ContentSlugHistoryModel = { + assertAvailable: async (_tx, args) => { + calls.push({ args, kind: "assertAvailable" }); + if (reserved !== null && args.slug === reserved) { + throw new ContentDeliverySlugReserved({ + contentTypeId: "writes.article", + locale: args.locale, + slug: args.slug, + }); + } + + return await Promise.resolve(); + }, + list: async () => await Promise.resolve([]), + owner: async () => await Promise.resolve(null), + reserve: async (_tx, args) => { + calls.push({ args, kind: "reserve" }); + if (reserved !== null && args.slug === reserved) { + throw new ContentDeliverySlugReserved({ + contentTypeId: "writes.article", + locale: args.locale, + slug: args.slug, + }); + } + + return await Promise.resolve({ created: true }); + }, + retire: async (_tx, args) => { + calls.push({ args, kind: "retire" }); + + return await Promise.resolve({ retired }); + }, + }; + + return { calls, model }; +}; + +const tx = {} as ContentDatabase; + +const apply = async ( + definition: AnyContentTypeDefinition, + transition: Parameters<typeof applyContentDeliveryWrite>[0]["transition"], + options?: { reserved?: null | string; retired?: boolean }, +) => { + const { calls, model } = recorder(options); + const outcome = await applyContentDeliveryWrite({ + definition, + slugHistory: model, + transition, + tx, + }); + + return { calls, outcome }; +}; + +describe("a draft", () => { + it("checks its slug but reserves nothing", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: null, + slug: "hello", + wasPublic: false, + }); + + // Checked, because "that address belongs to an article that moved" is far + // better heard at save time. Not reserved, because a draft has no public URL + // and claiming one would refuse a live address to somebody who wants it. + expect(calls.map(call => call.kind)).toStrictEqual(["assertAvailable"]); + expect(outcome).toMatchObject({ + canonicalPath: "/articles/hello", + redirectCreated: false, + sitemapChanged: false, + slugChanged: false, + }); + }); + + it("creates no redirect when its slug is corrected before publication", async () => { + const { calls, outcome } = await apply( + articleType, + { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "typo", + slug: "fixed", + wasPublic: false, + }, + // Nothing to retire: the old slug was never publicly addressable, so no row + // exists for it. + { retired: false }, + ); + + expect(calls.map(call => call.kind)).toStrictEqual([ + "retire", + "assertAvailable", + ]); + expect(outcome).toMatchObject({ + previousPath: "/articles/typo", + redirectCreated: false, + slugChanged: true, + }); + }); +}); + +describe("publishing", () => { + it("reserves the current address", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: false, + }); + + expect(calls).toStrictEqual([ + { + args: { + itemId: 1, + languageId: null, + locale: null, + path: "/articles/hello", + slug: "hello", + }, + kind: "reserve", + }, + ]); + // A publish adds a sitemap line even though no URL moved. + expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + }); + + it("refuses an address another record's history owns", async () => { + await expect( + apply( + articleType, + { + isPublic: true, + itemId: 2, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: false, + }, + { reserved: "hello" }, + ), + ).rejects.toThrow(ContentDeliverySlugReserved); + }); +}); + +describe("moving a published URL", () => { + it("retires the old address and reserves the new one, in that order", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "old", + slug: "new", + wasPublic: true, + }); + + // Retire first: a move from `a` to `b` and back to `a` would otherwise hit its + // own live reservation. + expect(calls.map(call => call.kind)).toStrictEqual(["retire", "reserve"]); + expect(outcome).toMatchObject({ + canonicalPath: "/articles/new", + previousPath: "/articles/old", + previousSlug: "old", + redirectCreated: true, + sitemapChanged: true, + slugChanged: true, + }); + }); + + it("reports no redirect when the old slug had never been live", async () => { + const { outcome } = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "old", + slug: "new", + wasPublic: true, + }, + { retired: false }, + ); + + expect(outcome).toMatchObject({ + redirectCreated: false, + slugChanged: true, + }); + }); +}); + +describe("unpublishing and deleting", () => { + it("writes nothing on an unpublish, and keeps the history", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: true, + }); + + // No retire (the slug did not move) and no reserve (it is not public). The + // resolver stops redirecting because it reads the live publication state. + expect(calls).toStrictEqual([]); + expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + }); + + it("writes nothing on a delete, and reports the lost sitemap line", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: null, + wasPublic: true, + }); + + expect(calls).toStrictEqual([]); + expect(outcome).toMatchObject({ + canonicalPath: null, + sitemapChanged: true, + slug: null, + slugChanged: false, + }); + }); +}); + +describe("a localized slug", () => { + it("carries the language on every write, so histories stay isolated", async () => { + const { calls } = await apply(localizedType, { + isPublic: true, + itemId: 7, + languageId: 2, + locale: "pl", + previousSlug: "stary", + slug: "nowy", + wasPublic: true, + }); + + expect(calls).toStrictEqual([ + { args: { itemId: 7, languageId: 2, slug: "stary" }, kind: "retire" }, + { + args: { + itemId: 7, + languageId: 2, + locale: "pl", + path: "/pl/articles/nowy", + slug: "nowy", + }, + kind: "reserve", + }, + ]); + }); + + it("builds locale-prefixed paths on both sides of the move", async () => { + const { outcome } = await apply(localizedType, { + isPublic: true, + itemId: 7, + languageId: 2, + locale: "pl", + previousSlug: "stary", + slug: "nowy", + wasPublic: true, + }); + + expect(outcome).toMatchObject({ + canonicalPath: "/pl/articles/nowy", + locale: "pl", + previousPath: "/pl/articles/stary", + }); + }); +}); + +describe("delivery without redirects", () => { + it("reports the paths and writes no history at all", async () => { + const withoutRedirects = defineContentType({ + admin: { label: { plural: "A", singular: "A" } }, + id: "writes.no-redirects", + delivery: { enabled: true, sitemap: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "a" }, + tableName: "writes_no_redirects", + }); + + const outcome = await applyContentDeliveryWrite({ + definition: withoutRedirects, + // `null` is how the caller says "this content type keeps no history". + slugHistory: null, + transition: { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "old", + slug: "new", + wasPublic: true, + }, + tx, + }); + + expect(outcome).toMatchObject({ + canonicalPath: "/a/new", + previousPath: "/a/old", + // The URL moved and the sitemap changed - the engine simply cannot redirect + // the old address, because nothing recorded it. + redirectCreated: false, + sitemapChanged: true, + slugChanged: true, + }); + }); +}); diff --git a/packages/vitnode/src/content/server/localized-preview-routes.test.ts b/packages/vitnode/src/content/server/localized-preview-routes.test.ts index f8147a0c7..958d88d3e 100644 --- a/packages/vitnode/src/content/server/localized-preview-routes.test.ts +++ b/packages/vitnode/src/content/server/localized-preview-routes.test.ts @@ -66,6 +66,12 @@ const harness = ({ secret = SECRET }: { secret?: string } = {}) => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn().mockResolvedValue(translationRow()), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts index b665761f9..f10299ddb 100644 --- a/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts +++ b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts @@ -102,6 +102,12 @@ const translations = () => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-editorial-service.test.ts b/packages/vitnode/src/content/server/translation-editorial-service.test.ts index 1c3da36e4..ffea2d070 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.test.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.test.ts @@ -119,6 +119,12 @@ const translations = () => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-publication-routes.test.ts b/packages/vitnode/src/content/server/translation-publication-routes.test.ts index b52c4b2a0..410d9127a 100644 --- a/packages/vitnode/src/content/server/translation-publication-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-publication-routes.test.ts @@ -73,6 +73,12 @@ const harness = () => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-routes.test.ts b/packages/vitnode/src/content/server/translation-routes.test.ts index 547781b78..efc0a803d 100644 --- a/packages/vitnode/src/content/server/translation-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-routes.test.ts @@ -86,6 +86,12 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/sitemap.test.ts b/packages/vitnode/src/content/sitemap.test.ts new file mode 100644 index 000000000..21d9c9a08 --- /dev/null +++ b/packages/vitnode/src/content/sitemap.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentSitemapEntry } from "./sitemap"; + +import { + contentSitemapChunks, + contentSitemapIndexXml, + contentSitemapXml, + escapeXml, +} from "./sitemap"; + +/** + * Sitemap serialization, without a database. + * + * A sitemap is a document other people's parsers read, so the assertions here are + * mostly about bytes: valid XML, correct escaping, the elements the protocol + * defines and deterministic output. A malformed `<loc>` is not a cosmetic problem - + * a crawler may reject the whole file. + */ + +const entry = ( + overrides: Partial<ContentSitemapEntry> = {}, +): ContentSitemapEntry => ({ + changeFrequency: "weekly", + itemId: 1, + lastModified: new Date("2026-01-02T03:04:05.000Z"), + locale: null, + path: "/articles/my-article", + priority: 0.7, + ...overrides, +}); + +describe("escapeXml", () => { + it("escapes the five predefined entities", () => { + expect(escapeXml(`&<>"'`)).toBe("&<>"'"); + }); + + it("escapes the ampersand first, so nothing is double-escaped", () => { + // `&` after `<` would turn the `<` this produced into `&lt;`. + expect(escapeXml("<a & b>")).toBe("<a & b>"); + }); +}); + +describe("contentSitemapXml", () => { + it("emits a valid urlset with every configured element", () => { + const xml = contentSitemapXml({ + entries: [entry()], + origin: "https://example.com", + }); + + expect(xml).toBe( + [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + " <url>", + " <loc>https://example.com/articles/my-article</loc>", + " <lastmod>2026-01-02T03:04:05.000Z</lastmod>", + " <changefreq>weekly</changefreq>", + " <priority>0.7</priority>", + " </url>", + "</urlset>", + "", + ].join("\n"), + ); + }); + + it("omits changefreq and priority when the content type set none", () => { + const xml = contentSitemapXml({ + entries: [entry({ changeFrequency: null, priority: null })], + origin: "https://example.com", + }); + + expect(xml).not.toContain("changefreq"); + expect(xml).not.toContain("priority"); + expect(xml).toContain("<loc>https://example.com/articles/my-article</loc>"); + }); + + it("drops an entry whose path will not resolve rather than emitting a bad loc", () => { + const xml = contentSitemapXml({ + entries: [entry(), entry({ itemId: 2, path: "http://" })], + origin: "https://example.com", + }); + + expect(xml.match(/<url>/g)).toHaveLength(1); + }); + + it("declares the xhtml namespace only when alternates are supplied", () => { + const without = contentSitemapXml({ + entries: [entry()], + origin: "https://example.com", + }); + expect(without).not.toContain("xmlns:xhtml"); + + const withAlternates = contentSitemapXml({ + alternates: new Map([ + [ + 1, + [ + { locale: "en", path: "/en/articles/my-article" }, + { locale: "pl", path: "/pl/articles/moj-artykul" }, + ], + ], + ]), + entries: [entry({ locale: "en", path: "/en/articles/my-article" })], + origin: "https://example.com", + }); + + expect(withAlternates).toContain( + 'xmlns:xhtml="http://www.w3.org/1999/xhtml"', + ); + // Every alternate of a group is repeated inside each `<url>` - the rule + // implementations get wrong. + expect(withAlternates).toContain( + '<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/articles/my-article" />', + ); + expect(withAlternates).toContain( + '<xhtml:link rel="alternate" hreflang="pl" href="https://example.com/pl/articles/moj-artykul" />', + ); + }); + + it("is deterministic, so two processes produce identical bytes", () => { + const entries = [entry(), entry({ itemId: 2, path: "/articles/second" })]; + const first = contentSitemapXml({ entries, origin: "https://example.com" }); + const second = contentSitemapXml({ + entries, + origin: "https://example.com", + }); + + expect(first).toBe(second); + }); + + it("emits an empty but valid document for no entries", () => { + expect( + contentSitemapXml({ entries: [], origin: "https://example.com" }), + ).toBe( + [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + "</urlset>", + "", + ].join("\n"), + ); + }); +}); + +describe("contentSitemapIndexXml", () => { + it("emits a sitemapindex, not a urlset", () => { + const xml = contentSitemapIndexXml({ + entries: [ + { + lastModified: new Date("2026-01-02T03:04:05.000Z"), + path: "/sitemaps/blog.article-1.xml", + }, + { path: "/sitemaps/blog.article-2.xml" }, + ], + origin: "https://example.com", + }); + + expect(xml).toContain("<sitemapindex"); + expect(xml).not.toContain("<urlset"); + expect(xml).toContain( + "<loc>https://example.com/sitemaps/blog.article-1.xml</loc>", + ); + expect(xml).toContain("<lastmod>2026-01-02T03:04:05.000Z</lastmod>"); + // The second entry has no timestamp, so it carries no `lastmod` element. + expect(xml.match(/<lastmod>/g)).toHaveLength(1); + }); +}); + +describe("contentSitemapChunks", () => { + it("is one page for an empty content type, not zero", () => { + // An index that lists a file which does not exist is a broken index, and a + // content type with nothing published today will have something tomorrow. + expect(contentSitemapChunks({ total: 0 })).toStrictEqual({ + pages: 1, + size: 1_000, + }); + }); + + it("divides by the page size and rounds up", () => { + expect(contentSitemapChunks({ size: 100, total: 250 })).toStrictEqual({ + pages: 3, + size: 100, + }); + }); + + it("clamps the page size to the protocol ceiling", () => { + expect( + contentSitemapChunks({ size: 1_000_000, total: 60_000 }), + ).toStrictEqual({ pages: 2, size: 50_000 }); + }); + + it("never accepts a page size below one", () => { + expect(contentSitemapChunks({ size: 0, total: 3 })).toStrictEqual({ + pages: 3, + size: 1, + }); + }); +}); diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 6b6b2c893..cc4082127 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -483,3 +483,100 @@ export const testAdvancedLocalizedContentType = defineContentType({ list: { columns: ["featured", "status"] }, }, }); + +/** + * The Stage 8 shape: `testPostContentType` plus the whole delivery layer. + * + * A separate fixture rather than a flag on the post, for the same reason the + * searchable and editorial ones are separate: leaving the post exactly as it was is + * what proves a content type without `delivery` produces the same tables, the same + * routes, the same cache tags and the same events it always did. + */ +export const testDeliveredPostContentType = defineContentType({ + id: "test.delivered-post", + tableName: "test_delivered_posts", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + hidden: field.boolean({ defaultValue: false }), + }, + publication: { enabled: true }, + editorial: { enabled: true }, + publicApi: { + enabled: true, + path: "delivered-posts", + fields: ["id", "title", "slug", "excerpt", "hidden", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { + titleField: "title", + descriptionField: "excerpt", + noIndexField: "hidden", + openGraph: { titleField: "title", descriptionField: "excerpt" }, + }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + }, + admin: { label: { plural: "Test Delivered", singular: "Test Delivered" } }, +}); + +/** + * A localized delivery content type: locale-prefixed URLs and per-locale history. + * + * Its slug is `localized: true`, which is what `delivery.redirects` requires on a + * localized content type - a shared slug would give every language the same segment, + * so one retired address would belong to several URLs at once. + */ +export const testDeliveredLocalizedContentType = defineContentType({ + id: "test.delivered-localized", + tableName: "test_delivered_localized", + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + publication: { enabled: true }, + editorial: { enabled: true }, + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + seo: field.group({ + localized: true, + nullable: true, + fields: { + title: field.text({ nullable: true, maxLength: 200 }), + description: field.textarea({ nullable: true, maxLength: 500 }), + }, + }), + }, + publicApi: { + enabled: true, + path: "delivered-localized", + fields: [ + "id", + "title", + "slug", + "seo.title", + "seo.description", + "publishedAt", + ], + defaultOrderBy: "publishedAt", + }, + delivery: { + enabled: true, + redirects: { enabled: true }, + hreflang: { xDefault: "defaultLocale" }, + seo: { + titleField: "seo.title", + fallbackTitleField: "title", + descriptionField: "seo.description", + }, + sitemap: { enabled: true, changeFrequency: "daily", priority: 0.5 }, + }, + admin: { + label: { + plural: "Test Delivered Localized", + singular: "Test Delivered Localized", + }, + list: { columns: ["status", "updatedAt"] }, + }, +}); From 38f672ed55baf0f04e4eb4ff41dc98f52a251b25 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:10:58 +0200 Subject: [PATCH 095/123] test(content): add the Stage 8 PostgreSQL suite 49 tests against real Postgres, covering what only a database can show: the two partial unique indexes really do reserve a retired address, a rolled-back write leaves the history exactly as it found it, and two writers racing on one slug produce one winner and one structured version conflict. The full redirect lifecycle is walked end to end - draft (nothing recorded), publish (reserved), A -> B -> C (both old addresses resolve to C in one hop), unpublish (all inactive, history retained), republish (active again), delete (retained, resolves to nothing), restore (the two addresses swap roles). Localized coverage asserts the isolation that matters: an English slug change writes nothing Polish, the same historical address may be retired in two locales, alternates list only real published translations, and a fallback read reports the locale it actually served. The sitemap tests page a fixture in twos and assert every record appears exactly once in ascending key order - and one of them caught a real bug: `greatest(base, translation)` read without the column's decoder parsed a naive timestamp as local time, putting every localized `lastmod` hours out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/database/delivery-postgres.test.ts | 1479 +++++++++++++++++ 1 file changed, 1479 insertions(+) create mode 100644 plugins/example/src/database/delivery-postgres.test.ts diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts new file mode 100644 index 000000000..4733c48ff --- /dev/null +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -0,0 +1,1479 @@ +import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { Context } from "hono"; + +import { + ContentDeliverySlugReserved, + ContentVersionConflict, +} from "@vitnode/core/content"; +import { + contentDeliveryEffects, + contentEditorialEffects, + contentTranslationEffects, +} from "@vitnode/core/content/server"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import postgres from "postgres"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { CONFIG_PLUGIN, EXAMPLE_MIGRATIONS } from "@/const"; + +import { advancedArticleContent } from "./advanced-articles"; +import { articleContent } from "./articles"; +import { categoryContent } from "./categories"; + +/** + * Stage 8 against real Postgres. + * + * Everything here is about what the *database* enforces and what the resolver + * actually answers, neither of which a mock can show: + * + * - a historical URL is **reserved** by two partial unique indexes, so an unrelated + * record cannot inherit somebody's incoming links; + * - a redirect chain collapses because the resolver reads the record's current slug + * rather than the next entry in the chain; + * - a slug change and its reservation are **one transaction**, so a writer that + * loses the version race leaves the history exactly as it found it; + * - each locale's history is its own, because `languageId` is part of the key. + * + * Runs only with `DATABASE_TEST_URL` set, and **wipes** the database it points at - + * so the URL has to name one with "test" in it: + * + * ```bash + * DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \ + * pnpm --filter @vitnode/example test + * ``` + */ +const url = process.env.DATABASE_TEST_URL; + +const databaseName = (() => { + if (!url) return ""; + try { + return new URL(url).pathname.replace(/^\//, ""); + } catch { + return ""; + } +})(); + +const here = dirname(fileURLToPath(import.meta.url)); + +const migrationSql = (files: readonly string[]): string => + files + .map(file => + readFileSync( + resolve(here, "../../../../apps/docs/migrations", file), + "utf8", + ), + ) + .join("\n--> statement-breakpoint\n"); + +const CORE_STUBS = ` + CREATE TABLE "core_users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL + ); + 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 + ); + CREATE TABLE "core_languages" ( + "id" serial PRIMARY KEY NOT NULL, + "code" varchar(32) NOT NULL, + "name" varchar(255) NOT NULL, + "default" boolean DEFAULT false NOT NULL, + "protected" boolean DEFAULT false NOT NULL, + CONSTRAINT "core_languages_code_unique" UNIQUE("code") + ); +`; + +const ACTOR = { type: "staff" as const, userId: null }; +const PLUGIN = CONFIG_PLUGIN.pluginId; + +let sql: ReturnType<typeof postgres>; +let db: ReturnType<typeof drizzle>; +let context: Context; +/** A second connection, for the tests that need two writers at once. */ +let rival: ReturnType<typeof postgres>; +let rivalContext: Context; +let categoryId = 0; + +const emitted: { name: string; payload: Record<string, unknown> }[] = []; +const indexed: SearchDocument[] = []; + +const pgErrorCode = async (run: () => Promise<unknown>) => { + try { + await run(); + } catch (error) { + const cause = (error as { cause?: { code?: string } }).cause; + + return cause?.code ?? (error as { code?: string }).code; + } + + return undefined; +}; + +// --------------------------------------------------------------------------- +// Nonlocalized fixture: `example.article` +// --------------------------------------------------------------------------- + +const editorial = (target: Context = context) => + articleContent.editorialService?.(target, { pluginId: PLUGIN }); + +const delivery = (target: Context = context) => + articleContent.deliveryService?.(target, { pluginId: PLUGIN }); + +const createArticle = async ( + values: Record<string, unknown> = {}, +): Promise<{ id: number; version: number }> => { + const outcome = await editorial()?.create( + { + category: categoryId, + code: `code-${Math.round(Date.now() % 1_000_000)}-${values.title ?? "x"}`, + title: "Hello world", + ...values, + } as never, + { actor: ACTOR }, + ); + if (!outcome) throw new Error("create returned nothing"); + + return { id: outcome.row.id, version: outcome.version }; +}; + +/** Creates, publishes, and hands back the version to write against next. */ +const publishArticle = async ( + values: Record<string, unknown> = {}, +): Promise<{ id: number; version: number }> => { + const created = await createArticle(values); + const published = await editorial()?.publish(created.id, { actor: ACTOR }); + if (!published) throw new Error("publish returned nothing"); + + return { id: created.id, version: published.version }; +}; + +/** + * One record's addresses, as plain objects. + * + * `postgres.js` hands back a `Result` array subclass whose prototype is not + * `Array.prototype`, which `toStrictEqual` compares - so every raw read in this file + * is normalised rather than asserted directly. + */ +const historyRows = async ( + itemId: number, +): Promise<{ path: string; retired: boolean; slug: string }[]> => { + const rows = await sql<{ path: string; retiredAt: null | string; slug: string }[]>` + SELECT "slug", "path", "retiredAt" + FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.article' AND "itemId" = ${itemId} + ORDER BY "id" + `; + + return rows.map(row => ({ + path: row.path, + retired: row.retiredAt !== null, + slug: row.slug, + })); +}; + +// --------------------------------------------------------------------------- +// Localized fixture: `example.advanced-article` +// --------------------------------------------------------------------------- + +const localizedService = () => + advancedArticleContent.localizedService?.(context, { pluginId: PLUGIN }); + +const translationEditorial = (target: Context = context) => + advancedArticleContent.translationEditorialService?.(target, { + pluginId: PLUGIN, + }); + +const advancedEditorial = () => + advancedArticleContent.editorialService?.(context, { pluginId: PLUGIN }); + +const advancedDelivery = () => + advancedArticleContent.deliveryService?.(context, { pluginId: PLUGIN }); + +/** + * A localized article, published in `en` and optionally in `pl`. + * + * Both halves are published on purpose: a translation is only publicly reachable + * when the record is too, which is the subordination the delivery layer reads. + */ +const publishLocalized = async ({ + pl, + title = "Hello world", +}: { pl?: string; title?: string } = {}) => { + const localized = localizedService(); + if (!localized) throw new Error("no localized service"); + + const created = await localized.create({ + shared: {}, + translation: { title }, + }); + + const base = await advancedEditorial()?.publish(created.row.id, { + actor: ACTOR, + }); + if (!base) throw new Error("base publish returned nothing"); + + const en = await translationEditorial()?.publish(created.row.id, "en", { + actor: ACTOR, + }); + if (!en) throw new Error("en publish returned nothing"); + + if (pl !== undefined) { + await translationEditorial()?.create( + created.row.id, + "pl", + { title: pl } as never, + { actor: ACTOR }, + ); + await translationEditorial()?.publish(created.row.id, "pl", { + actor: ACTOR, + }); + } + + return { enVersion: en.version, id: created.row.id }; +}; + +const localizedHistory = async ( + itemId: number, +): Promise< + { languageId: null | number; path: string; retired: boolean; slug: string }[] +> => { + const rows = await sql< + { + languageId: null | number; + path: string; + retiredAt: null | string; + slug: string; + }[] + >` + SELECT "slug", "path", "retiredAt", "languageId" + FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.advanced-article' AND "itemId" = ${itemId} + ORDER BY "id" + `; + + return rows.map(row => ({ + languageId: row.languageId, + path: row.path, + retired: row.retiredAt !== null, + slug: row.slug, + })); +}; + +const localeIds: Record<string, number> = {}; + +describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { + beforeAll(async () => { + if (!/test/i.test(databaseName)) { + throw new Error( + `DATABASE_TEST_URL points at "${databaseName || url}". This suite wipes the database it runs against, so its name must contain "test".`, + ); + } + + sql = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + + await sql.unsafe(` + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + `); + await sql.unsafe(CORE_STUBS); + const languages = await sql<{ code: string; id: number }[]>` + INSERT INTO "core_languages" ("code", "name", "default") VALUES + ('en', 'English', true), + ('pl', 'Polski', false) + RETURNING "id", "code" + `; + for (const language of languages) localeIds[language.code] = language.id; + + for (const statement of migrationSql(EXAMPLE_MIGRATIONS).split( + "--> statement-breakpoint", + )) { + const trimmed = statement.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + + db = drizzle(sql, { casing: "camelCase" }); + rival = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + + const buildContext = (handle: ReturnType<typeof drizzle>) => + ({ + get: (key: string) => { + if (key === "db") return handle; + if (key === "search") { + return { + delete: async () => await Promise.resolve(), + index: async (document: SearchDocument) => { + indexed.push(document); + + return await Promise.resolve(); + }, + }; + } + if (key === "events") { + return { + emit: async ( + name: string, + payload: Record<string, unknown>, + ) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ failures: [] }); + }, + }; + } + if (key === "log") return { error: async () => await Promise.resolve() }; + if (key === "core") { + return { + contentModels: [ + { model: advancedArticleContent, pluginId: PLUGIN }, + { model: articleContent, pluginId: PLUGIN }, + { model: categoryContent, pluginId: PLUGIN }, + ], + i18n: { + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + }, + }; + } + + return undefined; + }, + }) as unknown as Context; + + context = buildContext(db); + rivalContext = buildContext(drizzle(rival, { casing: "camelCase" })); + }, 60_000); + + afterAll(async () => { + await sql?.end(); + await rival?.end(); + }); + + beforeEach(async () => { + await sql`DELETE FROM "example_articles"`; + await sql`DELETE FROM "example_advanced_articles"`; + await sql`DELETE FROM "core_content_slug_history"`; + await sql`DELETE FROM "core_content_revisions"`; + await sql`DELETE FROM "example_categories"`; + + const [category] = await sql<{ id: number }[]>` + INSERT INTO "example_categories" ("name") VALUES ('News') RETURNING "id" + `; + categoryId = category.id; + emitted.length = 0; + indexed.length = 0; + }); + + // ------------------------------------------------------------------------- + // The table itself + // ------------------------------------------------------------------------- + + describe("the reservation constraints", () => { + it("refuses two shared rows for the same address", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "slug", "path") + VALUES (${PLUGIN}, 'example.article', 1, 'hello', '/articles/hello') + `; + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "slug", "path") + VALUES (${PLUGIN}, 'example.article', 2, 'hello', '/articles/hello') + `, + ); + + // The partial unique index over `(contentTypeId, slug) WHERE languageId IS + // NULL` is what makes a retired URL a reservation rather than only a log. + expect(code).toBe("23505"); + }); + + it("allows the same address in two different locales", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path") + VALUES + (${PLUGIN}, 'example.advanced-article', 1, ${localeIds.en}, 'shared', '/en/advanced-articles/shared'), + (${PLUGIN}, 'example.advanced-article', 2, ${localeIds.pl}, 'shared', '/pl/advanced-articles/shared') + `; + + const [row] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + `; + + // Locale-scoped uniqueness: `/en/x/shared` and `/pl/x/shared` are two URLs. + expect(row.count).toBe(2); + }); + + it("refuses two rows for the same address in one locale", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path") + VALUES (${PLUGIN}, 'example.advanced-article', 1, ${localeIds.en}, 'hello', '/en/advanced-articles/hello') + `; + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path") + VALUES (${PLUGIN}, 'example.advanced-article', 2, ${localeIds.en}, 'hello', '/en/advanced-articles/hello') + `, + ); + + expect(code).toBe("23505"); + }); + + it("keeps two content types' histories apart", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "slug", "path") + VALUES + (${PLUGIN}, 'example.article', 1, 'hello', '/articles/hello'), + (${PLUGIN}, 'other.thing', 1, 'hello', '/things/hello') + `; + + const [row] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + `; + + expect(row.count).toBe(2); + }); + + it("indexes the resolver's lookup", async () => { + // The redirect lookup is on a public request path for a URL that is very + // often a typo, so it has to be an index hit rather than a scan. + const rows = await sql<{ indexdef: string; indexname: string }[]>` + SELECT indexname, indexdef FROM pg_indexes + WHERE tablename = 'core_content_slug_history' + `; + const names = rows.map(row => row.indexname); + + expect(names).toContain("core_content_slug_history_shared_unique"); + expect(names).toContain("core_content_slug_history_locale_unique"); + expect(names).toContain("core_content_slug_history_item_idx"); + + const shared = rows.find( + row => row.indexname === "core_content_slug_history_shared_unique", + ); + expect(shared?.indexdef).toContain("UNIQUE"); + expect(shared?.indexdef).toMatch(/"?languageId"? IS NULL/); + }); + }); + + // ------------------------------------------------------------------------- + // The redirect lifecycle + // ------------------------------------------------------------------------- + + describe("the redirect lifecycle", () => { + it("records nothing while the record is still a draft", async () => { + const article = await createArticle({ title: "Draft article" }); + + await editorial()?.update( + article.id, + { slug: "corrected" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + // A draft has no public URL, so neither the original nor the corrected slug + // was ever addressable - and neither is reserved. + expect(await historyRows(article.id)).toStrictEqual([]); + }); + + it("reserves the current address on publication", async () => { + const article = await publishArticle({ title: "Hello world" }); + + const rows = await historyRows(article.id); + + expect(rows).toStrictEqual([ + { + path: "/articles/hello-world", + retired: false, + slug: "hello-world", + }, + ]); + }); + + it("resolves the current slug as canonical content", async () => { + const article = await publishArticle({ title: "Hello world" }); + + expect(await delivery()?.resolveSlug("hello-world")).toMatchObject({ + canonicalPath: "/articles/hello-world", + // `example.article` does not expose `id`, so delivery reports none rather + // than publishing a column the public API withheld. + itemId: null, + type: "content", + }); + expect(article.id).toBeGreaterThan(0); + }); + + it("redirects the old address after a slug change", async () => { + const article = await publishArticle({ title: "Hello world" }); + + await editorial()?.update( + article.id, + { slug: "hello-there" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + expect(await delivery()?.resolveSlug("hello-world")).toStrictEqual({ + location: "/articles/hello-there", + status: 308, + type: "redirect", + }); + expect(await delivery()?.resolveSlug("hello-there")).toMatchObject({ + canonicalPath: "/articles/hello-there", + type: "content", + }); + }); + + it("collapses a chain: A and B both resolve straight to C", async () => { + const article = await publishArticle({ title: "Slug a" }); + + const toB = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + await editorial()?.update( + article.id, + { slug: "slug-c" }, + { actor: ACTOR, expectedVersion: toB?.version ?? 0 }, + ); + + // One hop each, never A -> B -> C. + for (const retired of ["slug-a", "slug-b"]) { + expect(await delivery()?.resolveSlug(retired)).toStrictEqual({ + location: "/articles/slug-c", + status: 308, + type: "redirect", + }); + } + expect(await delivery()?.resolveSlug("slug-c")).toMatchObject({ + type: "content", + }); + }); + + it("keeps three rows: two retired and one current", async () => { + const article = await publishArticle({ title: "Slug a" }); + const toB = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + await editorial()?.update( + article.id, + { slug: "slug-c" }, + { actor: ACTOR, expectedVersion: toB?.version ?? 0 }, + ); + + const rows = await historyRows(article.id); + + expect(rows.map(row => row.slug)).toStrictEqual([ + "slug-a", + "slug-b", + "slug-c", + ]); + expect(rows.map(row => row.retired)).toStrictEqual([true, true, false]); + // The database keeps the chronology; the resolver is what collapses it. + expect(rows[0].path).toBe("/articles/slug-a"); + }); + + it("stops redirecting while the record is unpublished, and starts again", async () => { + const article = await publishArticle({ title: "Slug a" }); + const moved = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const unpublished = await editorial()?.unpublish(article.id, { + actor: ACTOR, + }); + + expect(await delivery()?.resolveSlug("slug-a")).toStrictEqual({ + type: "not_found", + }); + expect(await delivery()?.resolveSlug("slug-b")).toStrictEqual({ + type: "not_found", + }); + // The history survives - it is what makes the redirect come back. + expect((await historyRows(article.id)).length).toBe(2); + + await editorial()?.publish(article.id, { + actor: ACTOR, + expectedVersion: unpublished?.version, + }); + + expect(await delivery()?.resolveSlug("slug-a")).toStrictEqual({ + location: "/articles/slug-b", + status: 308, + type: "redirect", + }); + expect(moved?.delivery?.redirectCreated).toBe(true); + }); + + it("keeps the history but resolves nothing after a delete", async () => { + const article = await publishArticle({ title: "Slug a" }); + const moved = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + await editorial()?.delete(article.id, { + actor: ACTOR, + expectedVersion: moved?.version ?? 0, + }); + + // Retained for audit, and never a redirect to content that is gone. + expect((await historyRows(article.id)).length).toBe(2); + expect(await delivery()?.resolveSlug("slug-a")).toStrictEqual({ + type: "not_found", + }); + expect(await delivery()?.resolveSlug("slug-b")).toStrictEqual({ + type: "not_found", + }); + }); + + it("brings a slug back into service when it is restored", async () => { + const article = await publishArticle({ title: "Original name" }); + const [original] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + + const moved = await editorial()?.update( + article.id, + { slug: "new-name" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const restored = await editorial()?.restore(article.id, original.id, { + actor: ACTOR, + expectedVersion: moved?.version ?? 0, + }); + + expect(restored?.delivery).toMatchObject({ + canonicalPath: "/articles/original-name", + previousPath: "/articles/new-name", + redirectCreated: true, + slugChanged: true, + }); + + // The two addresses have swapped roles: `new-name` now redirects to the + // restored `original-name`. + expect(await delivery()?.resolveSlug("new-name")).toStrictEqual({ + location: "/articles/original-name", + status: 308, + type: "redirect", + }); + expect(await delivery()?.resolveSlug("original-name")).toMatchObject({ + type: "content", + }); + }); + + it("writes no history for a restore that moves no slug", async () => { + const article = await publishArticle({ title: "Stable" }); + const [first] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + + const edited = await editorial()?.update( + article.id, + { excerpt: "Changed prose" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const restored = await editorial()?.restore(article.id, first.id, { + actor: ACTOR, + expectedVersion: edited?.version ?? 0, + }); + + expect(restored?.delivery?.slugChanged).toBe(false); + expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ + "stable", + ]); + }); + }); + + // ------------------------------------------------------------------------- + // Reservations + // ------------------------------------------------------------------------- + + describe("slug reservations", () => { + it("refuses an address another record retired", async () => { + const first = await publishArticle({ title: "Hello" }); + await editorial()?.update( + first.id, + { slug: "hello-world" }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + // `hello` is free on the content table now - the first article moved off it - + // so the reservation is the only thing standing between the second article + // and somebody else's incoming links. + await expect(createArticle({ slug: "hello", title: "Second" })).rejects.toThrow( + ContentDeliverySlugReserved, + ); + }); + + it("names the address in the structured error", async () => { + const first = await publishArticle({ title: "Hello" }); + await editorial()?.update( + first.id, + { slug: "hello-world" }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + const error = await createArticle({ + slug: "hello", + title: "Second", + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(ContentDeliverySlugReserved); + expect(error).toMatchObject({ locale: null, slug: "hello" }); + }); + + it("lets a record take its own retired address back", async () => { + const article = await publishArticle({ title: "Slug a" }); + const toB = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const back = await editorial()?.update( + article.id, + { slug: "slug-a" }, + { actor: ACTOR, expectedVersion: toB?.version ?? 0 }, + ); + + expect(back?.delivery?.canonicalPath).toBe("/articles/slug-a"); + expect(await delivery()?.resolveSlug("slug-b")).toStrictEqual({ + location: "/articles/slug-a", + status: 308, + type: "redirect", + }); + // Two rows, and `slug-a` is live again rather than duplicated. + const rows = await historyRows(article.id); + expect(rows).toHaveLength(2); + expect(rows.find(row => row.slug === "slug-a")?.retired).toBe(false); + }); + + it("never reserves a draft's address", async () => { + await createArticle({ slug: "wanted", title: "A draft" }); + + // A draft has no public URL, so another record may still publish at that + // address - the content table's own unique index is what stops a *live* + // duplicate, and it fires on the create below rather than the reservation. + const code = await pgErrorCode( + async () => await createArticle({ slug: "wanted", title: "Another" }), + ); + + expect(code).toBe("23505"); + const [rows] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + `; + expect(rows.count).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // Concurrency + // ------------------------------------------------------------------------- + + describe("concurrency", () => { + it("lets one of two racing slug edits win and refuses the other", async () => { + const article = await publishArticle({ title: "Original" }); + + const results = await Promise.allSettled([ + editorial()?.update( + article.id, + { slug: "winner" }, + { actor: ACTOR, expectedVersion: article.version }, + ), + editorial(rivalContext)?.update( + article.id, + { slug: "loser" }, + { actor: ACTOR, expectedVersion: article.version }, + ), + ]); + + const rejected = results.filter(result => result.status === "rejected"); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ + reason: expect.any(ContentVersionConflict), + }); + + // One winner, so exactly one retirement and one new reservation - the loser's + // transaction rolled back and left the history as it found it. + const rows = await historyRows(article.id); + expect(rows).toHaveLength(2); + expect(rows.filter(row => !row.retired)).toHaveLength(1); + expect(rows.filter(row => row.slug === "loser")).toHaveLength(0); + }); + + it("keeps the history consistent when the write rolls back", async () => { + const article = await publishArticle({ title: "Original" }); + + // A stale expectation: the guarded UPDATE matches nothing, so the reservation + // never runs at all. + await expect( + editorial()?.update( + article.id, + { slug: "never-written" }, + { actor: ACTOR, expectedVersion: article.version + 5 }, + ), + ).rejects.toThrow(ContentVersionConflict); + + expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ + "original", + ]); + }); + + it("serialises two records racing for the same retired address", async () => { + const first = await publishArticle({ title: "Contested" }); + await editorial()?.update( + first.id, + { slug: "moved-on" }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + const second = await createArticle({ slug: "second", title: "Second" }); + const third = await createArticle({ slug: "third", title: "Third" }); + + const results = await Promise.allSettled([ + editorial()?.update( + second.id, + { slug: "contested" }, + { actor: ACTOR, expectedVersion: second.version }, + ), + editorial(rivalContext)?.update( + third.id, + { slug: "contested" }, + { actor: ACTOR, expectedVersion: third.version }, + ), + ]); + + // Both lose: the address belongs to the first article's history, and neither + // of the two may take it. + expect( + results.every(result => result.status === "rejected"), + ).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Sitemap + // ------------------------------------------------------------------------- + + describe("sitemap", () => { + it("lists only published records", async () => { + const published = await publishArticle({ title: "Published one" }); + await createArticle({ title: "Still a draft" }); + + const page = await delivery()?.sitemap(); + + expect(page?.entries.map(entry => entry.itemId)).toStrictEqual([ + published.id, + ]); + expect(page?.entries[0]).toMatchObject({ + changeFrequency: "weekly", + path: "/articles/published-one", + priority: 0.7, + }); + }); + + it("omits a record whose publication date is in the future", async () => { + const article = await publishArticle({ title: "Scheduled" }); + await sql` + UPDATE "example_articles" + SET "publishedAt" = now() + interval '1 day' + WHERE "id" = ${article.id} + `; + + expect((await delivery()?.sitemap())?.entries).toStrictEqual([]); + }); + + it("paginates by keyset, without duplicates or gaps", async () => { + const ids: number[] = []; + for (const title of ["One", "Two", "Three", "Four", "Five"]) { + ids.push((await publishArticle({ title })).id); + } + + const seen: number[] = []; + let cursor: null | number | undefined = undefined; + + for (let page = 0; page < 10; page += 1) { + const result = await delivery()?.sitemap({ + cursor: cursor ?? undefined, + limit: 2, + }); + if (!result) break; + + seen.push(...result.entries.map(entry => entry.itemId)); + cursor = result.nextCursor; + if (cursor === null) break; + } + + // Every record exactly once, in ascending primary-key order. + expect(seen).toStrictEqual([...ids].sort((a, b) => a - b)); + expect(new Set(seen).size).toBe(seen.length); + expect(cursor).toBeNull(); + }); + + it("uses the base row's updatedAt for a nonlocalized entry", async () => { + const article = await publishArticle({ title: "Timestamped" }); + // Read through the same driver as the sitemap, never as `::text`: a + // `timestamp` column is rendered in the session's timezone as text and parsed + // back as an instant, so comparing the two forms compares two clocks. + const row = await articleContent.service(context).findById(article.id); + + const page = await delivery()?.sitemap(); + + expect(page?.entries[0].lastModified.toISOString()).toBe( + row?.updatedAt.toISOString(), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + describe("delivery events", () => { + it("emits both events after a live URL moves", async () => { + const article = await publishArticle({ title: "Slug a" }); + emitted.length = 0; + + const outcome = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentDeliveryEffects( + context, + articleContent.definition, + outcome.delivery, + { pluginId: PLUGIN }, + ); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.example.article.delivery_slug_changed", + "content.example.article.delivery_redirect_created", + ]); + expect(emitted[0].payload).toMatchObject({ + canonicalPath: "/articles/slug-b", + contentId: article.id, + previousPath: "/articles/slug-a", + previousSlug: "slug-a", + slug: "slug-b", + }); + }); + + it("emits them alongside the ordinary update event, never instead of it", async () => { + const article = await publishArticle({ title: "Slug a" }); + emitted.length = 0; + + const outcome = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentEditorialEffects(context, articleContent.definition, outcome, { + model: articleContent, + pluginId: PLUGIN, + }); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.example.article.updated", + "content.example.article.delivery_slug_changed", + "content.example.article.delivery_redirect_created", + ]); + }); + + it("emits nothing for a corrected draft", async () => { + const article = await createArticle({ title: "Draft" }); + emitted.length = 0; + + const outcome = await editorial()?.update( + article.id, + { slug: "corrected" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentDeliveryEffects( + context, + articleContent.definition, + outcome.delivery, + { pluginId: PLUGIN }, + ); + + // The URL moved, but it had never been live - so a listener that warms a CDN + // or writes an edge redirect table hears about a redirect that does not exist. + expect( + emitted.filter(entry => + entry.name.includes("delivery_redirect_created"), + ), + ).toStrictEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // Search integration + // ------------------------------------------------------------------------- + + describe("search integration", () => { + it("indexes the current canonical URL and never a historical one", async () => { + const article = await publishArticle({ title: "Slug a" }); + + const outcome = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + indexed.length = 0; + await contentEditorialEffects(context, articleContent.definition, outcome, { + model: articleContent, + pluginId: PLUGIN, + }); + + // One document, pointing at the new address. A retired URL never becomes a + // second search result competing with the page it redirects to. + expect(indexed).toHaveLength(1); + expect(indexed[0].url).toBe("/articles/slug-b"); + expect(indexed.filter(document => document.url === "/articles/slug-a")).toStrictEqual( + [], + ); + }); + }); + + // ------------------------------------------------------------------------- + // Localization + // ------------------------------------------------------------------------- + + describe("localized delivery", () => { + it("reserves one address per published language", async () => { + const article = await publishLocalized({ pl: "Witaj swiecie" }); + + const rows = await localizedHistory(article.id); + + expect(rows).toHaveLength(2); + expect(rows.map(row => row.path).sort()).toStrictEqual([ + "/en/advanced-articles/hello-world", + "/pl/advanced-articles/witaj-swiecie", + ]); + // Each carries its own language, which is what keeps the two histories apart. + expect(new Set(rows.map(row => row.languageId)).size).toBe(2); + }); + + it("keeps an English slug change out of the Polish history", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const before = await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + expect(before?.delivery).toMatchObject({ + canonicalPath: "/en/advanced-articles/hello-there", + locale: "en", + previousPath: "/en/advanced-articles/hello-world", + redirectCreated: true, + }); + + const rows = await localizedHistory(article.id); + const polish = rows.filter(row => row.languageId === localeIds.pl); + + // Polish gained nothing and retired nothing. + expect(polish).toHaveLength(1); + expect(polish[0]).toMatchObject({ + path: "/pl/advanced-articles/witaj", + retired: false, + }); + }); + + it("redirects only inside the locale that moved", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + expect( + await advancedDelivery()?.resolvePath( + "/en/advanced-articles/hello-world", + ), + ).toStrictEqual({ + location: "/en/advanced-articles/hello-there", + status: 308, + type: "redirect", + }); + // The Polish URL is untouched and still canonical. + expect( + await advancedDelivery()?.resolvePath("/pl/advanced-articles/witaj"), + ).toMatchObject({ + canonicalPath: "/pl/advanced-articles/witaj", + type: "content", + }); + }); + + it("allows the same historical address in two locales", async () => { + const first = await publishLocalized({ title: "Shared" }); + await translationEditorial()?.update( + first.id, + "en", + { slug: "english-now" } as never, + { actor: ACTOR, expectedVersion: first.enVersion }, + ); + + const second = await publishLocalized({ title: "Second" }); + await translationEditorial()?.create( + second.id, + "pl", + { title: "Shared" } as never, + { actor: ACTOR }, + ); + const pl = await translationEditorial()?.publish(second.id, "pl", { + actor: ACTOR, + }); + await translationEditorial()?.update( + second.id, + "pl", + { slug: "polski-teraz" } as never, + { actor: ACTOR, expectedVersion: pl?.version ?? 0 }, + ); + + // `/en/.../shared` and `/pl/.../shared` are two URLs, so both may be retired + // by two different records. + const [rows] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + WHERE "slug" = 'shared' + `; + expect(rows.count).toBe(2); + }); + + it("lists only real published translations as alternates", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + expect(await advancedDelivery()?.alternates(article.id)).toStrictEqual([ + { locale: "en", path: "/en/advanced-articles/hello-world" }, + { locale: "pl", path: "/pl/advanced-articles/witaj" }, + ]); + }); + + it("never fabricates an alternate from a draft translation", async () => { + const article = await publishLocalized(); + // Created but deliberately not published. + await translationEditorial()?.create( + article.id, + "pl", + { title: "Wersja robocza" } as never, + { actor: ACTOR }, + ); + + expect(await advancedDelivery()?.alternates(article.id)).toStrictEqual([ + { locale: "en", path: "/en/advanced-articles/hello-world" }, + ]); + }); + + it("reports the served locale on a fallback read", async () => { + const article = await publishLocalized(); + + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "pl", + }); + + // The Polish translation does not exist and the content type falls back to + // English, so the canonical URL is the English one - `/pl/...` would be a + // self-declared canonical that answers 404. + expect(metadata).toMatchObject({ + canonicalPath: "/en/advanced-articles/hello-world", + isFallback: true, + locale: "en", + requestedLocale: "pl", + }); + }); + + it("emits an x-default only when the default locale is published", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "pl", + }); + + expect(metadata?.hreflang).toStrictEqual({ + languages: { + en: "/en/advanced-articles/hello-world", + pl: "/pl/advanced-articles/witaj", + }, + xDefault: "/en/advanced-articles/hello-world", + }); + }); + + it("stops a locale's redirects when its translation is unpublished", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + const moved = await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + await translationEditorial()?.unpublish(article.id, "en", { + actor: ACTOR, + expectedVersion: moved?.version, + }); + + expect( + await advancedDelivery()?.resolvePath( + "/en/advanced-articles/hello-world", + ), + ).toStrictEqual({ type: "not_found" }); + // Polish is unaffected: one language going dark is not the record going dark. + expect( + await advancedDelivery()?.resolvePath("/pl/advanced-articles/witaj"), + ).toMatchObject({ type: "content" }); + }); + + it("emits the localized delivery event alongside the translation one", async () => { + const article = await publishLocalized(); + emitted.length = 0; + + const outcome = await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentTranslationEffects( + context, + advancedArticleContent.definition, + outcome, + { model: advancedArticleContent, pluginId: PLUGIN }, + ); + + const names = emitted.map(entry => entry.name); + expect(names).toContain( + "content.example.advanced-article.translation_updated", + ); + expect(names).toContain( + "content.example.advanced-article.delivery_slug_changed", + ); + expect( + emitted.find(entry => entry.name.includes("delivery_slug_changed")) + ?.payload, + ).toMatchObject({ locale: "en" }); + }); + }); + + // ------------------------------------------------------------------------- + // Localized sitemap and SEO + // ------------------------------------------------------------------------- + + describe("localized sitemap", () => { + it("lists one URL per published translation, per locale", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const en = await advancedDelivery()?.sitemap({ locale: "en" }); + const pl = await advancedDelivery()?.sitemap({ locale: "pl" }); + + expect(en?.entries.map(entry => entry.path)).toStrictEqual([ + "/en/advanced-articles/hello-world", + ]); + expect(pl?.entries.map(entry => entry.path)).toStrictEqual([ + "/pl/advanced-articles/witaj", + ]); + // `example.advanced-article` withholds `id` from its public allowlist too, but + // a sitemap entry is built from the row rather than the projection - so the + // identifier is there, and it is what an `xhtml:link` group is keyed by. + expect(en?.entries[0].itemId).toBe(article.id); + }); + + it("omits a draft translation and never falls back for it", async () => { + const article = await publishLocalized(); + await translationEditorial()?.create( + article.id, + "pl", + { title: "Wersja robocza" } as never, + { actor: ACTOR }, + ); + + // No Polish entry at all: it has no URL of its own, and listing the English + // one under a Polish path would put the same content in the sitemap twice. + expect( + (await advancedDelivery()?.sitemap({ locale: "pl" }))?.entries, + ).toStrictEqual([]); + expect(article.id).toBeGreaterThan(0); + }); + + it("takes the later of the base and translation timestamps", async () => { + const article = await publishLocalized(); + + // A shared field moving changes what every language's page renders, even + // though no translation row was touched. + await sql` + UPDATE "example_advanced_articles" + SET "updatedAt" = now() + interval '1 hour' + WHERE "id" = ${article.id} + `; + const base = await advancedArticleContent + .service(context) + .findById(article.id); + const translation = await advancedArticleContent + .translationService?.(context) + .findByLocale(article.id, "en"); + + const page = await advancedDelivery()?.sitemap({ locale: "en" }); + + // The base row is now the later of the two, and that is the timestamp the + // sitemap carries - a shared field moving has to look like a change. + expect(base?.updatedAt.getTime()).toBeGreaterThan( + translation?.updatedAt.getTime() ?? 0, + ); + expect(page?.entries[0].lastModified.toISOString()).toBe( + base?.updatedAt.toISOString(), + ); + }); + + it("excludes a record whose noIndex flag is set", async () => { + const article = await publishLocalized(); + + await sql` + UPDATE "example_advanced_articles" + SET "syndicationNoIndex" = true + WHERE "id" = ${article.id} + `; + + expect( + (await advancedDelivery()?.sitemap({ locale: "en" }))?.entries, + ).toStrictEqual([]); + + // And the two agree: a record absent from the sitemap reports `index: false`. + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "en", + }); + expect(metadata?.robots).toStrictEqual({ follow: true, index: false }); + }); + }); + + describe("localized SEO projection", () => { + it("reads each language's own SEO fields", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + await translationEditorial()?.update( + article.id, + "en", + { seo: { description: "English summary", title: "English SEO" } } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + const en = await advancedDelivery()?.findById(article.id, { + locale: "en", + }); + const pl = await advancedDelivery()?.findById(article.id, { + locale: "pl", + }); + + expect(en?.seo).toStrictEqual({ + description: "English summary", + title: "English SEO", + }); + // Polish set none, so its title falls back to the localized `title` field - + // its own, never English's. + expect(pl?.seo).toStrictEqual({ description: null, title: "Witaj" }); + }); + + it("never leaks a private field into the metadata", async () => { + const article = await publishLocalized(); + + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "en", + }); + + // `syndication.indexable` is a declared field that `publicApi.fields` does not + // expose, so it is not even fetched - the projection cannot reach it. + expect(JSON.stringify(metadata)).not.toContain("indexable"); + }); + }); + + // ------------------------------------------------------------------------- + // Stage 1-7 regression + // ------------------------------------------------------------------------- + + describe("a content type without delivery", () => { + it("has no delivery service and writes no history", async () => { + expect(categoryContent.deliveryService).toBeUndefined(); + expect(categoryContent.definition.delivery.enabled).toBe(false); + + const outcome = await categoryContent + .service(context) + .create({ name: "Guides" } as never); + + expect(outcome).toBeTruthy(); + const [rows] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.category' + `; + expect(rows.count).toBe(0); + }); + + it("reports no delivery outcome on its mutations", async () => { + const outcome = await categoryContent + .service(context) + .create({ name: "News two" } as never); + + expect(outcome).not.toHaveProperty("delivery"); + }); + }); + + describe("preview", () => { + it("registers no slug history and appears in no sitemap", async () => { + const article = await createArticle({ title: "Unpublished draft" }); + + // A preview reads a revision; it writes nothing. The record is still a draft, + // so it has no reservation and no sitemap line either. + const revisions = await editorial()?.revisions.list(article.id); + expect(revisions?.edges.length).toBeGreaterThan(0); + + expect(await historyRows(article.id)).toStrictEqual([]); + expect( + (await delivery()?.sitemap())?.entries.filter( + entry => entry.itemId === article.id, + ), + ).toStrictEqual([]); + expect(await delivery()?.resolveSlug("unpublished-draft")).toStrictEqual({ + type: "not_found", + }); + }); + }); +}); From 33b300be4573ce01dd905fb77dcb052fa59e4512 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:10:59 +0200 Subject: [PATCH 096/123] docs(content): document Content Delivery and SEO Nine pages under `dev/content-engine`, plus the delivery tags in `caching.mdx` and the two new events in `built-in-events.mdx`. Each page leads with the rule rather than the API, because the rules are what a reader has to hold: when a slug becomes redirectable, why an alternate is never fabricated from a fallback, why the canonical URL is the *served* locale, and why the redirect status is not configurable. `content-delivery-migrations.mdx` states plainly what is **not** backfilled and why - scanning revisions would create incorrect permanent redirects from draft-only slugs, and an incorrect permanent redirect is worse than a missing one - then gives the SQL for an explicit backfill and for a route-prefix migration. `content-delivery-limitations.mdx` separates decisions from gaps: no page builder, no manual redirect manager, no `og:image`, no per-locale `noIndex`, no `410`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../docs/dev/content-engine/caching.mdx | 84 +++++ .../dev/content-engine/canonical-urls.mdx | 176 +++++++++ .../content-delivery-limitations.mdx | 166 +++++++++ .../content-delivery-migrations.mdx | 184 ++++++++++ .../content-delivery-nextjs.mdx | 253 +++++++++++++ .../dev/content-engine/content-delivery.mdx | 217 ++++++++++++ .../localization-and-hreflang.mdx | 207 +++++++++++ .../content/docs/dev/content-engine/meta.json | 9 + .../content/docs/dev/content-engine/seo.mdx | 232 ++++++++++++ .../docs/dev/content-engine/sitemaps.mdx | 276 +++++++++++++++ .../slug-history-and-redirects.mdx | 335 ++++++++++++++++++ .../docs/dev/events/built-in-events.mdx | 43 ++- 12 files changed, 2181 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/docs/dev/content-engine/canonical-urls.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/seo.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/sitemaps.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx index b0c4e390f..2ad224197 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -361,6 +361,90 @@ Nothing falls back to Polish, whatever the fallback setting is - so a Polish edi never throws away the English cache. See [Localized public API](/docs/dev/content-engine/localized-public-api#caching). +## Delivery tags + +A content type with [`delivery`](/docs/dev/content-engine/content-delivery) produces +three more scopes, in the same namespace and with the locale in the same position: + +```ts +import { + contentDeliveryRedirectTag, + contentDeliverySitemapTag, + contentDeliveryTag, +} from "@vitnode/core/content"; + +contentDeliveryTag("example.article", 42); +// "content:example.article:delivery:42" + +contentDeliveryTag("example.article", 42, "pl"); +// "content:example.article:delivery:pl:42" + +contentDeliveryRedirectTag("example.article", "stary-slug", "pl"); +// "content:example.article:redirect:pl:stary-slug" + +contentDeliverySitemapTag("example.article", "pl"); +// "content:example.article:sitemap:pl" +``` + +Each answers a different question a page asked, which is why they are separate from +the three above rather than folded into them: + +| Scope | Keyed by | Holds | +| ---------- | ------------- | -------------------------------------------------- | +| `delivery` | the record | canonical path, alternates, SEO metadata | +| `redirect` | the **slug** | "does this address still resolve here" | +| `sitemap` | the locale | one locale's file, and the index that lists them | + +A `generateMetadata` that renders only metadata is tagged `delivery` alone, so an +unrelated field of the record changing does not throw it away. A redirect lookup is +tagged by the **old** address, because that is what a request for a moved page arrives +with. + +### What expires them + +`contentInvalidationTags` takes an optional `delivery` block and derives everything +from the data it already has - the affected locales and every slug the record answered +to across the mutation: + +```ts +contentInvalidationTags({ + contentTypeId, + delivery: { sitemap: true }, + id, + isPublic, + slugs: [previousSlug, currentSlug], + wasPublic, +}); +``` + +| Mutation | delivery | redirect (old + new) | sitemap | +| --------------------------------- | -------- | -------------------- | ------- | +| Slug change (published) | ✅ | ✅ | ✅ | +| Publish / unpublish | ✅ | ✅ | ✅ | +| Delete | ✅ | ✅ | ✅ | +| Restore that moves a slug | ✅ | ✅ | ✅ | +| Translation create / delete | ✅ | ✅ | ✅ | +| SEO field edit (still published) | ✅ | ✅ | ❌ | + +The last row is the one worth reading twice: an edit that only changed what an +already-listed page *says* leaves the sitemap byte-identical, so its tag is not +expired. Everything that adds, removes or moves a line in the file expires it - and on +a localized content type that means each affected locale's file **and** the +locale-less index that enumerates them. + +<Callout type="warn" title="Delivery is opt-in at this layer too"> + Omit `delivery` from the input - which is what every content type without the block + does - and `contentInvalidationTags` returns exactly the strings it always returned, + byte for byte. Nothing existing has to be re-tagged, and no warm cache is thrown + away for a feature the content type does not use. A test asserts the exact lists. +</Callout> + +### Background mutations + +A [scheduled](/docs/dev/content-engine/scheduling) publish reaches the web app through +the same revalidation bridge, with the delivery tags included - there is no second +cross-origin invalidation system, and the same all-origins-must-accept rule applies. + ## Where the Next imports live Exactly one place: `@vitnode/core/content/next`. diff --git a/apps/docs/content/docs/dev/content-engine/canonical-urls.mdx b/apps/docs/content/docs/dev/content-engine/canonical-urls.mdx new file mode 100644 index 000000000..97da9fae2 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/canonical-urls.mdx @@ -0,0 +1,176 @@ +--- +title: Canonical URLs +description: One helper builds every content URL, it is relative on purpose, and the locale is normalized so one page has one cache key. +icon: Link +--- + +A canonical URL is the one address a page admits to living at. Everything else - +redirects, `hreflang`, sitemaps, cache tags - is defined in terms of it, so the +engine builds it in exactly one place. + +```ts +import { contentDeliveryPath } from "@vitnode/core/content"; + +contentDeliveryPath({ definition: articleContentType, slug: "my-article" }); +// "/articles/my-article" + +contentDeliveryPath({ + definition: advancedArticleContentType, + locale: "pl", + slug: "moj-artykul", +}); +// "/pl/articles/moj-artykul" +``` + +## How a path is built + +```text +nonlocalized /{publicApi.path}/{slug} +localized /{locale}/{publicApi.path}/{slug} +``` + +Nothing is configurable here, and that is the point: a resolver has to be able to +parse back what the builder produced, and a per-content-type URL template would +make that a guess. `publicApi.path` is reused rather than duplicated into +`delivery`, so the public API route and the public page cannot disagree about the +prefix. + +## It is relative + +`contentDeliveryPath` never returns an origin, because a content type definition +lives in source control and gets deployed to a preview domain, a staging domain and +production - so an origin baked into it would be wrong in two of the three places. + +Supply one when you need an absolute URL: + +```ts +import { contentDeliveryUrl } from "@vitnode/core/content"; + +contentDeliveryUrl({ + origin: "https://example.com", + path: "/pl/articles/moj-artykul", +}); +// "https://example.com/pl/articles/moj-artykul" +``` + +`https://example.com` and `https://example.com/` produce the same URL - it resolves +rather than concatenates - and a malformed origin comes back `null` rather than a +link with two schemes in it. + +The delivery service takes the same argument: + +```ts +await delivery.findById(42, { locale: "pl", origin: "https://example.com" }); +// { canonicalPath: "/pl/articles/…", canonicalUrl: "https://example.com/pl/articles/…", … } +``` + +`canonicalUrl` is **absent** rather than `null` when no origin was given, so a +consumer never has to tell "no origin was supplied" from "the URL could not be +built". + +<Callout type="info" title="Sitemaps are the exception"> + The sitemap protocol only accepts absolute URLs, so `contentSitemapXml` requires + an origin rather than taking one. See [Sitemaps](/docs/dev/content-engine/sitemaps). +</Callout> + +## The locale is normalized + +```ts +contentDeliveryPath({ definition, locale: "PL", slug: "witaj" }); +contentDeliveryPath({ definition, locale: "pl", slug: "witaj" }); +contentDeliveryPath({ definition, locale: " pl ", slug: "witaj" }); +// all three: "/pl/articles/witaj" +``` + +Same `normalizeContentLocale` the rest of the engine uses. It matters because a +path is also a cache key: three spellings of one locale producing three paths would +produce three cache entries for one page, and expiring one of them would leave the +other two stale forever. + +Slugs are percent-encoded on the way in. A generated slug is already URL-safe - +[`slugify`](/docs/dev/content-engine/slug-field) guarantees it - but a row written +straight into the database is not, and a *path* is what this function promises. + +## Nulls are deliberate + +`contentDeliveryPath` returns `null` rather than a best effort in three cases: + +- **An empty slug.** A canonical URL that points at the list page is worse than no + canonical URL at all. +- **An empty `publicApi.path`.** The content type has no public API. +- **A localized content type with no locale.** A localized record has one URL per + language and no locale-less one, so guessing would hand a reader the wrong + language under a URL that claims otherwise. + +## Parsing a path back + +```ts +import { parseContentDeliveryPath } from "@vitnode/core/content"; + +parseContentDeliveryPath(articleContentType, "/articles/my-article"); +// { locale: null, slug: "my-article" } + +parseContentDeliveryPath(advancedArticleContentType, "/pl/articles/moj-artykul"); +// { locale: "pl", slug: "moj-artykul" } +``` + +The inverse of the builder, and deliberately strict: it accepts exactly the shape +that function produces and refuses everything else. An extra segment, a different +public prefix, a traversal or a malformed escape is `null` rather than a best guess +- a resolver that guessed would answer one content type's URL with another's +record. + +A query string and a fragment are stripped first, because a browser sends them and +they are not part of the identity of a page. + +`delivery.resolvePath()` is this plus the lookup, and it is what a catch-all route +should call: + +```ts +const resolution = await delivery.resolvePath("/pl/articles/stary-slug"); +``` + +## The canonical URL is the *served* locale + +This is the rule most likely to be got wrong, and it comes straight out of +[Stage 5 fallback](/docs/dev/content-engine/localized-public-api): + +```text +requestedLocale = pl +PL translation missing +fallback EN translation exists +``` + +A public `findById()` may return the English copy. The canonical URL of that +response is the **English** one: + +```ts +{ + requestedLocale: "pl", + locale: "en", + isFallback: true, + canonicalPath: "/en/articles/article", +} +``` + +`/pl/articles/article` would be a self-declared canonical that answers 404 - the +Polish translation does not exist, so nothing serves that URL. Reporting the served +locale is what lets a page render `<link rel="canonical">` correctly *and* show a +"not translated yet" notice. + +`findBySlug` and `resolveSlug` remain strict-locale: a URL belongs to the language +it was published under, so they never fall back at all. + +## Registry helpers + +```ts +import { listDeliveryContentTypes } from "@vitnode/core/content"; + +const delivered = listDeliveryContentTypes(core.contentModels); +``` + +Every delivery-enabled content type of an installation, sorted by id so two +processes building the same sitemap index produce the same document. It is what +lets a site-level `/sitemap.xml` enumerate `blog.article`, `docs.page` and +`shop.category` without hardcoding a single plugin name - installing a plugin adds +its content types and removing it takes them out again. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx new file mode 100644 index 000000000..23a85f327 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -0,0 +1,166 @@ +--- +title: Delivery limitations +description: What Content Delivery deliberately does not do, and the reasoning behind each line - so you can tell a gap from a decision. +icon: OctagonAlert +--- + +Delivery is metadata and routing infrastructure. Most of the list below is not +"unfinished" - it is the shape of that decision. + +## It does not render anything + +No page builder, no layout builder, no block renderer, no React page generation. The +engine answers "what is the URL of this, and what should the page say about itself"; what +the page *is* belongs to the application. + +The practical consequence: delivery returns a path and a metadata object, and a plugin or +an app builds `/pl/articles/moj-artykul` from them. It does not assume your route +structure and it will not generate one. + +## No manual redirect manager + +There is no UI, no route and no service for creating a redirect by hand - and none for +deleting one. + +The AdminCP panel is **read-only** on purpose. A redirect is somebody else's incoming +link, so deleting one silently breaks traffic nobody in that dialog can see. That is a +destructive action, and a destructive action needs its own permission, a confirmation +that explains the consequence, and an audit trail. Displaying the history is useful +today; managing it is a product rather than a button. + +Consequences worth knowing: + +- A historical address stays reserved for as long as redirects are enabled. There is no + way to release one through the engine, so + [slug reuse](/docs/dev/content-engine/slug-history-and-redirects#historical-addresses-are-reserved) + by an unrelated record is refused permanently. +- If you genuinely need to release one, delete the row. It is an ordinary table, and the + [migrations guide](/docs/dev/content-engine/content-delivery-migrations) documents its + shape. + +There are also no **wildcard** or **regex** redirects, and no redirects between +arbitrary URLs. Slug history maps one record's old addresses to that record's current +one; a rule engine over paths is a different feature living in a different layer (a +middleware, a CDN, a `next.config` `redirects` array). + +## No og:image + +`delivery.seo.openGraph` projects a title and a description, and stops there. + +An `og:image` needs an absolute URL, known dimensions and a stable content type for the +file - which is a media subsystem, and Stage 8 does not build one. Emit it from your own +`generateMetadata` alongside the delivery metadata: + +```ts +const metadata = await contentDeliveryMetadata({ … }); + +return { + ...metadata, + openGraph: { ...metadata.openGraph, images: [await coverImageFor(slug)] }, +}; +``` + +## noIndex is shared, not per locale + +`delivery.seo.noIndexField` must be a **shared** boolean, and a localized one is a +definition-time error. + +The reason is that one field drives two consumers - the sitemap exclusion and the +`robots` directive - and they have to agree. A per-locale value would give one record one +answer per language while it has a single canonical decision, and the two consumers could +then disagree about which URLs exist. + +Per-locale indexing is a real thing to want. It is deferred rather than approximated, +because doing it properly means a per-locale sitemap decision *and* a per-locale +`robots`, both derived from the translation actually being served. + +## A localized content type needs a localized slug for redirects + +```ts +// ✖ localized content type, shared slug field +delivery: { enabled: true, redirects: { enabled: true } } +``` + +Every language answers to the same segment, so `/en/x/hello` and `/pl/x/hello` are both +live and one slug change moves both at once. Slug history stores *the URL that was live*, +so one retired row would have to be several paths - and the panel would show one of them +as if it were the address somebody bookmarked. + +Canonical URLs, SEO, alternates and the sitemap all work in that shape. Only the +reservation is ambiguous, so only `redirects` is refused. Mark the slug +`localized: true` and everything is available. + +## 410 Gone is not distinguished from 404 + +A historical URL whose destination is unpublished or deleted answers `not_found`. + +A `410` would be more informative for a deletion - it tells a crawler to forget the URL - +but the engine has no tombstone abstraction that distinguishes "deleted on purpose" from +"unpublished for now", and a `410` that guessed would tell a crawler to forget a URL that +is coming back next week. One documented status, chosen because it is the one that is +always correct. + +## The redirect status is not configurable + +Always `308`. Every historical URL of every content type answers with it, so there is no +per-content-type setting to get wrong and no reason for two of them to disagree. `301` is +not offered: it lets a client rewrite the method to `GET`, and `308` does not. + +## Sitemap frequency and priority are static + +Per content type, not per record. There are no dynamic callbacks: a function that runs +once per URL in a 50,000-URL file is a performance decision disguised as a configuration +option. + +## No site-wide robots.txt + +`delivery.seo.noIndexField` is per record. Site-wide crawl rules - `Disallow`, crawl +delay, sitemap declarations - are application or core configuration, not something a +content type gets to influence. + +## No delivery mutations in the service + +`model.deliveryService` is read-only, and structurally so: slug history is written by the +editorial services inside the transaction that moves the slug, so there is no `reserve` +to call without one. Admin or manual history mutation, if it ever exists, will be a +separate API with its own permission. + +## Delivery metadata is not a revision + +SEO is derived from content fields, so it already participates in +[revisions](/docs/dev/content-engine/revisions) - restoring a revision that had a +different `seo.title` changes the derived metadata on the next read. There is no separate +SEO history, and there will not be one: two revision systems over the same values is two +things to keep in sync. + +## itemId can be absent + +Delivery metadata reports `itemId: null` for a content type whose `publicApi.fields` +withholds `"id"`. + +That is deliberate rather than a gap: delivery reads the **public projection**, so it +cannot report a column the public API declined to publish. Expose `"id"` in the allowlist +and it is always present. A sitemap entry always carries it, because a sitemap row is +built from the row rather than from the projection. + +## Not in Stage 8 at all + +For the avoidance of doubt: no domain management, no CDN configuration, no content +approval workflow, no collaborative editing, no AI SEO generation, no AI translation, no +translation memory, no external TMS, no GraphQL, no semantic search, no analytics, no A/B +testing and no personalized URLs. + +## See also + +<Cards> + <Card + href="/docs/dev/content-engine/limitations" + title="Content Engine limitations" + description="The stage-wide list." + /> + <Card + href="/docs/dev/content-engine/content-delivery-migrations" + title="Delivery migrations" + description="What is not backfilled, and how to backfill it yourself." + /> +</Cards> diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx new file mode 100644 index 000000000..3a55bc025 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx @@ -0,0 +1,184 @@ +--- +title: Delivery migrations +description: One new core table, one deterministic migration, and a clear statement about what history does *not* get backfilled. +icon: Database +--- + +Enabling `delivery` adds no columns to your content tables. It needs one shared core +table, and that is the whole schema change. + +## The migration + +```bash +pnpm drizzle-kit generate --name=add_content_slug_history +pnpm db:migrate +``` + +```sql +CREATE TABLE "core_content_slug_history" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "languageId" integer, + "slug" varchar(160) NOT NULL, + "path" varchar(512) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "retiredAt" timestamp +); +ALTER TABLE "core_content_slug_history" ENABLE ROW LEVEL SECURITY; + +CREATE UNIQUE INDEX "core_content_slug_history_shared_unique" + ON "core_content_slug_history" ("contentTypeId","slug") + WHERE "languageId" IS NULL; + +CREATE UNIQUE INDEX "core_content_slug_history_locale_unique" + ON "core_content_slug_history" ("contentTypeId","languageId","slug") + WHERE "languageId" IS NOT NULL; + +CREATE INDEX "core_content_slug_history_item_idx" + ON "core_content_slug_history" ("contentTypeId","itemId","languageId"); + +CREATE INDEX "core_content_slug_history_plugin_id_idx" + ON "core_content_slug_history" ("pluginId"); +``` + +One table for every delivery-enabled content type in the install, for the same reason +`core_content_revisions` is shared: a content table is generated at runtime from a +descriptor, so core's static schema cannot name it - and a per-type history table would +mean a second generated table and a second migration for every plugin. + +### The indexes are the feature + +| Index | What it is for | +| -------------------- | ----------------------------------------------------- | +| `…_shared_unique` | The reservation, for a nonlocalized or shared slug | +| `…_locale_unique` | The reservation, per language | +| `…_item_idx` | One record's history, and retiring the slug it moved off | +| `…_plugin_id_idx` | Ownership, for an audit or a cleanup | + +The two uniques double as the resolver's lookup, which is why they lead with +`(contentTypeId, slug)`: a redirect lookup runs on a public request path for a URL that +is very often a typo, so it has to be an index hit rather than a scan. The PostgreSQL +suite asserts all four exist and that the shared one really is partial. + +Two partial uniques rather than one over a nullable `languageId`, because Postgres treats +every `NULL` as distinct - a single key including it would enforce nothing at all for the +shared case it exists to protect. + +## History is not backfilled + +**Nothing existing gets a history row.** History starts when Stage 8 begins tracking +future public slug changes, and that is a decision rather than an omission: + +```text +existing published article, slug = hello + → no history row + → /articles/hello is canonical, as it always was + → the *next* slug change creates the redirect +``` + +A record's current slug does not need to be history - it is the canonical URL, and the +resolver finds it through the ordinary public read. The first row for a record is written +the next time it is published or the next time its live slug moves. + +### Why revisions are not scanned + +It is tempting to reconstruct history from +[Stage 4 revisions](/docs/dev/content-engine/revisions), and the engine deliberately does +not: + +- **Revision snapshots include draft-only slugs.** A record whose slug was corrected + three times before publication would produce three redirects to URLs nobody ever + visited - and three permanent reservations blocking those addresses. +- **Publication timing is ambiguous.** A snapshot records the values at a version, not + whether that version was ever the *live* one. Reconstructing "was this slug + addressable" from a revision list means guessing. +- **Old schemas differ.** A snapshot taken before a field was renamed does not name the + slug field the content type has today. + +An automatic backfill would therefore create incorrect redirects, and an incorrect +permanent redirect is worse than a missing one: it sends real traffic somewhere wrong and +reserves an address nobody can reclaim. + +### An explicit backfill, if you want one + +If you *know* your data - because you have an access log, an external redirect map, or a +changelog - insert the rows yourself. The shape is documented above and the engine reads +it directly: + +```sql +INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path", "retiredAt") +VALUES + ('@vitnode/example', 'example.article', 42, NULL, + 'old-slug', '/articles/old-slug', now()); +``` + +Three rules to hold: + +1. **`retiredAt` must be set** for a historical address. A `NULL` means "this is the + record's current slug", and two current rows for one record is a state the engine does + not produce. +2. **`path` must be the URL that was live**, not one rebuilt from today's + `publicApi.path`. That is the whole reason the column exists. +3. **`languageId`** is the language for a localized slug and `NULL` for a shared one - + matching `delivery.slugScope`. Getting it wrong puts the row in the other partial + unique index and the resolver will not find it. + +Verify with the AdminCP delivery panel: it lists exactly what the resolver will use. + +## Changing `publicApi.path` + +```text +/articles → /blog +``` + +This is **source configuration**, not a content mutation, and the engine creates no +redirects for it. Every record's URL changes at once, at deploy time, for a reason no +row in the database records. + +Automating it would mean writing one history row per record on boot - a migration +disguised as a config change, running inside a process that may be one of several +starting at the same moment. So it is left to you, deliberately: + +```sql +-- One row per published record, with the old prefix. +INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path", "retiredAt") +SELECT + '@vitnode/example', 'example.article', a."id", NULL, + a."slug", '/articles/' || a."slug", now() +FROM "example_articles" a +WHERE a."status" = 'published' AND a."publishedAt" IS NOT NULL +ON CONFLICT DO NOTHING; +``` + +That is safe because the slug is unchanged - only the prefix moved - so the retired +`path` is the old URL and the resolver's destination is the record's current canonical +path under the new prefix. Run it in the same deploy as the config change. + +Stage 8's automatic redirects are for **slug changes**. Route-prefix migrations are a +deployment decision, and they get explicit tooling or explicit SQL. + +## Turning delivery off + +Removing the `delivery` block stops the engine reading or writing history. The table and +its rows stay - which is what you want, because turning it back on restores every +redirect rather than starting from nothing. + +Nothing else about the content type changes: no columns are dropped, no routes disappear +beyond the three delivery ones, and no cache tag it produced was ever a delivery tag. + +## Adding delivery to an existing content type + +Safe and additive: + +1. Add the block. No schema change to your table. +2. Run the core migration if you have not already. +3. Existing published records keep their canonical URLs and gain sitemap entries + immediately. +4. The first slug change on a published record creates the first redirect. + +There is no reindex, no rebuild and no backfill step - which is the practical +consequence of delivery being a projection over data the content type already had. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx new file mode 100644 index 000000000..e6b7594ff --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx @@ -0,0 +1,253 @@ +--- +title: Delivery in Next.js +description: A thin adapter that turns framework-neutral delivery metadata into a generateMetadata return value, a sitemap.ts and a 308 - and nothing more. +icon: FileCode +--- + +The core delivery layer is framework-neutral on purpose, so `@vitnode/core/content/next` +is a translation layer and nothing else: it maps delivery metadata onto the two shapes +Next.js asks for, and issues the redirect the resolver reports. + +It reads over **HTTP** rather than through `model.deliveryService`, because in VitNode's +split deployment the web app is not the process that holds the database. A +single-process install can call the service directly and skip this entirely. + +## generateMetadata + +```tsx title="src/app/[locale]/articles/[slug]/page.tsx" +import { contentDeliveryMetadata } from "@vitnode/core/content/next"; + +import { articleContentType } from "@vitnode/example/content/article"; + +export const generateMetadata = async ({ + params, +}: { + params: Promise<{ locale: string; slug: string }>; +}) => { + const { locale, slug } = await params; + + return await contentDeliveryMetadata({ + definition: articleContentType, + locale, + origin: "https://example.com", + pluginId: "@vitnode/example", + slug, + }); +}; +``` + +That produces, for a published record: + +```ts +{ + title: "My article", + description: "A summary.", + alternates: { + canonical: "https://example.com/en/articles/my-article", + languages: { + en: "https://example.com/en/articles/my-article", + pl: "https://example.com/pl/articles/moj-artykul", + "x-default": "https://example.com/en/articles/my-article", + }, + }, + openGraph: { + title: "My article", + description: "A summary.", + url: "https://example.com/en/articles/my-article", + }, + robots: { index: true, follow: true }, +} +``` + +Every key is **absent** rather than present-and-null when there is no value. Next +renders a `null` title as an empty `<title>` and an absent one not at all, and an empty +`<title>` is worse than none. + +`{}` for a URL that does not resolve, rather than a throw: `generateMetadata` runs +alongside the page, the page is what calls `notFound()`, and a metadata function that +threw would replace a clean 404 with an error boundary. + +### Pass an origin + +Optional, and strongly recommended. Without it every URL in the result is relative - a +relative `canonical` is legal and resolves against the page, and an absolute one is +what every SEO checker asks for. + +### The pure half + +```ts +import { contentDeliveryToNextMetadata } from "@vitnode/core/content/next"; + +contentDeliveryToNextMetadata(deliveryResponse, { origin }); +``` + +Exported separately so a page that already holds the delivery response - because it +fetched the record and its metadata together - can translate it without a second round +trip. It is also what makes the mapping unit-testable without a network. + +## The page: redirect, render, or 404 + +```tsx title="src/app/[locale]/articles/[slug]/page.tsx" +import { contentDeliveryPage } from "@vitnode/core/content/next"; + +const Page = async ({ + params, +}: { + params: Promise<{ locale: string; slug: string }>; +}) => { + const { locale, slug } = await params; + + // Only returns for the current slug: a moved URL has already 308ed, and a missing + // one has already 404ed. + const delivery = await contentDeliveryPage({ + definition: articleContentType, + locale, + pluginId: "@vitnode/example", + slug, + }); + + return <Article delivery={delivery} />; +}; + +export default Page; +``` + +`contentDeliveryPage` is the only helper in the adapter with a side effect, which is why +it lives in its own module: `next/navigation`'s control-flow functions throw to unwind +the render, so a page that only wanted metadata should not be able to reach them by +accident. + +It issues a **308** via `permanentRedirect`, with `RedirectType.replace` so a reader who +follows an old link does not have to press back twice to leave a page they were never +meant to land on. + +A draft, an unpublished record, a deleted one, a slug that never existed and a +historical URL whose destination is no longer public are all the same `notFound()`. A +redirect to hidden content would be a way to confirm it exists. + +<Callout type="info" title="Why not vitnode-frontend/navigation"> + That wrapper is the locale-aware one every app-level redirect should use, and this is + the one place it would be wrong: a delivery location is a **complete** path that + already carries its locale segment - the engine built it - so routing it through + `next-intl` would prefix the locale a second time. It is also a 307, and a canonical + slug change needs the permanent, method-preserving 308. +</Callout> + +### Resolving without acting + +```ts +import { contentDeliveryResolve } from "@vitnode/core/content/next"; + +const resolution = await contentDeliveryResolve({ + definition: articleContentType, + locale, + pluginId: "@vitnode/example", + slug, +}); + +switch (resolution.type) { + case "content": + return resolution; // canonical metadata + case "redirect": + return resolution; // { location, status: 308 } + case "not_found": + return null; +} +``` + +A discriminated union, so a caller branches on `type` rather than inferring which arm it +is holding. The route answers `not_found` as a **200 with a body** rather than a 404, +which is what lets a caller tell "this URL resolves to nothing" from "the delivery API +is unreachable" - and keeps a negative out of the response cache, so publishing the +record makes it resolve immediately. + +## Sitemap + +```ts title="src/app/sitemap.ts" +import { contentSitemapEntries } from "@vitnode/core/content/next"; + +const sitemap = async () => { + const { entries } = await contentSitemapEntries({ + definition: articleContentType, + origin: "https://example.com", + pluginId: "@vitnode/example", + }); + + return entries; +}; + +export default sitemap; +``` + +It pages through the delivery sitemap route until the cursor runs out, so a content type +with 40,000 published records is 40 requests rather than one enormous response. +`maxPages` (default 100) is a backstop, because an unbounded loop against a paginated +API is the one bug in this file that could take a site down - and reaching it is +reported through `truncated` rather than thrown, so a partial sitemap is still a valid +sitemap. + +```ts +const { entries, truncated } = await contentSitemapEntries({ … }); +if (truncated) { + // Split with `generateSitemaps` - see contentSitemapChunks. +} +``` + +Next caps a `sitemap.ts` at 50,000 URLs and splits beyond that with `generateSitemaps`; +[`contentSitemapChunks`](/docs/dev/content-engine/sitemaps#scaling-past-one-file) is the +helper that decides how many files that is. + +For a localized site, one call per locale: + +```ts +const sitemap = async () => { + const locales = ["en", "pl"]; + const pages = await Promise.all( + locales.map(async locale => + ( + await contentSitemapEntries({ + definition: articleContentType, + locale, + origin: "https://example.com", + pluginId: "@vitnode/example", + }) + ).entries, + ), + ); + + return pages.flat(); +}; +``` + +## Cache tags + +Every read here is `cache: "force-cache"` and carries the delivery tag that a mutation +expires: + +| Helper | Tag | +| -------------------------- | ----------------------------------------- | +| `contentDeliveryResolve` | `content:{id}:redirect:{locale?}:{slug}` | +| `contentDeliveryItem` | `content:{id}:delivery:{locale?}:{itemId}` | +| `contentSitemapEntries` | `content:{id}:sitemap:{locale?}` | + +`resolve` is tagged by the **slug** rather than the record, which is what makes a moved +page stop being served from its former URL: a slug change expires the old address's +lookup and the record's metadata at the same moment. + +`contentDeliveryItem` is tagged by the record, which makes it the right call for a page +that already knows which record it is rendering - an edit to the SEO description expires +it, and an unrelated record's publish does not. + +See [Cache behaviour](/docs/dev/content-engine/caching) for the whole tag list and what +expires each one. + +## Metadata types are not in core + +`ContentDeliveryNextMetadata` is a structural type rather than an +`import type { Metadata } from "next"`, so the core package does not grow a +compile-time dependency on the framework's type surface for four keys. It is assignable +to `Metadata`, which is what a `generateMetadata` needs it to be. + +That is the same reason the core engine returns `{ languages, xDefault? }` rather than +Next's `alternates` shape: move to Astro and you write a different forty lines against +the same service. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx new file mode 100644 index 000000000..04ae39f38 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx @@ -0,0 +1,217 @@ +--- +title: Content Delivery +description: Opt a content type into canonical URLs, slug history, redirects, hreflang, SEO metadata and sitemaps - without the engine rendering a single page. +icon: Route +--- + +Stages 1–7 gave content a table, a lifecycle, a public API, translations and a +history. None of them answered the question a frontend actually asks: + +```text +What is the URL of this thing? +``` + +`delivery` is the block that answers it - and the four questions that follow from +it: what was its URL before, should the old one redirect, which other languages +does it exist in, and what should the page put in `<head>`. + +```ts title="src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + + fields: { + title: field.text({ required: true, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + }, + + publication: { enabled: true }, + + publicApi: { + enabled: true, + path: "articles", + fields: ["id", "title", "slug", "excerpt", "publishedAt"], + }, + + delivery: { // [!code highlight] + enabled: true, // [!code highlight] + redirects: { enabled: true }, // [!code highlight] + seo: { // [!code highlight] + titleField: "title", // [!code highlight] + descriptionField: "excerpt", // [!code highlight] + }, // [!code highlight] + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, // [!code highlight] + }, // [!code highlight] + + admin: { label: { plural: "Articles", singular: "Article" } }, +}); +``` + +That is the whole opt-in. Omit the block and **nothing** about the content type +changes: same tables, same routes, same cache tags, same events, same everything. + +## What delivery is not + +Delivery is metadata and routing infrastructure. It is deliberately **not** a page +builder, and the line is worth stating plainly because it explains most of the API: + +- It returns a **path**, not a page. No React, no layout, no blocks. +- It does not assume your route structure. `/pl/articles/x` is what the engine + builds from `publicApi.path`, and a frontend is free to serve it from anywhere. +- It does not know your domain. Canonical paths are relative; you supply an origin + when you want an absolute URL. + +What it gives you is enough typed metadata that a plugin or an app can render +`/pl/articles/moj-artykul` and `/en/articles/my-article` correctly - including the +`hreflang` set, the redirect from the URL that page used to live at, and the +sitemap entry. + +## The blocks + +| Block | What it adds | +| ----------- | ------------------------------------------------------------------ | +| `redirects` | Durable [slug history](/docs/dev/content-engine/slug-history-and-redirects) and automatic 308s | +| `seo` | [Title, description, Open Graph and robots](/docs/dev/content-engine/seo) projection | +| `sitemap` | A paginated [sitemap service](/docs/dev/content-engine/sitemaps) | +| `hreflang` | An `x-default` for [localized alternates](/docs/dev/content-engine/localization-and-hreflang) | + +Every one of them is optional. `delivery: { enabled: true }` on its own gives you +canonical URLs and alternates, which is already the hard part. + +## Delivery requires a public API + +```ts +delivery: { enabled: true } +// ✖ without `publicApi: { enabled: true }` +``` + +A content type with no public API has no public URL, so there is nothing for +delivery to be about. That is a **compile error** on the `enabled: true` itself, +not just a boot-time throw: + +```ts +// Type 'true' is not assignable to type 'never'. +``` + +The runtime check stays as well, for a JavaScript caller and for a value that +widened somewhere upstream. Every other delivery rule works the same way - see +[Validation rules](#validation-rules). + +## The service + +`model.deliveryService(c, { pluginId })` is the server API. It is read-only, and +that is structural rather than a convention: slug history is written by the +editorial services inside the transaction that moves the slug, so there is no +`reserve` here to call without one. + +```ts +const delivery = articleContent.deliveryService?.(c, { pluginId }); + +await delivery?.findById(42, { locale: "pl" }); +await delivery?.resolvePath("/pl/articles/stary-slug"); +await delivery?.alternates(42); +await delivery?.sitemap({ locale: "pl", limit: 1_000 }); +await delivery?.history(42); +``` + +`undefined` for a content type without `delivery`, exactly like `publicService` +and `editorialService` - so the check reads naturally in code that does not know +which content type it was handed. + +Every answer is derived from the **public projection**, not from the base row: +`findById` and `resolveSlug` go through `model.publicService`, so the publication +predicate, the field allowlist and the +[fallback rules](/docs/dev/content-engine/localized-public-api) are the ones +already tested rather than a second implementation that agrees on the day it is +written. It is also what makes "SEO cannot leak a private field" true at runtime: +a private column is never fetched, so it is not in the row delivery reads. + +## Generated routes + +A delivery-enabled content type gains three public routes: + +```http +GET /api/{pluginId}/content/{path}/delivery/resolve/{slug} +GET /api/{pluginId}/content/{path}/delivery/item/{id} +GET /api/{pluginId}/content/{path}/delivery/sitemap +``` + +They exist because a frontend is very often **not** the process that holds the +database: VitNode's split deployment runs Next.js against a separate API, so +`generateMetadata`, a catch-all route and a `sitemap.ts` handler all need an HTTP +answer rather than a service call. A single-process install can call the service +directly and never touch them. + +Every path begins with the static `delivery` segment, which is what makes them +impossible to shadow: `/{slug}` is one segment and these are two or three, so a +record whose slug is literally `delivery` still resolves the ordinary way. + +<Callout type="info" title="No staff permission"> + Public delivery resolution is exactly as public as the content it describes. + Requiring a session to learn a canonical URL would be requiring one to render a + page. The [AdminCP route](/docs/dev/content-engine/slug-history-and-redirects#admincp) + that shows historical URLs is a different route, and it does require `can_view`. +</Callout> + +## Validation rules + +Delivery fails at **definition time** rather than at request time, because a +canonical URL that quietly stopped being generated is a page that quietly stopped +being indexable - and that is not a symptom anybody notices. + +| Rule | Result | +| ------------------------------------------------------ | -------------------------- | +| `delivery` without `publicApi` | Compile error + throw | +| `sitemap` without `publication` | Throw | +| `redirects` on a localized type with a **shared** slug | Throw | +| An SEO field not in `publicApi.fields` | Compile error + throw | +| A `textarea` as `titleField` | Compile error + throw | +| A repeatable leaf in any SEO slot | Compile error + throw | +| A non-boolean `noIndexField` | Compile error + throw | +| A **localized** `noIndexField` | Throw | +| `sitemap.priority` outside `0`–`1` | Throw | +| An unknown `changeFrequency` | Compile error + throw | +| `hreflang` without `localization` | Throw | +| A fallback SEO field with no primary | Throw | + +The last one is worth a word: `fallbackTitleField` without `titleField` is a +configuration that reads as if it does something and does nothing, because the +fallback is only consulted when the primary is empty. Naming only the fallback +means it is never reached, so the engine tells you to name it as the primary +instead. + +## Where to go next + +<Cards> + <Card + href="/docs/dev/content-engine/canonical-urls" + title="Canonical URLs" + description="How a path is built, and why it is relative." + /> + <Card + href="/docs/dev/content-engine/slug-history-and-redirects" + title="Slug history and redirects" + description="When a URL becomes redirectable, and who owns it afterwards." + /> + <Card + href="/docs/dev/content-engine/seo" + title="SEO" + description="Title, description, Open Graph and robots, from public fields." + /> + <Card + href="/docs/dev/content-engine/sitemaps" + title="Sitemaps" + description="A paginated service, an XML helper and a sitemap index." + /> + <Card + href="/docs/dev/content-engine/localization-and-hreflang" + title="Localization and hreflang" + description="Alternates that are real published translations, and nothing else." + /> + <Card + href="/docs/dev/content-engine/content-delivery-nextjs" + title="Next.js helpers" + description="generateMetadata, sitemap.ts and the redirect." + /> +</Cards> diff --git a/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx new file mode 100644 index 000000000..a91a6267c --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx @@ -0,0 +1,207 @@ +--- +title: Localization and hreflang +description: Alternates are real published translations and nothing else - so an hreflang never points at a 404, and a fallback never fabricates a URL. +icon: Languages +--- + +A localized record has one URL per language, and a page has to announce the others. +`delivery.alternates()` is that list, and its defining property is what it leaves +out. + +```ts +await delivery.alternates(42); +// [ +// { locale: "en", path: "/en/articles/my-article" }, +// { locale: "pl", path: "/pl/articles/moj-artykul" }, +// ] +``` + +## An alternate is a promise that a URL resolves + +So it is included only when **all** of this holds: + +```text +the base row is published +AND the translation is published +AND publishedAt <= now +AND the installation still serves that language +``` + +That is the same subordinated predicate the +[localized public read](/docs/dev/content-engine/localized-public-api) applies - not +a second implementation of it - so an alternate can never describe something the +public API would refuse to serve. + +Ordered by locale, so two processes rendering the same `hreflang` set produce the +same markup. + +## Fallback never fabricates an alternate + +This is the rule to internalise: + +```text +Article #42 + EN published + PL published + DE draft +``` + +```text +alternates: /en/articles/… /pl/articles/… + (no DE) +``` + +A German reader with `fallback: "default"` will be *served* the English copy - that +is what fallback is for. But German has no URL of its own, so listing +`/de/articles/…` would announce an `hreflang` pointing at a 404 and invite a crawler +to index the same content twice under two addresses. + +Fallback decides **which translation answers a request**. It never creates a URL. + +## hreflang + +```ts +const metadata = await delivery.findById(42, { locale: "pl" }); + +metadata.hreflang; +// { +// languages: { en: "/en/articles/my-article", pl: "/pl/articles/moj-artykul" }, +// xDefault: "/en/articles/my-article", +// } +``` + +Framework-neutral by design: `{ languages, xDefault? }` rather than a Next.js +`Metadata` object, because the core engine has no business knowing which framework +renders it. The [Next.js adapter](/docs/dev/content-engine/content-delivery-nextjs) +turns it into `alternates.languages` in one line, and an Astro or Remix adapter would +do the same. + +## x-default + +```ts +delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, +} +``` + +`"defaultLocale"` is the only supported value, and that is deliberate: an `x-default` +has to point at a URL that actually resolves, and the default locale's canonical path +is the one URL a localized record is guaranteed to have whenever it is public at all. + +It is emitted **only when that language is genuinely published**: + +```text +EN published, PL published → x-default = /en/articles/… +EN unpublished, PL published → no x-default at all +``` + +An `x-default` pointing at a translation the record does not have would be a hint to +crawl a 404 - worse than emitting nothing. + +Omit the block and no `x-default` is emitted. The engine will not invent a +locale-less route it does not serve. + +<Callout type="info" title="It needs localization"> + `delivery.hreflang` on a content type without `localization` is a definition-time + error. One language has no alternates, so there is nothing for an `x-default` to be + the default of. +</Callout> + +## Per-locale slug history + +Each locale's redirects are its own, because `languageId` is part of the history key: + +```text +EN: /en/articles/hello → /en/articles/hello-world +PL: /pl/articles/witaj (unchanged, no redirect created) +``` + +Changing the English URL retires an English address and reserves an English one. The +Polish history is not read and not written. + +The same slug may be retired independently in two locales - `/en/x/shared` and +`/pl/x/shared` are two URLs, so two different records may each own one of them: + +```sql +UNIQUE (contentTypeId, languageId, slug) WHERE languageId IS NOT NULL +``` + +A nonlocalized content type uses `languageId = NULL` and the other partial index. + +### A localized slug is required for redirects + +```ts +// ✖ localized content type, shared slug +fields: { + slug: field.slug({ source: "title" }), // shared + title: field.text({ localized: true, required: true }), +} +delivery: { enabled: true, redirects: { enabled: true } } +``` + +Every language would answer to the same segment, so `/en/x/hello` and `/pl/x/hello` +are both live and one slug change moves both at once. Slug history stores *the URL +that was live*, so one retired row would have to be several paths - and the AdminCP +would show one of them as if it were the address somebody bookmarked. + +Canonical URLs, SEO, alternates and the sitemap all work fine in that shape. Only the +reservation is ambiguous, so only `redirects` is refused. Mark the slug +`localized: true` and everything is available. + +## Unpublishing one language + +```text +EN translation unpublished + /en/articles/hello-world not_found + /en/articles/hello (retired) not_found + /pl/articles/witaj still canonical +``` + +One language going dark is not the record going dark. The resolver reads the live +subordinated publication state per locale, so nothing else is affected - and +republishing the English translation brings its redirects back. + +## Deleting one translation + +The translation's history is **kept**, exactly as a deleted record's is: the URL +existed, and the resolver answers `not_found` for it by finding no live translation +rather than by having forgotten it. + +## The locale is normalized everywhere + +`PL`, `pl` and `" pl "` produce one path, one cache tag and one history lookup. The +canonical spelling always comes back off `core_languages.code`, never the caller's +casing - see [Canonical URLs](/docs/dev/content-engine/canonical-urls#the-locale-is-normalized). + +## Localized sitemaps + +Each language is its own sitemap file, and a draft translation contributes nothing: + +```text +Article #42 EN published PL published DE draft + +/en/articles/article +/pl/articles/artykul +``` + +No fallback URLs, for the same reason there are no fallback alternates. See +[Sitemaps](/docs/dev/content-engine/sitemaps#localized-sitemaps). + +## Events + +A translation slug change emits the delivery events with the locale attached: + +```ts +{ + contentId: 42, + locale: "pl", + previousSlug: "stary-slug", + slug: "nowy-slug", + previousPath: "/pl/articles/stary-slug", + canonicalPath: "/pl/articles/nowy-slug", +} +``` + +They arrive **alongside** `translation_updated`, never instead of it. See +[Slug history and redirects](/docs/dev/content-engine/slug-history-and-redirects#events). diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 10fbaaa53..71e167fdc 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -36,6 +36,15 @@ "advanced-modeling-public-api", "advanced-modeling-migrations", "advanced-modeling-limitations", + "content-delivery", + "canonical-urls", + "slug-history-and-redirects", + "seo", + "localization-and-hreflang", + "sitemaps", + "content-delivery-nextjs", + "content-delivery-migrations", + "content-delivery-limitations", "admincp", "permissions", "events", diff --git a/apps/docs/content/docs/dev/content-engine/seo.mdx b/apps/docs/content/docs/dev/content-engine/seo.mdx new file mode 100644 index 000000000..fdc76d4a6 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/seo.mdx @@ -0,0 +1,232 @@ +--- +title: SEO metadata +description: Project a title, a description, Open Graph and a robots directive out of fields the public API already exposes - with explicit fallbacks and no invented text. +icon: Search +--- + +`delivery.seo` names which **public** fields become which piece of page metadata. +It projects; it never invents. + +```ts +delivery: { + enabled: true, + seo: { + titleField: "seo.title", + fallbackTitleField: "title", + descriptionField: "seo.description", + fallbackDescriptionField: "excerpt", + noIndexField: "syndication.noIndex", + openGraph: { + titleField: "seo.title", + descriptionField: "seo.description", + }, + }, +} +``` + +## Every field has to be public + +```ts +seo: { titleField: "internalNote" } +// ✖ Type error, and a definition-time throw +``` + +A `<title>` is rendered into a public page, so it has to be something the public API +would already have said out loud. That is a **compile error** as well as a runtime +one - `ContentDeliveryTitleField` extracts from `publicApi.fields`, so an +unexposed name is not in the union at all. + +It is also true at runtime for free, and this is the part worth understanding: SEO +is projected from the **public projection** rather than from the base row. A private +column is never fetched, so it is not in the object the projection returns - the +metadata cannot reach it even by mistake. + +## Field kinds + +| Slot | Kinds | Why | +| -------------------------- | -------------------- | ---------------------------------------------- | +| `titleField` | `text` | A `<title>` is one line, not a paragraph | +| `descriptionField` | `text`, `textarea` | Prose is exactly what a description is | +| `noIndexField` | `boolean`, **shared** | Two answers, one canonical decision | + +A **repeatable leaf** is refused in every slot: a page has one title and a +repeatable has many values. A **group leaf** is accepted everywhere - `seo.title` is +one column under a generated name, so it is one value. + +```ts +fields: { + seo: field.group({ + localized: true, + nullable: true, + fields: { + title: field.text({ nullable: true, maxLength: 200 }), + description: field.textarea({ nullable: true, maxLength: 500 }), + }, + }), +} +``` + +Paths use the same canonical dotted form the rest of the engine speaks - see +[Structured fields](/docs/dev/content-engine/structured-fields). There is no second +flatten/unflatten implementation here; delivery reads the projected row through +`readContentPath`, exactly as search does. + +## Fallbacks are explicit + +```ts +seo: { + titleField: "seo.title", + fallbackTitleField: "title", +} +``` + +The fallback is consulted when the primary resolves to `null` or to whitespace - a +`<title>` of three spaces is a missing title with extra steps. That covers the common +case exactly: nobody writes an SEO title twice, so `seo.title` is usually empty and +the article's real `title` is what should appear. + +There is deliberately **no** "derive a description from the first 160 characters of +the body". A summary somebody did not write is a summary nobody reviewed, and it +would silently become the description of every page that forgot to set one. + +<Callout type="warn" title="A fallback with no primary is refused"> + `fallbackTitleField` without `titleField` reads as if it does something and does + nothing: the fallback is only reached when the primary is empty, so on its own it + is never consulted. Name it as `titleField` instead. +</Callout> + +## The result + +```ts +const metadata = await delivery.findById(42, { locale: "pl" }); + +metadata.seo; +// { title: "Mój artykuł", description: "…" } +``` + +The shape is stable whether or not the block was configured - a content type that +names nothing gets `{ title: null, description: null }` - so a frontend never +branches on "was SEO set up", only on "did a value come back". + +## Open Graph + +```ts +seo: { + titleField: "seo.title", + openGraph: { titleField: "social.title" }, +} +``` + +`null` when the content type configured none, and that is a different fact from "it +did, and this page has no title" - a renderer treats them differently, because the +first emits no tags at all. + +Each Open Graph slot falls back to the ordinary SEO one, which makes the common case +- the same title in both places - a two-line config: + +```ts +openGraph: {} // inherits titleField and descriptionField +``` + +<Callout type="info" title="No og:image"> + Stage 8 does not implement a media subsystem, and an `og:image` needs one: an + absolute URL, known dimensions and a stable content type for the file. Emit it from + your own `generateMetadata` alongside the delivery metadata - see + [Limitations](/docs/dev/content-engine/content-delivery-limitations). +</Callout> + +## Robots and noindex + +```ts +seo: { noIndexField: "syndication.noIndex" } +``` + +```ts +metadata.robots; +// { index: false, follow: true } +``` + +One boolean drives **two** consumers, and that is the whole reason it exists as a +single field: a record excluded from the sitemap and a record reporting `index: +false` have to be the same record. Two settings would eventually disagree. + +`follow` is always `true`. "Do not list this page" and "do not follow the links on +it" are different instructions, and a content type that asked for the first has not +asked for the second - a `noindex, nofollow` page is a dead end for a crawler walking +the site, which is a decision for site-wide robots configuration rather than for one +record. + +`null` when no `noIndexField` is configured, so a content type that never thought +about indexing emits no `robots` meta tag rather than an affirmative "yes, index +this". + +### It has to be shared + +```ts +// ✖ a localized group's leaf +seo: { noIndexField: "flags.noIndex" } +``` + +A localized boolean would give one record one answer per language while it has a +single canonical decision - and the sitemap exclusion and the `robots` directive +would then be able to disagree. Delivery refuses it at definition time. + +Per-locale indexing is a real thing to want; it is deferred rather than approximated. +See [Limitations](/docs/dev/content-engine/content-delivery-limitations). + +## Localized SEO + +A localized group gives every language its own copy: + +```ts +fields: { + seo: field.group({ localized: true, nullable: true, fields: { … } }), +} +``` + +```text +en: { title: "English SEO", description: "English summary" } +pl: { title: null, description: null } → falls back to the + Polish `title` +``` + +The fallback is **that language's own** field, never English's. The projection reads +one row - the translation being served - so it has nothing else to reach for. + +On a fallback read the metadata reports the locale it actually served, and the +canonical URL follows it: + +```ts +{ + requestedLocale: "pl", + locale: "en", + isFallback: true, + canonicalPath: "/en/articles/article", + seo: { title: "The English title", … }, +} +``` + +See [Canonical URLs](/docs/dev/content-engine/canonical-urls#the-canonical-url-is-the-served-locale). + +## SEO has no revision history of its own + +SEO is derived from content fields, so it already participates in +[revisions](/docs/dev/content-engine/revisions): + +```text +restore a revision that had seo.title = "Old heading" +→ seo.title is "Old heading" again +→ the delivery metadata says so on the next read +``` + +There is no second history to keep in sync, and restoring SEO is not a separate +operation. That is the reason `delivery.seo` names fields rather than storing values. + +## Search + +Changing an SEO field already triggers +[search synchronization](/docs/dev/content-engine/search) when the field is one +`search` indexes - there is no second indexing path, and delivery adds none. What +delivery guarantees is narrower and worth stating: a search document carries the +**current** canonical URL, and a historical URL never becomes a second document +competing with the page it redirects to. diff --git a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx new file mode 100644 index 000000000..f738b2040 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx @@ -0,0 +1,276 @@ +--- +title: Sitemaps +description: A cursor-paginated service that lists what is public right now, plus pure helpers that turn its entries into valid XML and a sitemap index. +icon: Map +--- + +```ts +delivery: { + enabled: true, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, +} +``` + +That is the whole configuration. The engine does not build a file - it answers "which +URLs are public right now", one page at a time, and leaves serialization to a pure +helper. + +## Querying and serializing are separate + +Deliberately, and it is the design decision that makes both testable: "which URLs are +public" is a keyset scan over two tables, and "what does a sitemap file look like" is +a string. Folded into one function, the XML would be untestable without a database +and the pagination untestable without parsing XML. + +```ts +// the query +const page = await delivery.sitemap({ locale: "pl", limit: 1_000 }); + +// the serialization +import { contentSitemapXml } from "@vitnode/core/content"; + +const xml = contentSitemapXml({ + entries: page.entries, + origin: "https://example.com", +}); +``` + +## One page of entries + +```ts +await delivery.sitemap({ cursor, limit, locale }); +``` + +```ts +{ + entries: [ + { + itemId: 42, + locale: "pl", + path: "/pl/articles/moj-artykul", + lastModified: new Date("2026-01-02T03:04:05.000Z"), + changeFrequency: "weekly", + priority: 0.7, + }, + ], + nextCursor: 42, // pass back as `cursor`; null on the last page +} +``` + +### Cursors, never offsets + +`cursor` is the last `itemId` of the previous page, and pagination is a keyset over +the primary key. An `OFFSET` deep into a large table both slows down linearly *and* +skips rows when something is published between two pages - and a sitemap is +regenerated from scratch every time a crawler asks, so both matter. + +Ordering is `ORDER BY id ASC`, which makes the output deterministic: no duplicates, no +gaps, and the same document from two processes. + +`limit` defaults to 1,000 and is capped at the protocol's 50,000. A page is one keyset +query plus one batched read, so 1,000 rows is a response a serverless function can +hold without thinking about it; a caller that wants a whole 50,000-URL file asks for +it explicitly. + +### Only what is public right now + +The publication predicate is not a parameter: + +```text +nonlocalized the base row published +localized the base row AND the translation published +``` + +A draft, an unpublished record and a `publishedAt` in the future are all simply +absent. The localized form is the same subordination the +[public read](/docs/dev/content-engine/localized-public-api) applies. + +## Localized sitemaps + +Each published translation is one URL, and each language is its own file: + +```text +Article #42 EN published PL published DE draft + +/en/articles/article +/pl/articles/artykul +``` + +No DE, and **no fallback URLs**. A locale served English through +`fallback: "default"` has no URL of its own, so listing one would put the same +content in the sitemap twice under two addresses. + +A locale that names no language this install serves gets an empty page rather than an +error - a crawler asking for `/sitemaps/blog.article-de.xml` on a site with no German +should get a valid empty document. + +## lastModified + +| Content type | Value | +| -------------- | -------------------------------------------- | +| nonlocalized | `base.updatedAt` | +| localized | `max(base.updatedAt, translation.updatedAt)` | + +The localized rule is the interesting one: both halves are rendered into the page, so +a **shared** field moving changes what every language's document says even though no +translation row was touched. Taking the translation's timestamp alone would tell a +crawler nothing had changed. + +<Callout type="info" title="Timestamps are read through the column's own decoder"> + Drizzle turns off the driver's timestamp parsing so its column mappers can treat a + naive `timestamp` as UTC. A raw `sql` fragment has no mapper, so the driver's + fallback parses the same value as *local* time - the two disagree by the server's + offset. The `greatest()` expression borrows the column's decoder with `.mapWith`, + which is why a localized `lastmod` is not hours out. The PostgreSQL suite asserts + it. +</Callout> + +## changeFrequency and priority + +Static, per content type, and validated: + +```ts +sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 } +``` + +`changeFrequency` must be one of the seven values the protocol defines - `always`, +`hourly`, `daily`, `weekly`, `monthly`, `yearly`, `never`. A crawler ignores an +unknown value silently, so a typo has to be a compile error or it is a hint nobody +ever receives. + +`priority` must be between `0` and `1` inclusive, and is emitted at one decimal place. + +Both are omitted from the XML when unset, which is valid. There are deliberately no +per-record dynamic callbacks: a function that runs once per URL in a 50,000-URL file +is a performance decision disguised as a configuration option. + +## Excluding one record + +```ts +seo: { noIndexField: "syndication.noIndex" } +``` + +`noIndex = true` removes the record from the sitemap **and** reports +`robots: { index: false }` - one boolean behind both, so they cannot disagree. It is a +single clause in the query rather than a post-filter, so a page of 1,000 entries is +1,000 listed URLs rather than however many survived. + +The field must be a shared boolean. See [SEO](/docs/dev/content-engine/seo#robots-and-noindex). + +## The XML helper + +```ts +import { contentSitemapXml } from "@vitnode/core/content"; + +contentSitemapXml({ entries, origin: "https://example.com" }); +``` + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>https://example.com/articles/my-article</loc> + <lastmod>2026-01-02T03:04:05.000Z</lastmod> + <changefreq>weekly</changefreq> + <priority>0.7</priority> + </url> +</urlset> +``` + +`origin` is required rather than optional: the protocol only accepts absolute URLs, so +this is the one place delivery cannot stay origin-agnostic. An entry whose path will +not resolve against it is **dropped** rather than emitted - one malformed `<loc>` is a +document a crawler may reject whole. + +XML's five predefined entities are escaped, with `&` first: escaping it after `<` would +turn the `<` just produced into `&lt;`. + +### hreflang inside a sitemap + +```ts +contentSitemapXml({ + alternates: await readDeliveryAlternatesMany({ c, itemIds, model }), + entries, + origin: "https://example.com", +}); +``` + +```xml +<url> + <loc>https://example.com/en/articles/my-article</loc> + <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/articles/my-article" /> + <xhtml:link rel="alternate" hreflang="pl" href="https://example.com/pl/articles/moj-artykul" /> +</url> +``` + +Opt-in, and standards-compliant: the namespace is declared on the root element, and +every alternate of a group is repeated inside **each** of its `<url>` entries - +including the entry's own. That last rule is the one implementations get wrong, and it +is why alternates are supplied per entry rather than derived: the caller has already +resolved which translations are published, and the serializer does not go looking. + +`readDeliveryAlternatesMany` batches a whole page into one query rather than one per +URL. + +## Scaling past one file + +```ts +import { contentSitemapChunks, contentSitemapIndexXml } from "@vitnode/core/content"; + +const { pages, size } = contentSitemapChunks({ total, size: 1_000 }); +``` + +```text +/sitemap.xml the index +/sitemaps/blog.article-1.xml +/sitemaps/blog.article-2.xml +``` + +```ts +contentSitemapIndexXml({ + entries: Array.from({ length: pages }, (_, page) => ({ + path: `/sitemaps/blog.article-${page + 1}.xml`, + })), + origin: "https://example.com", +}); +``` + +`pages` is at least `1` even for an empty content type, and that is on purpose: an +index that lists a file which does not exist is a broken index, and a content type +with nothing published today will have something tomorrow. `size` is clamped to the +protocol's 50,000-URL ceiling, so a caller cannot ask for one enormous invalid file. + +`contentSitemapIndexXml` emits `<sitemapindex>` with `<sitemap>` children - a separate +function from `contentSitemapXml` because it is a separate document type, and because +an index whose entries were `<url>` elements is the single most common way to publish a +sitemap no crawler reads. + +## A site-level sitemap + +```ts +import { listDeliveryContentTypes } from "@vitnode/core/content"; + +const delivered = listDeliveryContentTypes(core.contentModels); +``` + +Every delivery-enabled content type of the installation, sorted by id. Build one index +entry per content type per locale per chunk, and no plugin name is ever hardcoded - +installing a plugin adds its URLs and removing it takes them out again. + +## Memory + +Nothing here loads a content type whole: + +- one keyset page at a time, bounded by `limit`; +- one batched translation read per page, never one per row; +- one batched alternates read per page, when alternates are asked for; +- `noIndex` as a `WHERE` clause rather than a post-filter. + +The PostgreSQL suite pages a fixture in twos and asserts every record appears exactly +once, in ascending key order, with the cursor ending at `null`. + +## Next.js + +`contentSitemapEntries` pages through the delivery route and returns entries a +`sitemap.ts` can return directly. See +[Next.js helpers](/docs/dev/content-engine/content-delivery-nextjs#sitemap). diff --git a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx new file mode 100644 index 000000000..50febcb19 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -0,0 +1,335 @@ +--- +title: Slug history and redirects +description: Every URL a record was ever reachable at is recorded, reserved, and redirected to the current one - with no chains and no stolen addresses. +icon: CornerDownRight +--- + +Change the slug of a published article and its old URL stops existing. Every link +to it, every bookmark, every search result: gone. `delivery.redirects` is the block +that fixes that. + +```ts +delivery: { + enabled: true, + redirects: { enabled: true }, +} +``` + +From then on: + +```text +current: /articles/stary-slug +update: slug = nowy-slug + +after commit: + /articles/nowy-slug canonical + /articles/stary-slug 308 -> /articles/nowy-slug +``` + +## When a slug becomes redirectable + +This is the rule the whole feature rests on, so it is stated exactly: + +> A slug becomes redirectable only if it was **previously used by an addressable +> public version** - that is, the record (or the translation) was published while +> that slug was current. + +The consequence is the useful part: + +| Situation | Redirect? | +| ------------------------------------------------------ | --------- | +| Published as `a`, changed to `b` | ✅ `a → b` | +| Draft created as `a`, corrected to `b`, then published | ❌ none | +| Published as `a`, unpublished, changed to `b` | ❌ none *yet* | +| …then republished | ✅ `a → b` | + +A draft whose slug was corrected three times before anybody saw it produces no +redirects at all, because none of those URLs was ever live. Without that rule a +content type would accumulate a redirect per typo, and each one would be a +permanent claim on an address nobody had visited. + +## What is stored + +One shared table, `core_content_slug_history`: + +```text +id +pluginId +contentTypeId +itemId +languageId NULL for a shared slug +slug +path the URL exactly as it was live +createdAt +retiredAt NULL while this slug is the record's current address +``` + +Both states are stored - current *and* retired - and that is what makes the +uniqueness below a **reservation** rather than only a log. + +`path` is recorded rather than rebuilt on read, because it is the one thing the +engine cannot recompute later: a path is built from `publicApi.path`, which is +source configuration a developer may change. The URL that was live is a historical +fact, so it is kept as one - and the AdminCP shows exactly the address somebody's +bookmark holds. + +<Callout type="info" title="No foreign key to the record"> + Same reasoning as `core_content_revisions`: the target table is generated at + runtime, so core's static schema cannot name it. Every query is scoped by + `(contentTypeId, itemId)`, and a URL's history stays true after the record is + gone. +</Callout> + +## Historical addresses are reserved + +```text +Article 1: old slug = hello current = hello-world +Article 2: slug = hello ✖ CONTENT_DELIVERY_SLUG_RESERVED +``` + +`hello` is free on the content table - Article 1 moved off it - so without the +reservation Article 2 could take it, and `/articles/hello` would silently stop +redirecting to the article it belonged to and start resolving to an unrelated one. +Every link, bookmark and search result pointing at it would change meaning. + +So a historical public slug stays reserved for the **content type and locale** that +retired it, for as long as redirects are enabled. Two partial unique indexes enforce +it: + +```sql +UNIQUE (contentTypeId, slug) WHERE languageId IS NULL +UNIQUE (contentTypeId, languageId, slug) WHERE languageId IS NOT NULL +``` + +Two indexes rather than one over a nullable column, because Postgres treats every +`NULL` as distinct - a single key including `languageId` would enforce nothing at +all for the shared case it exists to protect. + +The refusal is a structured **409**, not a raw constraint error: + +```json +{ + "code": "CONTENT_DELIVERY_SLUG_RESERVED", + "contentTypeId": "example.article", + "locale": null, + "slug": "hello" +} +``` + +It carries no owning-record id on purpose: a 409 on a public-facing address must not +become a way to enumerate records the caller cannot read. + +A record may always take **its own** retired address back - moving from `b` to `a` +re-activates its own row rather than colliding with it. + +<Callout type="warn" title="A draft's slug is checked, not reserved"> + Taking a new slug on a draft checks the reservations - so an editor hears "that + address is taken" at save time rather than at publish time - but claims nothing. + A draft has no public URL, and reserving one would refuse a live address to + somebody who wants it. +</Callout> + +## Resolution collapses chains + +```text +a → b +b → c + +request a → c (one hop) +request b → c (one hop) +``` + +The database keeps the chronology - three rows, two retired - and the *resolver* is +what collapses it. It never follows the history: it looks the address up, finds the +record it belongs to, and reads that record's **current** slug. There is no second +hop to make. + +```ts +await delivery.resolvePath("/articles/a"); +// { type: "redirect", status: 308, location: "/articles/c" } +``` + +## 308, and only 308 + +`308 Permanent Redirect` rather than `301`, and the difference is not cosmetic: a +`301` lets a client rewrite the method to `GET`, a `308` does not. Both behave +identically for the `GET` a content page is read with - and only one of them still +behaves correctly the day somebody `POST`s to a form under a moved path. + +It is **not configurable**. Every historical URL of every content type answers with +this, so there is no per-content-type setting to get wrong and no reason for two of +them to disagree. + +## Unpublished and deleted destinations + +A historical URL must never become a way to reach content that is not public. + +```text +record unpublished → old URLs answer not_found, history retained +record republished → old URLs redirect again +record deleted → old URLs answer not_found, history retained +``` + +The resolver checks the **live** publication state rather than the history, which is +why this needs no extra bookkeeping: an unpublished record simply has no current +canonical path to redirect to, so the answer is `not_found`. + +`not_found` rather than `410 Gone`, and that is a decision rather than an omission: +the engine has no abstraction that distinguishes "deleted on purpose" from +"unpublished for now", and a `410` that guessed would tell a crawler to forget a URL +that is coming back next week. + +History is **kept** on delete. An incoming link to a deleted article is exactly the +diagnostic somebody will want, and the resolver answers 404 for it by reading the +live record rather than by having forgotten the URL. + +## Restore + +A [revision restore](/docs/dev/content-engine/revisions) can move a slug, and it +integrates with history like any other edit: + +```text +current slug: new-name +restored revision: old-name + +after restore: + /articles/old-name canonical + /articles/new-name 308 -> /articles/old-name +``` + +The two addresses swap roles. A restore that changes no slug writes nothing at all - +the diff proves nothing moved before the delivery step runs. + +## Localized history + +Each locale's history is its own, because `languageId` is part of the key: + +```text +EN: /en/articles/hello → /en/articles/hello-world +PL: /pl/articles/witaj (untouched) +``` + +Changing the English URL creates no Polish redirect and retires no Polish address. +The same historical slug may be retired independently in two locales, because +`/en/x/shared` and `/pl/x/shared` are two URLs. + +See [Localization and hreflang](/docs/dev/content-engine/localization-and-hreflang). + +<Callout type="warn" title="A localized content type needs a localized slug"> + `delivery.redirects` refuses a localized content type whose `publicApi.slugField` + is **shared**. Every language would answer to the same segment, so one retired + address would belong to several URLs at once - and slug history stores the URL + that was live. Canonical URLs, SEO, alternates and the sitemap all work fine in + that shape; only the reservation is ambiguous, so only it is refused. +</Callout> + +## Transactions + +The slug write and its reservation are one transaction: + +```text +BEGIN + lock the row (the guarded UPDATE does it) + verify expectedVersion + update the slug + retire the old address + reserve the new one + write the revision +COMMIT + +emit the delivery events +invalidate the cache tags +sync the search index +``` + +The order matters twice. The reservation runs **after** the guarded write, so a +writer holding a stale `expectedVersion` fails first and leaves the history exactly +as it found it. And the old address is retired **before** the new one is reserved, +or a move from `a` to `b` and back to `a` would hit its own live reservation. + +Everything after `COMMIT` is outside the transaction, for the reason every other +stage states: a rollback cannot un-emit an event or un-expire a cache tag. + +## Concurrency + +Two editors racing on one slug produce one winner and one structured +[version conflict](/docs/dev/content-engine/editorial#optimistic-locking) - the +guarded `UPDATE` is the whole mechanism, and the history follows it: + +```text +version 3, slug = A +writer 1: A → B +writer 2: A → C + +one commits; the other gets 409 CONTENT_VERSION_CONFLICT +history: exactly one retirement and one new reservation +``` + +Two *different* records racing for the same retired address both lose: the +reservation lookup takes a row lock, so they serialise rather than race, and the +address belongs to neither of them. + +## Events + +Two events, each gated on a fact rather than an operation: + +```text +content.<id>.delivery_slug_changed +content.<id>.delivery_redirect_created +``` + +```ts +{ + contentId: 42, + locale: "pl", + previousSlug: "stary-slug", + slug: "nowy-slug", + previousPath: "/pl/articles/stary-slug", + canonicalPath: "/pl/articles/nowy-slug", +} +``` + +They are emitted **alongside** `updated` or `restored`, never instead of one: the +field mutation and the URL change are different facts with different audiences. A +listener that mirrors content wants the first; one that warms a CDN, tells an +external search engine or writes to an edge redirect table wants the second, and +would otherwise have to inspect `changedFields` for a slug field whose name it +cannot know. + +`delivery_redirect_created` fires only when the old address had genuinely been live, +so a corrected draft emits nothing. There is deliberately no sitemap event - every +mutation that changes a sitemap line already emits one of these or a publication +event. + +Both are documented in +[Built-in events](/docs/dev/events/built-in-events). + +## AdminCP + +Every delivery-enabled content type gets a read-only delivery panel on its row +action: + +```text +Delivery + +Canonical URL +/pl/articles/moj-artykul + +Status +Published + +Historical URLs +/pl/articles/stary-slug → redirects to the current URL +/pl/articles/jeszcze-starszy → redirects to the current URL +``` + +Gated by `can_view` and nothing narrower. It reports what the slug mutations already +did, so the permission that allowed the mutation is the only one it needs - +inventing a `can_manage_redirects` for a screen that manages nothing would be a +permission every install has to configure for no decision it can make. + +**Read-only is the deliberate scope.** A redirect is somebody else's incoming link, +so deleting one silently breaks traffic nobody in that dialog can see. That is a +destructive action, and a destructive action needs its own permission, a +confirmation that explains the consequence, and an audit trail. Displaying the +history is useful today; managing it is a product rather than a button. 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 48ebb666c..522971ecd 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -275,7 +275,33 @@ content.example.article.translation_unpublished (with publication) content.example.article.translation_restored (with editorial) ``` -Every one of them carries `locale` and `languageId`. They are deliberately +And one that opts into +[`delivery`](/docs/dev/content-engine/content-delivery) emits two more: + +```text +content.example.article.delivery_slug_changed +content.example.article.delivery_redirect_created +``` + +These arrive **alongside** `updated` (or `restored`, or `translation_updated`), never +instead of one: a field moving and a URL moving are different facts with different +audiences. A listener that mirrors content wants the first; one that warms a CDN, tells +an external search engine or writes to an edge redirect table wants the second, and +would otherwise have to inspect `changedFields` for a slug field whose name it cannot +know. + +`delivery_redirect_created` fires only when the old address had genuinely been +**publicly addressable** - so an article whose slug was corrected three times while it +was still a draft emits nothing, and a published article that moves emits exactly one. +That is the difference between "a URL now needs a redirect" and "somebody edited a +field". Both payloads carry `previousPath` and `canonicalPath`, and `locale` is `null` +when the slug is shared. + +There is deliberately no sitemap event: every mutation that changes a sitemap line +already emits one of these or a publication event, and a third carrying no new +information would be one more thing to keep consistent for no listener's benefit. + +Every translation event carries `locale` and `languageId`. They are deliberately **not** folded into `updated`: a shared update and a Polish translation update are different domain facts with different consequences - one invalidates every language, the other invalidates one - and a listener that had to inspect @@ -316,6 +342,21 @@ core event - `changedFields` narrows to that content type's own field names. description: "Restored only - the revision the values were taken from.", type: "number", }, + previousSlug: { + description: + "Delivery only - the slug the record answered to before this mutation.", + type: "string", + }, + previousPath: { + description: + "Delivery only - the full path it answered to before, e.g. `/pl/articles/stary-slug`.", + type: "string", + }, + canonicalPath: { + description: + "Delivery only - the path it answers to now, and where the historical one redirects.", + type: "string", + }, locale: { description: "Translation events only - the canonical core_languages.code the mutation was made in. Always present, so a listener never has to go and ask which language.", From 59547a866f4d0b298e4886e74ddd6dc2431ba73b Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:18:17 +0200 Subject: [PATCH 097/123] fix(example): satisfy the plugin's typed lint in the delivery suite Three small things the example plugin's stricter ruleset caught, none of which the core package's does: `code` is a `unique: true` field and several articles are created inside one millisecond, so a `Date.now()`-derived value was both a duplicate risk and a `Record<string, unknown>` interpolated into a template. A monotonic counter is what it should have been. The sitemap page is annotated explicitly: the optional-call chain through `deliveryService?.()` loses the element type in the typed-lint program even though `tsc` resolves it, and `itemId` is exactly what that test asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../example/src/content/advanced-article.ts | 5 +- .../src/database/delivery-postgres.test.ts | 127 +++++++++++------- 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index 86abca97e..665972814 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -204,7 +204,10 @@ export const advancedArticleContentType = defineContentType({ fallbackTitleField: "title", descriptionField: "seo.description", noIndexField: "syndication.noIndex", - openGraph: { titleField: "seo.title", descriptionField: "seo.description" }, + openGraph: { + titleField: "seo.title", + descriptionField: "seo.description", + }, }, sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, }, diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index 4733c48ff..faebf1259 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -1,4 +1,5 @@ import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { ContentDeliverySitemapPage } from "@vitnode/core/content/server"; import type { Context } from "hono"; import { @@ -136,16 +137,27 @@ const editorial = (target: Context = context) => const delivery = (target: Context = context) => articleContent.deliveryService?.(target, { pluginId: PLUGIN }); +/** + * A monotonic counter for the `unique: true` `code` field. + * + * `Date.now()` is not enough: several articles are created inside one millisecond by + * the tests below, and a duplicate `code` would surface as a `23505` from an + * unrelated constraint. + */ +let nextCode = 0; + const createArticle = async ( values: Record<string, unknown> = {}, ): Promise<{ id: number; version: number }> => { + nextCode += 1; + const outcome = await editorial()?.create( { category: categoryId, - code: `code-${Math.round(Date.now() % 1_000_000)}-${values.title ?? "x"}`, + code: `code-${nextCode}`, title: "Hello world", ...values, - } as never, + }, { actor: ACTOR }, ); if (!outcome) throw new Error("create returned nothing"); @@ -174,7 +186,9 @@ const publishArticle = async ( const historyRows = async ( itemId: number, ): Promise<{ path: string; retired: boolean; slug: string }[]> => { - const rows = await sql<{ path: string; retiredAt: null | string; slug: string }[]>` + const rows = await sql< + { path: string; retiredAt: null | string; slug: string }[] + >` SELECT "slug", "path", "retiredAt" FROM "core_content_slug_history" WHERE "contentTypeId" = 'example.article' AND "itemId" = ${itemId} @@ -238,7 +252,7 @@ const publishLocalized = async ({ await translationEditorial()?.create( created.row.id, "pl", - { title: pl } as never, + { title: pl }, { actor: ACTOR }, ); await translationEditorial()?.publish(created.row.id, "pl", { @@ -327,17 +341,15 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { } if (key === "events") { return { - emit: async ( - name: string, - payload: Record<string, unknown>, - ) => { + emit: async (name: string, payload: Record<string, unknown>) => { emitted.push({ name, payload }); return await Promise.resolve({ failures: [] }); }, }; } - if (key === "log") return { error: async () => await Promise.resolve() }; + if (key === "log") + return { error: async () => await Promise.resolve() }; if (key === "core") { return { contentModels: [ @@ -658,7 +670,8 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { it("brings a slug back into service when it is restored", async () => { const article = await publishArticle({ title: "Original name" }); - const [original] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + const [original] = + (await editorial()?.revisions.list(article.id))?.edges ?? []; const moved = await editorial()?.update( article.id, @@ -692,7 +705,8 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { it("writes no history for a restore that moves no slug", async () => { const article = await publishArticle({ title: "Stable" }); - const [first] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + const [first] = + (await editorial()?.revisions.list(article.id))?.edges ?? []; const edited = await editorial()?.update( article.id, @@ -706,9 +720,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { }); expect(restored?.delivery?.slugChanged).toBe(false); - expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ - "stable", - ]); + expect( + (await historyRows(article.id)).map(row => row.slug), + ).toStrictEqual(["stable"]); }); }); @@ -728,9 +742,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // `hello` is free on the content table now - the first article moved off it - // so the reservation is the only thing standing between the second article // and somebody else's incoming links. - await expect(createArticle({ slug: "hello", title: "Second" })).rejects.toThrow( - ContentDeliverySlugReserved, - ); + await expect( + createArticle({ slug: "hello", title: "Second" }), + ).rejects.toThrow(ContentDeliverySlugReserved); }); it("names the address in the structured error", async () => { @@ -842,9 +856,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { ), ).rejects.toThrow(ContentVersionConflict); - expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ - "original", - ]); + expect( + (await historyRows(article.id)).map(row => row.slug), + ).toStrictEqual(["original"]); }); it("serialises two records racing for the same retired address", async () => { @@ -873,9 +887,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // Both lose: the address belongs to the first article's history, and neither // of the two may take it. - expect( - results.every(result => result.status === "rejected"), - ).toBe(true); + expect(results.every(result => result.status === "rejected")).toBe(true); }); }); @@ -921,13 +933,14 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { let cursor: null | number | undefined = undefined; for (let page = 0; page < 10; page += 1) { - const result = await delivery()?.sitemap({ - cursor: cursor ?? undefined, - limit: 2, - }); + // Annotated, because the optional-call chain through `deliveryService?.()` + // loses the element type in the typed-lint program even though `tsc` + // resolves it - and `itemId` is exactly what this test is about. + const result: ContentDeliverySitemapPage | undefined = + await delivery()?.sitemap({ cursor: cursor ?? undefined, limit: 2 }); if (!result) break; - seen.push(...result.entries.map(entry => entry.itemId)); + for (const entry of result.entries) seen.push(entry.itemId); cursor = result.nextCursor; if (cursor === null) break; } @@ -1000,10 +1013,15 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { ); if (!outcome) throw new Error("update returned nothing"); - await contentEditorialEffects(context, articleContent.definition, outcome, { - model: articleContent, - pluginId: PLUGIN, - }); + await contentEditorialEffects( + context, + articleContent.definition, + outcome, + { + model: articleContent, + pluginId: PLUGIN, + }, + ); expect(emitted.map(entry => entry.name)).toStrictEqual([ "content.example.article.updated", @@ -1056,18 +1074,23 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { if (!outcome) throw new Error("update returned nothing"); indexed.length = 0; - await contentEditorialEffects(context, articleContent.definition, outcome, { - model: articleContent, - pluginId: PLUGIN, - }); + await contentEditorialEffects( + context, + articleContent.definition, + outcome, + { + model: articleContent, + pluginId: PLUGIN, + }, + ); // One document, pointing at the new address. A retired URL never becomes a // second search result competing with the page it redirects to. expect(indexed).toHaveLength(1); expect(indexed[0].url).toBe("/articles/slug-b"); - expect(indexed.filter(document => document.url === "/articles/slug-a")).toStrictEqual( - [], - ); + expect( + indexed.filter(document => document.url === "/articles/slug-a"), + ).toStrictEqual([]); }); }); @@ -1096,7 +1119,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const before = await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1123,7 +1146,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1150,7 +1173,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( first.id, "en", - { slug: "english-now" } as never, + { slug: "english-now" }, { actor: ACTOR, expectedVersion: first.enVersion }, ); @@ -1158,7 +1181,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.create( second.id, "pl", - { title: "Shared" } as never, + { title: "Shared" }, { actor: ACTOR }, ); const pl = await translationEditorial()?.publish(second.id, "pl", { @@ -1167,7 +1190,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( second.id, "pl", - { slug: "polski-teraz" } as never, + { slug: "polski-teraz" }, { actor: ACTOR, expectedVersion: pl?.version ?? 0 }, ); @@ -1195,7 +1218,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.create( article.id, "pl", - { title: "Wersja robocza" } as never, + { title: "Wersja robocza" }, { actor: ACTOR }, ); @@ -1243,7 +1266,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const moved = await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1270,7 +1293,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const outcome = await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); if (!outcome) throw new Error("update returned nothing"); @@ -1324,7 +1347,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.create( article.id, "pl", - { title: "Wersja robocza" } as never, + { title: "Wersja robocza" }, { actor: ACTOR }, ); @@ -1393,7 +1416,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( article.id, "en", - { seo: { description: "English summary", title: "English SEO" } } as never, + { + seo: { description: "English summary", title: "English SEO" }, + }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1437,7 +1462,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const outcome = await categoryContent .service(context) - .create({ name: "Guides" } as never); + .create({ name: "Guides" }); expect(outcome).toBeTruthy(); const [rows] = await sql<{ count: number }[]>` @@ -1450,7 +1475,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { it("reports no delivery outcome on its mutations", async () => { const outcome = await categoryContent .service(context) - .create({ name: "News two" } as never); + .create({ name: "News two" }); expect(outcome).not.toHaveProperty("delivery"); }); From aca76b50ed7c6df7ed70085a98d6f8342bc8948a Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:23:46 +0200 Subject: [PATCH 098/123] fix(content): resolve two delivery correctness bugs found in review **Alternates were silently empty on the public resolve route.** `findBySlug` returns the public *projection*, and a content type that withholds `"id"` has no identifier in it - so `resolveSlug` could not enumerate a record's published translations and answered with an empty `hreflang` set. That is the worst shape a bug can take here: an empty `hreflang` looks exactly like a record with one translation, so it is invisible in the AdminCP and wrong on every page. Since alternates are resolved by identifier and there is no honest way to recover one from a projection that omits it, a **localized** content type with `delivery` now has to expose `"id"` - refused at definition time rather than left as a quiet gap. A nonlocalized content type has no alternates to resolve and needs nothing, so its `itemId` may still be `null`. **The two halves of a resolution could disagree about the locale.** `findBySlug` and `findById` resolve `defaultLocale` internally when the caller names none, while the history lookup treated "no locale" as the *shared* rows - which a localized content type never has. So a service call omitting the locale searched `en` for the live record and found nothing for the redirect. Both halves now resolve the same language. Each fix has a PostgreSQL test that fails without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../content-delivery-limitations.mdx | 9 ++-- .../dev/content-engine/content-delivery.mdx | 9 +++- .../localization-and-hreflang.mdx | 9 ++++ packages/vitnode/src/content/delivery.test.ts | 43 ++++++++++++++++++- packages/vitnode/src/content/delivery.ts | 15 +++++++ .../src/content/server/delivery-service.ts | 24 ++++++++--- .../example/src/content/advanced-article.ts | 4 ++ .../src/database/advanced-routes.test.ts | 3 ++ .../src/database/delivery-postgres.test.ts | 43 +++++++++++++++++++ 9 files changed, 148 insertions(+), 11 deletions(-) diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx index 23a85f327..aea4c83b7 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -133,16 +133,19 @@ different `seo.title` changes the derived metadata on the next read. There is no SEO history, and there will not be one: two revision systems over the same values is two things to keep in sync. -## itemId can be absent +## itemId can be absent on a nonlocalized content type -Delivery metadata reports `itemId: null` for a content type whose `publicApi.fields` -withholds `"id"`. +Delivery metadata reports `itemId: null` for a **nonlocalized** content type whose +`publicApi.fields` withholds `"id"`. That is deliberate rather than a gap: delivery reads the **public projection**, so it cannot report a column the public API declined to publish. Expose `"id"` in the allowlist and it is always present. A sitemap entry always carries it, because a sitemap row is built from the row rather than from the projection. +A **localized** content type has to expose `"id"` - alternates are resolved by +identifier - so its `itemId` is never `null`. + ## Not in Stage 8 at all For the avoidance of doubt: no domain management, no CDN configuration, no content diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx index 04ae39f38..a66f09af2 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx @@ -173,9 +173,16 @@ being indexable - and that is not a symptom anybody notices. | `sitemap.priority` outside `0`–`1` | Throw | | An unknown `changeFrequency` | Compile error + throw | | `hreflang` without `localization` | Throw | +| A localized content type withholding `"id"` | Throw | | A fallback SEO field with no primary | Throw | -The last one is worth a word: `fallbackTitleField` without `titleField` is a +Two are worth a word. A **localized** content type has to expose `"id"` in +`publicApi.fields`, because alternates and `hreflang` are resolved by identifier and +delivery reads the public projection - so without it every localized response would +carry an empty alternate set, which looks exactly like a record with one translation. +A nonlocalized content type has no alternates to resolve and needs nothing. + +And the last one: `fallbackTitleField` without `titleField` is a configuration that reads as if it does something and does nothing, because the fallback is only consulted when the primary is empty. Naming only the fallback means it is never reached, so the engine tells you to name it as the primary diff --git a/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx index a91a6267c..4b58fa8e0 100644 --- a/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx +++ b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx @@ -35,6 +35,15 @@ public API would refuse to serve. Ordered by locale, so two processes rendering the same `hreflang` set produce the same markup. +<Callout type="warn" title="A localized content type must expose id"> + Alternates are resolved **by identifier** - the query enumerates a record's published + translations - and delivery reads the public projection, so a localized content type + that withholds `"id"` from `publicApi.fields` is a definition-time error. Without it + every localized response would carry an empty alternate set, which looks exactly like + a record with one translation. A nonlocalized content type has no alternates and needs + nothing. +</Callout> + ## Fallback never fabricates an alternate This is the rule to internalise: diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts index 0386ad949..41f80fe93 100644 --- a/packages/vitnode/src/content/delivery.test.ts +++ b/packages/vitnode/src/content/delivery.test.ts @@ -308,6 +308,45 @@ describe("delivery definition validation", () => { ).toThrow(/needs a localized slug field/); }); + it("refuses a localized content type that withholds id", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.no-id", + delivery: { enabled: true }, + fields: { + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + // No `id`, so alternates and `hreflang` could not be resolved - and an + // empty `hreflang` looks exactly like a record with one translation. + fields: ["title", "slug"], + path: "articles", + }, + tableName: "delivery_no_id", + }), + ).toThrow(/needs "id" in publicApi.fields/); + }); + + it("does not require id of a nonlocalized content type", () => { + // It has no alternates to resolve, so there is nothing the identifier is needed + // for - `itemId` simply comes back `null` on its delivery metadata. + const withoutId = defineContentType({ + ...base, + id: "delivery.no-id-flat", + delivery: { enabled: true }, + fields, + publicApi: { enabled: true, fields: ["title", "slug"], path: "articles" }, + tableName: "delivery_no_id_flat", + }); + + expect(withoutId.delivery.enabled).toBe(true); + expect(withoutId.publicApi.fields).not.toContain("id"); + }); + it("refuses a localized noIndexField", () => { expect(() => defineContentType({ @@ -330,7 +369,9 @@ describe("delivery definition validation", () => { localization: { defaultLocale: "en", enabled: true }, publicApi: { enabled: true, - fields: ["title", "slug", "flags.noIndex"], + // `id` because a localized delivery content type has to expose it - see + // "refuses a localized content type that withholds id" below. + fields: ["id", "title", "slug", "flags.noIndex"], path: "articles", }, tableName: "delivery_localized_noindex", diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts index e54cbd1f4..fe4004579 100644 --- a/packages/vitnode/src/content/delivery.ts +++ b/packages/vitnode/src/content/delivery.ts @@ -279,6 +279,21 @@ export const resolveContentDelivery = ({ } const exposed = new Set(publicApi.fields); + + // Alternates and `hreflang` are resolved by identifier - the query enumerates a + // record's published translations - and delivery reads the **public projection**, + // so a localized content type that withholds `id` would silently produce an empty + // `hreflang` set from `resolveSlug`. Refused loudly here rather than left as a + // quiet gap: an empty `hreflang` looks exactly like a record with one translation. + // + // Not required of a nonlocalized content type, which has no alternates to resolve. + if (localization.enabled && !exposed.has("id")) { + throw new ContentEngineError( + 'delivery on a localized content type needs "id" in publicApi.fields. Alternates and `hreflang` are resolved by identifier, and delivery reads the public projection - so without it every localized response would carry an empty alternate set.', + { contentTypeId: id }, + ); + } + const seo = delivery.seo ?? {}; for (const [label, name] of [ diff --git a/packages/vitnode/src/content/server/delivery-service.ts b/packages/vitnode/src/content/server/delivery-service.ts index 52d36be37..af3c7e2b4 100644 --- a/packages/vitnode/src/content/server/delivery-service.ts +++ b/packages/vitnode/src/content/server/delivery-service.ts @@ -319,12 +319,27 @@ export const createContentDeliveryService = < : contentDeliveryPath({ definition, locale: served, slug }); }; + /** + * The language a read is *actually* for. + * + * The default locale when the caller named none, because that is what the public + * service resolves internally - and the history lookup has to be about the same + * language, or the live branch would search `en` while the redirect branch searched + * the shared rows and found nothing. + */ + const localeFor = (locale: string | undefined): null | string => { + if (!localized) return null; + + return normalizeContentLocale( + locale ?? definition.localization.defaultLocale, + ); + }; + const resolve = async ( slug: string, { locale, origin }: ContentDeliveryReadOptions = {}, ): Promise<ContentDeliveryResolution> => { - const requestedLocale = - localized && locale !== undefined ? normalizeContentLocale(locale) : null; + const requestedLocale = localeFor(locale); // The live record first, and strictly by slug: a URL belongs to the language // it was published under, so `findBySlug` never falls back. @@ -382,10 +397,7 @@ export const createContentDeliveryService = < return await metadataFor(row, { itemId, origin, - requestedLocale: - localized && locale !== undefined - ? normalizeContentLocale(locale) - : null, + requestedLocale: localeFor(locale), }); }, diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index 665972814..987fadbb2 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -145,6 +145,10 @@ export const advancedArticleContentType = defineContentType({ enabled: true, path: "advanced-articles", fields: [ + // Exposed because Stage 8 needs it: alternates and `hreflang` are resolved by + // identifier, and delivery reads the public projection - so a localized + // delivery content type that withheld `id` would carry an empty alternate set. + "id", "title", "slug", "categories", diff --git a/plugins/example/src/database/advanced-routes.test.ts b/plugins/example/src/database/advanced-routes.test.ts index 9e1443cff..629369630 100644 --- a/plugins/example/src/database/advanced-routes.test.ts +++ b/plugins/example/src/database/advanced-routes.test.ts @@ -99,6 +99,9 @@ describe("advanced article: generated routes", () => { expect(Object.keys(shape).sort()).toStrictEqual([ "categories", "faq", + // Stage 8: a localized delivery content type has to expose `id`, because + // alternates and `hreflang` are resolved by identifier. + "id", "locale", "publishedAt", "seo", diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index faebf1259..51a409381 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -1203,6 +1203,49 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { expect(rows.count).toBe(2); }); + it("carries the alternates through resolveSlug, not only findById", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const resolution = await advancedDelivery()?.resolveSlug("hello-world", { + locale: "en", + }); + + // The public resolve route is what a frontend calls, so an empty `hreflang` + // here would be invisible in the AdminCP and wrong on every page. It is only + // possible because the content type exposes `id` - which `delivery` requires + // of a localized content type for exactly this reason. + expect(resolution).toMatchObject({ + itemId: article.id, + type: "content", + }); + expect( + resolution?.type === "content" ? resolution.alternates : [], + ).toStrictEqual([ + { locale: "en", path: "/en/advanced-articles/hello-world" }, + { locale: "pl", path: "/pl/advanced-articles/witaj" }, + ]); + }); + + it("resolves the default locale when the caller names none", async () => { + const article = await publishLocalized(); + await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + // The public read resolves `defaultLocale` internally when no locale is given, + // so the history lookup has to be about the same language - otherwise the live + // branch would search `en` while the redirect branch searched the shared rows + // and found nothing. + expect(await advancedDelivery()?.resolveSlug("hello-world")).toStrictEqual({ + location: "/en/advanced-articles/hello-there", + status: 308, + type: "redirect", + }); + }); + it("lists only real published translations as alternates", async () => { const article = await publishLocalized({ pl: "Witaj" }); From a74ea04f8a6d65d4f3e63ca95249e8102f8681f7 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:25:26 +0200 Subject: [PATCH 099/123] style(example): satisfy the plugin lint in the new delivery assertions Formatting and one redundant type assertion in the two tests added for the review fixes. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- plugins/example/src/database/delivery-postgres.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index 51a409381..9cf061ab8 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -1231,7 +1231,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1239,7 +1239,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // so the history lookup has to be about the same language - otherwise the live // branch would search `en` while the redirect branch searched the shared rows // and found nothing. - expect(await advancedDelivery()?.resolveSlug("hello-world")).toStrictEqual({ + expect( + await advancedDelivery()?.resolveSlug("hello-world"), + ).toStrictEqual({ location: "/en/advanced-articles/hello-there", status: 308, type: "redirect", From 616b15e0fc81d440a04a10d54a896fa5ec5fca5e Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:34:43 +0200 Subject: [PATCH 100/123] fix(content): make delivery paths a globally unique namespace Two namespaces, not one, and the asymmetry is the whole fix. A generated API route is `/api/{pluginId}/content/{path}`, so two plugins publishing `articles` do not collide - Stage 1-7 deliberately allows it, and forbidding it would fail an app's boot over a name neither author can see. A **canonical delivery URL** is `/articles/{slug}` with no plugin id in it at all, so the same pair really would give one public URL two owners: two resolvers claiming it, two sitemaps listing it, and one slug reservation table with no way to say whose a retired address was. `byDeliveryPath` is therefore a second map keyed by the path alone, consulted only for a content type with `delivery.enabled`. A non-delivery route reserves nothing site-wide, so a delivery-enabled `articles` elsewhere is still free to take it. The fix is the check rather than a prefix: putting the plugin id in canonical URLs would solve the ambiguity by making every public content URL uglier for everybody. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/registry.test.ts | 190 ++++++++++++++++++ packages/vitnode/src/content/registry.ts | 37 ++++ 2 files changed, 227 insertions(+) diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index e7ec20307..64afd614c 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -593,3 +593,193 @@ describe("generated database identifiers", () => { ).not.toThrow(); }); }); + +/** + * Delivery paths are a **site-wide** namespace, unlike the API paths above. + * + * The asymmetry is the whole of this block. A generated API route is + * `/api/{pluginId}/content/{path}`, so two plugins publishing `articles` do not + * collide and Stage 1-7 deliberately allows it. A canonical delivery URL is + * `/articles/{slug}` with no plugin id in it at all, so the same pair really would + * give one public URL two owners: two resolvers claiming it, two sitemaps listing it, + * and one slug reservation table with no way to say whose a retired address was. + */ +describe("delivery paths", () => { + const deliveryWidget = ( + id: string, + tableName: string, + path: string, + { delivery = true }: { delivery?: boolean } = {}, + ) => + defineContentType({ + id, + tableName, + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + path, + fields: ["id", "title", "slug"], + }, + ...(delivery ? { delivery: { enabled: true } } : {}), + admin: { + label: { plural: "Widgets", singular: "Widget" }, + // Distinct, so the permission-module check does not fire first and mask the + // one this block is about. + permissionModule: tableName, + }, + }); + + it("still lets two plugins share a path when neither has delivery", () => { + // The Stage 1-7 promise, restated here so a future delivery change cannot + // quietly turn the API namespace into a global one. + expect(() => + validateContentTypes([ + entry( + deliveryWidget("first.one", "first_ones", "articles", { + delivery: false, + }), + "@acme/one", + ), + entry( + deliveryWidget("second.one", "second_ones", "articles", { + delivery: false, + }), + "@acme/two", + ), + ]), + ).not.toThrow(); + }); + + it("rejects two plugins claiming the same delivery path", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ), + ]), + ).toThrow(ContentEngineError); + }); + + it("names both conflicting owners", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ), + ]), + ).toThrow( + /Delivery path "articles" is claimed by both @acme\/blog -> blog\.article and @acme\/news -> news\.article/, + ); + }); + + it("says why the namespace is global", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ), + ]), + ).toThrow(/site-wide public namespaces and must be globally unique/); + }); + + it("accepts different delivery paths across plugins", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "news"), + "@acme/news", + ), + ]), + ).not.toThrow(); + }); + + it("rejects two content types in one plugin claiming one delivery path", () => { + // The per-plugin API check fires first here, which is correct - both rules are + // violated, and the one that names the narrower fix wins. + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("blog.news", "blog_news", "articles"), + "@acme/blog", + ), + ]), + ).toThrow(ContentEngineError); + }); + + it("does not let a non-delivery route reserve the site namespace", () => { + // A plugin whose `articles` route has no delivery claims nothing site-wide, so a + // delivery-enabled `articles` elsewhere is still free to take it. + expect(() => + validateContentTypes([ + entry( + deliveryWidget("plain.one", "plain_ones", "articles", { + delivery: false, + }), + "@acme/plain", + ), + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + ]), + ).not.toThrow(); + }); + + it("rejects the mixed case whichever order the two arrive in", () => { + const delivered = () => + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ); + const other = () => + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ); + + expect(() => validateContentTypes([delivered(), other()])).toThrow( + ContentEngineError, + ); + expect(() => validateContentTypes([other(), delivered()])).toThrow( + ContentEngineError, + ); + }); + + it("leaves one delivery-enabled content type alone", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + ]), + ).not.toThrow(); + }); +}); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index ba49e703d..05230e15b 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -133,6 +133,10 @@ const physicalIndexes = ( * table name, or two content types resolving to the same Postgres index name. * Permission modules and public paths are checked per plugin, because the * plugin id is part of the key each one is addressed by. + * + * **Delivery paths are the one exception**, and the asymmetry is deliberate: an API + * route carries the plugin id and a canonical delivery URL does not, so the second is + * a site-wide namespace where the first is not. See `byDeliveryPath` below. */ export const validateContentTypes = ( entries: RegisteredContentType[], @@ -141,6 +145,18 @@ export const validateContentTypes = ( const byTable = new Map<string, TableOwner>(); const byPermission = new Map<string, RegisteredContentType>(); const byPublicPath = new Map<string, RegisteredContentType>(); + /** + * Delivery paths, keyed by the path alone. + * + * A **second** map rather than a different key on `byPublicPath`, because the two + * namespaces are genuinely different and both have to be checked. A generated API + * route is `/api/{pluginId}/content/{path}`, so `plugin-a` and `plugin-b` may both + * publish `articles` - and forbidding that would make an app fail to boot over a + * name neither author can see. A **canonical delivery URL** is `/articles/{slug}` + * with no plugin id in it at all, so the same pair really would claim one site-wide + * namespace and `/articles/example` would have two owners. + */ + const byDeliveryPath = new Map<string, RegisteredContentType>(); const byIndexName = new Map<string, IndexOwner>(); for (const entry of entries) { @@ -204,6 +220,27 @@ export const validateContentTypes = ( ); } byPublicPath.set(pathKey, entry); + + // Delivery is the exception, and only delivery. Its canonical URLs are + // framework-neutral **site** paths - `/articles/my-article`, + // `/pl/articles/moj-artykul` - built from `publicApi.path` with no plugin id in + // them, so two delivery-enabled content types sharing a path would give + // `/articles/example` two owners: two resolvers claiming one URL, two sitemaps + // listing it, and one slug reservation table with no way to say which of them a + // retired address belonged to. + // + // The fix is the check, not a prefix: adding the plugin id to the URL would + // solve the ambiguity by making every public content URL uglier for everybody. + if (definition.delivery.enabled) { + const duplicateDeliveryPath = byDeliveryPath.get(path); + if (duplicateDeliveryPath) { + throw new ContentEngineError( + `Delivery path "${path}" is claimed by both ${describe(duplicateDeliveryPath)} and ${describe(entry)}. Delivery paths are site-wide public namespaces and must be globally unique - give one of them a different \`publicApi.path\`, or turn \`delivery\` off on one of them.`, + { contentTypeId: definition.id }, + ); + } + byDeliveryPath.set(path, entry); + } } // `resolveContentIndexes` already rejects a collision inside one content From c375de41c90f15041e13a396799df7f35e5a3cd2 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:34:43 +0200 Subject: [PATCH 101/123] fix(content): require editorial for delivery.redirects Slug history is written by `applyContentDeliveryWrite`, and the only two callers are `editorial-service` and `translation-editorial-service` - because the reservation has to commit or roll back with the slug mutation, its version check and its revision. A content type without `editorial` writes through the plain repository, which has no version to guard and no history to write, so `redirects: { enabled: true }` there was a feature that silently recorded nothing. Now a compile error *and* a definition-time throw. Refused rather than downgraded to `redirects: { enabled: false }`: an author who asked for redirects and quietly got none would find out from a broken link months later. The restriction is narrow on purpose. Canonical URLs, SEO, alternates, `hreflang`, the sitemap and every delivery read are projections over data the content type already has, and all of them stay available without `editorial` - which is what keeps Stage 5's "publication and localization without editorial" promise intact. The same rule covers a localized content type, whose localized history has the same missing write path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/define.ts | 4 + .../vitnode/src/content/delivery.test-d.ts | 58 ++++++++ packages/vitnode/src/content/delivery.test.ts | 131 ++++++++++++++++++ packages/vitnode/src/content/delivery.ts | 20 +++ .../content/server/delivery-service.test.ts | 2 + packages/vitnode/src/content/types.ts | 20 ++- 6 files changed, 233 insertions(+), 2 deletions(-) diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 0010892a2..9be48f575 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1425,6 +1425,7 @@ export const defineContentType = < TDelivery extends | ContentDeliveryConfig< TPublicEnabled, + ContentEditorialEnabled<TEditorial>, ContentDeliveryTitleField<TFields, TPublicField>, ContentDeliveryDescriptionField<TFields, TPublicField>, ContentDeliveryNoIndexField<TFields, TPublicField> @@ -1679,6 +1680,9 @@ export const defineContentType = < // The `{ enabled: false }` arm exists only so an explicit literal typechecks - // the same widening `publicApi`, `search`, `editorial` and `localization` do. delivery: delivery as ContentDeliveryConfig | undefined, + // Read off the *resolved* editorial config rather than the argument, so the + // redirect check sees exactly what `resolveEditorial` decided. + editorial: resolvedEditorial.enabled, fields: fieldMap, id, localization: { diff --git a/packages/vitnode/src/content/delivery.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts index 2c6416fe6..29f038812 100644 --- a/packages/vitnode/src/content/delivery.test-d.ts +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -55,6 +55,7 @@ const shared = { const deliveredType = defineContentType({ ...shared, id: "typed.delivered", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true }, @@ -144,6 +145,63 @@ describe("delivery requires a public API", () => { }); }); +describe("redirects require editorial", () => { + it("refuses `redirects: { enabled: true }` without editorial", () => { + defineContentType({ + ...shared, + id: "typed.no-editorial", + delivery: { + enabled: true, + // @ts-expect-error - slug history has to be written in the same transaction + // as the slug mutation and its revision, and only the editorial mutation + // paths own one. Without `editorial` this would record nothing. + redirects: { enabled: true }, + }, + tableName: "typed_no_editorial", + }); + }); + + it("still accepts an explicit `redirects: { enabled: false }`", () => { + const off = defineContentType({ + ...shared, + id: "typed.redirects-off", + delivery: { enabled: true, redirects: { enabled: false } }, + tableName: "typed_redirects_off", + }); + + expectTypeOf(off.delivery.enabled).toEqualTypeOf<true>(); + }); + + it("accepts redirects once editorial is enabled", () => { + const on = defineContentType({ + ...shared, + id: "typed.redirects-on", + editorial: { enabled: true }, + delivery: { enabled: true, redirects: { enabled: true } }, + tableName: "typed_redirects_on", + }); + + expectTypeOf(on.delivery.enabled).toEqualTypeOf<true>(); + }); + + it("leaves every other delivery block available without editorial", () => { + // The rule is narrow on purpose: only slug history needs a transaction. + const reads = defineContentType({ + ...shared, + id: "typed.reads-only", + delivery: { + enabled: true, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "daily", enabled: true, priority: 0.5 }, + }, + tableName: "typed_reads_only", + }); + + expectTypeOf(reads.delivery.enabled).toEqualTypeOf<true>(); + expectTypeOf(reads.editorial.enabled).toEqualTypeOf<false>(); + }); +}); + describe("SEO field references", () => { it("refuses a field the public allowlist withholds", () => { defineContentType({ diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts index 41f80fe93..6b69aaf1f 100644 --- a/packages/vitnode/src/content/delivery.test.ts +++ b/packages/vitnode/src/content/delivery.test.ts @@ -48,6 +48,9 @@ const fields = { const articleType = defineContentType({ ...base, id: "delivery.article", + // `redirects` needs `editorial`: slug history has to be written in the same + // transaction as the slug mutation and its revision. + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true }, @@ -291,6 +294,7 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.shared-slug", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { body: field.textarea({ localized: true, required: true }), @@ -308,6 +312,132 @@ describe("delivery definition validation", () => { ).toThrow(/needs a localized slug field/); }); + it("refuses redirects without editorial", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.no-editorial", + // No `editorial`, so the only mutation path is the plain repository - which + // has no version to guard and no history to write. Accepting this would be + // accepting a redirect feature that silently records nothing. + delivery: { + enabled: true, + redirects: { enabled: true as never }, + }, + fields, + publicApi, + tableName: "delivery_no_editorial", + }), + ).toThrow(/delivery.redirects needs `editorial/); + }); + + it("refuses localized redirects without editorial", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.localized-no-editorial", + delivery: { + enabled: true, + redirects: { enabled: true as never }, + }, + fields: { + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "delivery_localized_no_editorial", + }), + ).toThrow(/delivery.redirects needs `editorial/); + }); + + it("accepts redirects with editorial", () => { + const withEditorial = defineContentType({ + ...base, + id: "delivery.with-editorial", + editorial: { enabled: true }, + delivery: { enabled: true, redirects: { enabled: true } }, + fields, + publicApi, + tableName: "delivery_with_editorial", + }); + + expect(withEditorial.delivery.redirects.enabled).toBe(true); + }); + + it("accepts delivery without redirects and without editorial", () => { + // Everything except slug history is a read over data the content type already + // has, so none of it needs a transactional mutation path. + const withoutEditorial = defineContentType({ + ...base, + id: "delivery.reads-only", + delivery: { + enabled: true, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }, + fields, + publicApi, + tableName: "delivery_reads_only", + }); + + expect(withoutEditorial.delivery).toMatchObject({ + enabled: true, + redirects: { enabled: false }, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }); + }); + + it("accepts localized delivery reads without editorial", () => { + // Stage 5 supports publication and localization without editorial, and Stage 8 + // must not take that away - only `redirects` needs the extra dependency. + const localizedReads = defineContentType({ + ...base, + id: "delivery.localized-reads", + delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + seo: { fallbackTitleField: "title", titleField: "seo.title" }, + sitemap: { enabled: true }, + }, + fields: { + seo: field.group({ + fields: { title: field.text({ nullable: true }) }, + localized: true, + nullable: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "seo.title"], + path: "articles", + }, + tableName: "delivery_localized_reads", + }); + + expect(localizedReads.delivery).toMatchObject({ + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + redirects: { enabled: false }, + sitemap: { enabled: true }, + slugScope: "localized", + }); + }); + + it("leaves a content type without delivery untouched by the rule", () => { + // No `editorial`, no `delivery` - the Stage 1-7 shape, still accepted. + expect(plainType.delivery.enabled).toBe(false); + expect(plainType.editorial.enabled).toBe(false); + }); + it("refuses a localized content type that withholds id", () => { expect(() => defineContentType({ @@ -383,6 +513,7 @@ describe("delivery definition validation", () => { const localizedType = defineContentType({ ...base, id: "delivery.localized", + editorial: { enabled: true }, delivery: { enabled: true, hreflang: { xDefault: "defaultLocale" }, diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts index fe4004579..629eb56fd 100644 --- a/packages/vitnode/src/content/delivery.ts +++ b/packages/vitnode/src/content/delivery.ts @@ -179,6 +179,7 @@ const assertSeoField = ({ */ export const resolveContentDelivery = ({ delivery, + editorial, fields, id, localization, @@ -187,6 +188,8 @@ export const resolveContentDelivery = ({ publication, }: { delivery: ContentDeliveryConfig | undefined; + /** Whether the content type opted into the editorial workflow. */ + editorial: boolean; fields: ContentFieldMap; id: string; localization: { defaultLocale: string; enabled: boolean }; @@ -217,6 +220,23 @@ export const resolveContentDelivery = ({ const slugScope = localizedFields[slugField] === undefined ? "shared" : "localized"; + // Slug history has to be written in the same transaction as the slug mutation, the + // version check and the revision - and the only mutation paths that own such a + // transaction are `editorial-service` and `translation-editorial-service`. Without + // `editorial` a content type writes through the plain repository, which has neither + // a version to guard nor a history to write, so accepting this would be accepting a + // feature that records nothing. + // + // Refused rather than downgraded to `redirects: { enabled: false }`: an author who + // asked for redirects and silently got none would find out from a broken link + // months later. The type system refuses it too - see `ContentDeliveryConfig`. + if (redirects && !editorial) { + throw new ContentEngineError( + "delivery.redirects needs `editorial: { enabled: true }`. Redirect history has to be written atomically with the slug mutation and its version and revision, and only the editorial mutation paths own that transaction. Delivery without `redirects` - canonical URLs, SEO, alternates and the sitemap - works without editorial.", + { contentTypeId: id }, + ); + } + // A localized content type whose slug is *shared* has one URL segment and several // URLs - `/en/articles/hello` and `/pl/articles/hello` are both live, and a slug // change moves all of them at once. Slug history stores the URL that was live, so diff --git a/packages/vitnode/src/content/server/delivery-service.test.ts b/packages/vitnode/src/content/server/delivery-service.test.ts index 0d7835ca8..9dfa0210a 100644 --- a/packages/vitnode/src/content/server/delivery-service.test.ts +++ b/packages/vitnode/src/content/server/delivery-service.test.ts @@ -29,6 +29,7 @@ const PLUGIN = "@vitnode/test"; const articleType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "delivery.article", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true }, @@ -69,6 +70,7 @@ const withoutRedirects = defineContentType({ const localizedType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "delivery.localized", + editorial: { enabled: true }, delivery: { enabled: true, hreflang: { xDefault: "defaultLocale" }, diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 3d406ae86..3e3d89abe 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1324,11 +1324,12 @@ export interface ContentDeliveryHreflangConfig { * silently resolve to "no delivery". */ export interface ContentDeliveryConfig< - // Defaults to `true` rather than `boolean`, which is what keeps the bare + // Both flags default to `true` rather than `boolean`, which is what keeps the bare // `ContentDeliveryConfig` usable as a widened parameter type: `boolean extends // true` is false, so a `boolean` default would resolve `enabled` to `never` and // make the erased form describe a config nobody can write. TPublicEnabled extends boolean = true, + TEditorialEnabled extends boolean = true, TTitle extends string = string, TDescription extends string = string, TNoIndex extends string = string, @@ -1343,8 +1344,23 @@ export interface ContentDeliveryConfig< */ enabled: TPublicEnabled extends true ? true : never; hreflang?: ContentDeliveryHreflangConfig; + /** + * Gated on **editorial** as well as on the public API, and the second gate is not + * a taste decision: slug history has to be written in the same transaction as the + * slug mutation, the version check and the revision - and the only mutation paths + * that own such a transaction are the editorial ones. Without `editorial` a + * content type writes through the plain repository, which has no version to guard + * and no history to write, so `redirects: { enabled: true }` there would be a + * feature that silently records nothing. + * + * Only `redirects` is gated. Canonical URLs, SEO, alternates, `hreflang` and the + * sitemap are all reads over data the content type already has, and they remain + * available without `editorial`. + */ redirects?: TPublicEnabled extends true - ? ContentDeliveryRedirectsConfig | { enabled: false } + ? TEditorialEnabled extends true + ? ContentDeliveryRedirectsConfig | { enabled: false } + : { enabled: false } : { enabled: false }; seo?: ContentDeliverySeoConfig<TTitle, TDescription, TNoIndex>; sitemap?: ContentDeliverySitemapConfig | { enabled: false }; From 27a4af1816489882c9251aca2e0a091a7cb74e07 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:03 +0200 Subject: [PATCH 102/123] fix(content): expire the sitemap whenever its lastModified moves A sitemap entry carries `<lastmod>`, derived from `updatedAt`. The invalidation logic treated "the sitemap changed" as "URL membership changed", so a plain title or SEO edit on a published record left a cached sitemap serving a timestamp that was no longer true - for as long as the tag lived. `sitemapChanged: boolean` becomes `sitemap: { contentChanged, indexChanged }`, because the two cache different documents: - **`contentChanged`** - this locale's sitemap **file** is no longer byte-identical. True for any real mutation of a record that is or was publicly reachable, whether what moved was a URL, a title or an SEO field. - **`indexChanged`** - the set of files, or how many of them there are, moved. True only when public reachability flipped. A title edit rewrites a timestamp inside an existing file and a slug change rewrites one line: neither changes which files exist. For a nonlocalized content type the locale-less tag *is* its one file, so `contentChanged` expires it. For a localized one that tag is the index of its per-locale files, which is why an ordinary edit must not touch it. The nonlocalized Server Action decides `contentChanged` by comparing `updatedAt` across the write - not a proxy for "did the sitemap change" but *the value the sitemap serializes*, so the two move together by construction. It answers "was this a no-op" for free: the engine issues no `UPDATE` for an update that changed nothing. The localized path reuses the Stage 5 locale fan-out rather than inventing a second propagation rule: a shared edit reaches every locale because the base timestamp is in `max(base, translation)` for all of them, and a translation edit reaches its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/cache.ts | 55 ++++++--- packages/vitnode/src/content/index.ts | 1 + .../src/content/server/delivery-effects.ts | 10 +- .../src/content/server/delivery-writes.ts | 26 ++++- .../src/content/server/schedule-effects.ts | 11 +- .../content/actions/mutation-api.server.ts | 106 +++++++++++++----- .../content/actions/public-locale-cache.ts | 29 +++-- 7 files changed, 175 insertions(+), 63 deletions(-) diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index 3c1994dfe..cc09755e7 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -167,15 +167,35 @@ export interface ContentLocaleInvalidation { * keeps the public tags and the delivery tags from disagreeing about what moved. */ export interface ContentDeliveryInvalidation { - /** - * Whether the set of URLs in the sitemap changed. - * - * `true` for a publish, an unpublish, a delete, a slug change and a translation - * appearing or disappearing - every mutation that adds, removes or moves a line - * in the file. `false` for an edit that only changed what an already-listed page - * says, which leaves the sitemap byte-identical. - */ - sitemap: boolean; + /** What this mutation did to the sitemap. See {@link ContentSitemapChange}. */ + sitemap: ContentSitemapChange; +} + +/** + * How one mutation changed a sitemap, split into the two things a tag can cache. + * + * One boolean is not enough, and the reason is `<lastmod>`. A sitemap entry carries + * `lastModified`, derived from `updatedAt` - so a plain title edit on a published + * record changes the **bytes** of that locale's sitemap file even though the set of + * URLs in it is identical. Treating "the sitemap changed" as "membership changed" + * leaves a cached file serving a stale `<lastmod>` for as long as the tag lives. + * + * The two are separate because they cache different documents: + * + * - **`contentChanged`** - the sitemap *file* for this locale is no longer + * byte-identical. True for any real mutation of a record that is or was publicly + * reachable, whether what moved was a URL, a title or an SEO field. + * - **`indexChanged`** - the set of sitemap files, or how many of them there are, + * moved. True only when public reachability flipped, because an index lists files + * and their count follows the number of URLs. A title edit changes neither. + * + * Declared here rather than next to the write path because `cache.ts` is the + * client-safe layer and must not import from `server/` - the same reason the tag + * builders are plain strings a directory up from Drizzle. + */ +export interface ContentSitemapChange { + contentChanged: boolean; + indexChanged: boolean; } export interface ContentInvalidationInput { @@ -308,19 +328,22 @@ const deliveryTags = ({ .map(slug => contentDeliveryRedirectTag(contentTypeId, slug, entry.locale), ), - ...(delivery.sitemap + // The sitemap *file* this locale is listed in. For a content type that is not + // localized `entry.locale` is `undefined`, so this is the locale-less tag - which + // is that content type's only sitemap file rather than an index of files. + ...(delivery.sitemap.contentChanged ? [contentDeliverySitemapTag(contentTypeId, entry.locale)] : []), ]); - // The locale-less sitemap tag as well: a localized content type's sitemap index - // enumerates its per-locale files, so a language gaining or losing a page - // changes the index too. De-duplicated, because a content type that is not - // localized produces only this form and the per-locale line above already - // emitted it - and a tag list is asserted in tests as well as iterated. + // The locale-less tag on its own means the *index* of a localized content type's + // per-locale files, so it is expired only when the set of files or their count + // moved - never for a title edit, which rewrites bytes inside one existing file. + // De-duplicated, because a content type that is not localized produces only this + // form and the line above already emitted it. return [ ...new Set( - delivery.sitemap + delivery.sitemap.indexChanged ? [...tags, contentDeliverySitemapTag(contentTypeId)] : tags, ), diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index b1c00d961..eebb070dc 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -48,6 +48,7 @@ export type { ContentLocaleInvalidation, ContentLocaleState, ContentPublicLocaleState, + ContentSitemapChange, } from "./cache"; export { parseContentConflict, diff --git a/packages/vitnode/src/content/server/delivery-effects.ts b/packages/vitnode/src/content/server/delivery-effects.ts index 07e3e9ab7..acab0d012 100644 --- a/packages/vitnode/src/content/server/delivery-effects.ts +++ b/packages/vitnode/src/content/server/delivery-effects.ts @@ -121,6 +121,12 @@ export const contentDeliveryInvalidation = ( // A content type with delivery whose mutation reported nothing still expires its // delivery metadata - a shared SEO field moving changes what every locale's - // `<head>` renders even though no URL moved. Only the sitemap is conditional. - return { sitemap: delivery?.sitemapChanged ?? false }; + // `<head>` renders even though no URL moved. Only the sitemap is conditional, and + // an absent outcome means the mutation touched no slug-bearing path at all. + return { + sitemap: delivery?.sitemap ?? { + contentChanged: false, + indexChanged: false, + }, + }; }; diff --git a/packages/vitnode/src/content/server/delivery-writes.ts b/packages/vitnode/src/content/server/delivery-writes.ts index 968ec866f..551f6ce75 100644 --- a/packages/vitnode/src/content/server/delivery-writes.ts +++ b/packages/vitnode/src/content/server/delivery-writes.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; +import type { ContentSitemapChange } from "../cache"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDatabase } from "./service"; import type { ContentSlugHistoryModel } from "./slug-history-model"; @@ -36,8 +37,15 @@ export interface ContentDeliveryOutcome { * moved". It is what the `delivery_redirect_created` event is gated on. */ redirectCreated: boolean; - /** Whether the set of URLs a sitemap lists changed. */ - sitemapChanged: boolean; + /** + * What this mutation did to the sitemap. + * + * Two booleans rather than one, because a sitemap entry carries a `<lastmod>` + * derived from `updatedAt`: a plain title edit on a published record changes the + * file's bytes without changing which URLs it lists. See + * {@link ContentSitemapChange}. + */ + sitemap: ContentSitemapChange; /** The slug the record answers to now, or `null` once it is deleted. */ slug: null | string; /** Whether the canonical URL is different from what it was. */ @@ -166,10 +174,16 @@ export const applyContentDeliveryWrite = async ({ previousPath: slugChanged ? previousPath : null, previousSlug: slugChanged ? previousSlug : null, redirectCreated, - // A line is added, removed or moved when public reachability changed or when - // the URL did. An edit that only changed what an already-listed page says - // leaves the sitemap byte-identical. - sitemapChanged: wasPublic !== isPublic || slugChanged, + sitemap: { + // Any real mutation of a record that is or was publicly reachable changes the + // file: it gained a line, lost one, moved one, or moved its own `<lastmod>`. + // This function is only ever reached for a real mutation - a no-op update + // returns before the delivery step - so "was or is public" is the whole test. + contentChanged: wasPublic || isPublic, + // Only appearing or disappearing changes how many files an index lists. A slug + // change rewrites one line inside a file; a title edit rewrites a timestamp. + indexChanged: wasPublic !== isPublic, + }, slug, slugChanged, }; diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index a3c850e4b..05dbf15d3 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -212,10 +212,13 @@ export const runContentScheduleEffects = async ( const revalidation = await dispatchContentRevalidation(c, { contentTypeId: definition.id, - // A scheduled transition always adds or removes a sitemap line, so the delivery - // tags - including the sitemap's - go out with the rest. Absent for a content - // type without `delivery`, which keeps its tag list byte-identical. - ...(definition.delivery.enabled ? { delivery: { sitemap: true } } : {}), + // A scheduled transition always flips public reachability, so it changes both the + // file it adds a line to (or removes one from) and the index that counts them. + // Absent for a content type without `delivery`, which keeps its tag list + // byte-identical. + ...(definition.delivery.enabled + ? { delivery: { sitemap: { contentChanged: true, indexChanged: true } } } + : {}), id: payload.itemId, isPublic: isContentRowPublic(row), // A scheduled transition moves the *record*, and the record's publication 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 16ea7ad4b..806c2d441 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 @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import type { ContentPublicLocaleState } from "@/content/cache"; +import type { ContentDeliveryInvalidation } from "@/content/cache"; import type { ContentConflict, ContentDeliveryConflict, @@ -202,7 +203,7 @@ const invalidate = ( revalidateContent( { contentTypeId: definition.id, - ...deliveryInvalidationFor(definition, previous, current), + ...deliveryInvalidationFor(definition, before, after, previous, current), id, isPublic: current.isPublic, // Both, so a slug change stops the old URL and starts the new one. @@ -216,28 +217,67 @@ const invalidate = ( /** * The delivery half of a nonlocalized mutation's invalidation. * - * `{}` for a content type without `delivery`, so spreading it leaves the input - - * and therefore the tag list - exactly as it was. A sitemap line is added, removed - * or moved when public reachability changed or when the URL did, which is the same - * rule `applyContentDeliveryWrite` reports from inside the transaction; stated twice - * because the Server Action cannot see the outcome, only the two rows. + * `{}` for a content type without `delivery`, so spreading it leaves the input - and + * therefore the tag list - exactly as it was. + * + * `contentChanged` is decided by comparing `updatedAt` across the write, which is not + * a proxy for "did the sitemap change" but *the value the sitemap serializes*: a + * sitemap entry's `<lastmod>` is `base.updatedAt`, so the two move together by + * construction. It also answers "was this a no-op" for free - the engine issues no + * `UPDATE` for an update that changed nothing, so the timestamp does not move and the + * cached sitemap is still correct. + * + * `indexChanged` is public reachability flipping, and nothing else: an index lists + * files and counts URLs, so a slug change or a title edit leaves it alone. */ const deliveryInvalidationFor = ( definition: AnyContentTypeDefinition, - previous: { isPublic: boolean; slug: string }, - current: { isPublic: boolean; slug: string }, -): { delivery?: { sitemap: boolean } } => - definition.delivery.enabled - ? { - delivery: { - sitemap: - previous.isPublic !== current.isPublic || - (previous.slug !== "" && - current.slug !== "" && - previous.slug !== current.slug), - }, - } - : {}; + before: ContentRow | undefined, + after: ContentRow | undefined, + previous: { isPublic: boolean }, + current: { isPublic: boolean }, +): { delivery?: ContentDeliveryInvalidation } => { + if (!definition.delivery.enabled) return {}; + + const reachable = previous.isPublic || current.isPublic; + + return { + delivery: { + sitemap: { + contentChanged: reachable && timestampMoved(before, after), + indexChanged: previous.isPublic !== current.isPublic, + }, + }, + }; +}; + +/** + * Whether `updatedAt` moved across a write. + * + * `true` when either side is missing - a create or a delete - because the record + * appeared or disappeared and there is no pair to compare. Unparseable values are + * treated the same way: a cached sitemap that might be stale is worse than a cache + * miss. + */ +const timestampMoved = ( + before: ContentRow | undefined, + after: ContentRow | undefined, +): boolean => { + const at = (row: ContentRow | undefined): null | number => { + const value = row?.updatedAt; + if (value instanceof Date) return value.getTime(); + if (typeof value !== "string") return null; + + const parsed = new Date(value).getTime(); + + return Number.isNaN(parsed) ? null : parsed; + }; + + const first = at(before); + const second = at(after); + + return first === null || second === null || first !== second; +}; export const createContentAction = async ( contentTypeId: string, @@ -635,13 +675,22 @@ export const deleteContentAction = async ( // expiring a URL that is now gone forever costs nothing. const removed = publicStateOf(definition, result.data); + const wasEverPublic = result.data?.publishedAt != null; + revalidateContent( { contentTypeId: definition.id, - // The sitemap has lost a line whenever the record had one, which is exactly - // "was it ever published" - the same question the `wasPublic` below asks. + // A delete removes a line from the file and one URL from the index's count, + // whenever the record had one - which is exactly "was it ever published". ...(definition.delivery.enabled - ? { delivery: { sitemap: result.data?.publishedAt != null } } + ? { + delivery: { + sitemap: { + contentChanged: wasEverPublic, + indexChanged: wasEverPublic, + }, + }, + } : {}), id, isPublic: false, @@ -706,8 +755,15 @@ const publicationAction = async ( revalidateContent( { contentTypeId: definition.id, - // A real transition always adds or removes a sitemap line. - ...(definition.delivery.enabled ? { delivery: { sitemap: true } } : {}), + // A real transition flips reachability, so it moves both the file and the + // index that counts its URLs. + ...(definition.delivery.enabled + ? { + delivery: { + sitemap: { contentChanged: true, indexChanged: true }, + }, + } + : {}), id, isPublic, slugs: [slug], diff --git a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts index 393c31a87..b2cb16a39 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts @@ -102,19 +102,28 @@ export const invalidateContentLocales = ( // The delivery tags, for a content type with `delivery`. Absent otherwise, // which is what keeps a Stage 1-7 content type's tag list byte-identical. // - // The sitemap is expired when a locale gained or lost its page, or when one - // moved its URL - which is exactly what the before/after diff already knows, - // so it is read off the states rather than passed down from the action. + // Derived from the locales this mutation actually **reached**, which is the + // Stage 5 fan-out rather than a second locale-propagation rule: a shared field + // reaches every locale, a translation reaches its own, and `sitemap:pl` is + // expired exactly when Polish's public representation moved. That is also what + // makes a plain title edit expire the right file - the sitemap's `<lastmod>` is + // derived from `updatedAt`, so any real edit to a published translation changes + // that file's bytes even though its URL did not move. ...(definition.delivery.enabled ? { delivery: { - sitemap: states.some( - state => - state.isPublic !== state.wasPublic || - (state.previousSlug !== undefined && - state.previousSlug !== "" && - state.previousSlug !== state.slug), - ), + sitemap: { + // Every reached locale that is or was public has a file whose bytes + // moved. This helper is only called for a real mutation. + contentChanged: reached.some( + entry => entry.isPublic || entry.wasPublic, + ), + // Only a locale appearing or disappearing changes how many files the + // index lists. + indexChanged: reached.some( + entry => entry.isPublic !== entry.wasPublic, + ), + }, }, } : {}), From 8135c5244deb5fdf776961c5f1799be3cb9f32de Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:04 +0200 Subject: [PATCH 103/123] fix(content): carry the delivery tags across the revalidation bridge Found while auditing the scheduled mutation paths. `dispatchContentRevalidation` posts the delivery block, and the web-side route parsed the body with a zod object schema that did not declare it - so it was **stripped**. Every background transition crossed the bridge carrying its delivery tags and arrived with none, leaving a stale sitemap and a stale canonical response behind every scheduled publish and unpublish. Declared explicitly, and optional: an API that has not been redeployed posts the Stage 1-7 shape, and that body still has to be accepted rather than 400. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../next/revalidate-route.server.test.ts | 58 +++++++++++++++++++ .../content/next/revalidate-route.server.ts | 17 ++++++ 2 files changed, 75 insertions(+) diff --git a/packages/vitnode/src/content/next/revalidate-route.server.test.ts b/packages/vitnode/src/content/next/revalidate-route.server.test.ts index bbfadac94..ec2cf551c 100644 --- a/packages/vitnode/src/content/next/revalidate-route.server.test.ts +++ b/packages/vitnode/src/content/next/revalidate-route.server.test.ts @@ -81,6 +81,64 @@ describe("the revalidation Route Handler", () => { ]); }); + it("carries the delivery tags across the bridge", async () => { + // The bug this pins down: `zodBody` strips whatever it does not declare, so a + // missing `delivery` member meant a scheduled publish crossed the bridge with its + // delivery tags and arrived with none - leaving a stale sitemap and a stale + // canonical response behind every background transition. + await POST( + request({ + body: JSON.stringify({ + ...body, + delivery: { sitemap: { contentChanged: true, indexChanged: true } }, + }), + }), + ); + + const tags = calls.map(call => call.tag); + + expect(tags).toContain("content:example.article:delivery:7"); + expect(tags).toContain("content:example.article:redirect:hello-world"); + expect(tags).toContain("content:example.article:sitemap"); + }); + + it("expires no sitemap tag when the bridge says it did not move", async () => { + await POST( + request({ + body: JSON.stringify({ + ...body, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, + }), + }), + ); + + const tags = calls.map(call => call.tag); + + expect(tags).toContain("content:example.article:delivery:7"); + expect(tags).not.toContain("content:example.article:sitemap"); + }); + + it("accepts a body with no delivery member at all", async () => { + // An API that has not been redeployed posts the Stage 1-7 shape, and that body + // still has to be accepted rather than 400. + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(calls.map(call => call.tag)).not.toContain( + "content:example.article:delivery:7", + ); + }); + + it("refuses a malformed delivery member", async () => { + const response = await POST( + request({ + body: JSON.stringify({ ...body, delivery: { sitemap: true } }), + }), + ); + + expect(response.status).toBe(400); + }); + it("honours stale-while-revalidate", async () => { await POST( request({ diff --git a/packages/vitnode/src/content/next/revalidate-route.server.ts b/packages/vitnode/src/content/next/revalidate-route.server.ts index c896939b6..5f65dc587 100644 --- a/packages/vitnode/src/content/next/revalidate-route.server.ts +++ b/packages/vitnode/src/content/next/revalidate-route.server.ts @@ -12,6 +12,23 @@ import { revalidateContent } from "./revalidate.server"; const zodBody = z.object({ contentTypeId: z.string().min(1), + /** + * The delivery share of a mutation, for a content type with `delivery`. + * + * Optional for the same reason `locales` is: an API that has not been redeployed + * posts a body without it, and that body still has to be accepted. Without this + * member the object schema would **strip** it - so a scheduled publish would cross + * the bridge carrying its delivery tags and arrive with none, leaving a stale + * sitemap and a stale canonical response behind every background transition. + */ + delivery: z + .object({ + sitemap: z.object({ + contentChanged: z.boolean(), + indexChanged: z.boolean(), + }), + }) + .optional(), id: z.number().int().positive(), isPublic: z.boolean(), /** From 7b51bf5802650288431ee99969a2c979ee2f52f4 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:26 +0200 Subject: [PATCH 104/123] test(content): cover the sitemap lastModified invariant Exact cache-tag assertions for the rule the fix establishes: a real update to a published representation expires that locale's sitemap file, because its `lastModified` moves - even when the canonical URL does not. `public-locale-cache.test.ts` is new and is the localized half: a PL translation edit expires `sitemap:pl` and not `sitemap:en`; a shared edit expires both, because the base timestamp is in `max(base, translation)` for every language; a draft translation is skipped; and none of them touches the index. It also pins the fallback case explicitly - a default-locale edit reaches a fallback-consuming locale through the Stage 5 fan-out even though that locale contributes no sitemap URL, which is a cache miss rather than staleness. The nonlocalized assertions go through the existing `mutation-api` harness, so they exercise the real `revalidateContent` end to end rather than a mock of it: title edit, SEO-only edit, no-op, draft, slug change, publish, delete, and the Stage 1-7 tag list of a content type without delivery. Every one of them fails against the previous implementation - membership-only invalidation misses five localized cases and three nonlocalized ones, and the bridge misses three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/content/cache.delivery.test.ts | 55 +++- .../content/server/delivery-effects.test.ts | 21 +- .../content/server/delivery-writes.test.ts | 30 +- .../content/actions/mutation-api.test.ts | 170 +++++++++++ .../actions/public-locale-cache.test.ts | 286 ++++++++++++++++++ .../src/database/delivery-postgres.test.ts | 106 +++++++ 6 files changed, 635 insertions(+), 33 deletions(-) create mode 100644 packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts diff --git a/packages/vitnode/src/content/cache.delivery.test.ts b/packages/vitnode/src/content/cache.delivery.test.ts index 7e77ec0fc..d641e3833 100644 --- a/packages/vitnode/src/content/cache.delivery.test.ts +++ b/packages/vitnode/src/content/cache.delivery.test.ts @@ -119,7 +119,7 @@ describe("contentInvalidationTags with delivery", () => { expect( contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 42, isPublic: true, slugs: ["old", "new"], @@ -136,34 +136,39 @@ describe("contentInvalidationTags with delivery", () => { ]); }); - it("adds the sitemap tag only when the set of listed URLs changed", () => { + it("expires the sitemap file whenever its bytes moved", () => { + // A nonlocalized content type's locale-less tag *is* its one sitemap file, so + // `contentChanged` is what expires it - including for a plain title edit, whose + // `<lastmod>` moved even though the URL did not. const withSitemap = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: true }, + delivery: { sitemap: { contentChanged: true, indexChanged: false } }, id: 42, isPublic: true, - slugs: ["new"], - wasPublic: false, + slugs: ["same"], + wasPublic: true, }); expect(withSitemap).toContain(contentDeliverySitemapTag(ID)); - const withoutSitemap = contentInvalidationTags({ + const untouched = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 42, isPublic: true, slugs: ["new"], wasPublic: true, }); - expect(withoutSitemap).not.toContain(contentDeliverySitemapTag(ID)); + expect(untouched).not.toContain(contentDeliverySitemapTag(ID)); }); it("emits the sitemap tag once for a nonlocalized content type", () => { + // `contentChanged` and `indexChanged` name the same tag here, because a + // nonlocalized content type has one file and no index. const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: true }, + delivery: { sitemap: { contentChanged: true, indexChanged: true } }, id: 42, isPublic: true, slugs: ["new"], @@ -178,7 +183,7 @@ describe("contentInvalidationTags with delivery", () => { it("expires each locale's sitemap and the index that lists them", () => { const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: true }, + delivery: { sitemap: { contentChanged: true, indexChanged: true } }, id: 7, isPublic: true, locales: [ @@ -191,15 +196,35 @@ describe("contentInvalidationTags with delivery", () => { expect(tags).toContain(contentDeliverySitemapTag(ID, "en")); expect(tags).toContain(contentDeliverySitemapTag(ID, "pl")); - // The locale-less one too: a localized content type's index enumerates its - // per-locale files, so a language gaining a page changes the index. + // The locale-less one too, because a language gaining a page changes how many + // files the index lists. expect(tags).toContain(contentDeliverySitemapTag(ID)); }); + it("expires a locale's file without its index on an ordinary edit", () => { + // The rule §3.6 asks for: a title edit rewrites bytes inside an existing file and + // changes neither which files exist nor how many. + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: { contentChanged: true, indexChanged: false } }, + id: 7, + isPublic: true, + locales: [ + { isPublic: true, locale: "pl", slugs: ["witaj"], wasPublic: true }, + ], + slugs: [], + wasPublic: true, + }); + + expect(tags).toContain(contentDeliverySitemapTag(ID, "pl")); + expect(tags).not.toContain(contentDeliverySitemapTag(ID)); + expect(tags).not.toContain(contentDeliverySitemapTag(ID, "en")); + }); + it("keeps one locale's delivery tags out of another's", () => { const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 7, isPublic: true, locales: [ @@ -226,7 +251,7 @@ describe("contentInvalidationTags with delivery", () => { expect( contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 1, isPublic: false, slugs: ["a", "b"], @@ -238,7 +263,7 @@ describe("contentInvalidationTags with delivery", () => { it("drops an empty slug rather than tagging a redirect for it", () => { const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 42, isPublic: true, slugs: ["", "new"], diff --git a/packages/vitnode/src/content/server/delivery-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts index 099477e8c..809f88763 100644 --- a/packages/vitnode/src/content/server/delivery-effects.test.ts +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -23,6 +23,7 @@ import { const articleType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "effects.article", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { slug: field.slug({ source: "title" }), @@ -62,7 +63,8 @@ const outcome = ( previousPath: "/articles/old", previousSlug: "old", redirectCreated: true, - sitemapChanged: true, + // A slug change on a published record: the file's bytes moved, the index did not. + sitemap: { contentChanged: true, indexChanged: false }, slug: "new", slugChanged: true, ...overrides, @@ -203,23 +205,24 @@ describe("contentDeliveryInvalidation", () => { expect(contentDeliveryInvalidation(plainType, outcome())).toBeUndefined(); }); - it("reports the sitemap only when the set of listed URLs changed", () => { + it("passes the sitemap change through unchanged", () => { expect(contentDeliveryInvalidation(articleType, outcome())).toStrictEqual({ - sitemap: true, + sitemap: { contentChanged: true, indexChanged: false }, }); expect( contentDeliveryInvalidation( articleType, - outcome({ sitemapChanged: false }), + outcome({ sitemap: { contentChanged: true, indexChanged: true } }), ), - ).toStrictEqual({ sitemap: false }); + ).toStrictEqual({ sitemap: { contentChanged: true, indexChanged: true } }); }); - it("still expires the delivery metadata when no URL moved", () => { - // A shared SEO field moving changes what every locale's `<head>` renders even - // though nothing was added to or removed from the sitemap. + it("expires no sitemap for a mutation that reported no delivery outcome", () => { + // The delivery metadata tag still goes out - a shared SEO field moving changes + // what every locale's `<head>` renders - but a mutation that touched no + // slug-bearing path has nothing to say about the sitemap. expect(contentDeliveryInvalidation(articleType, undefined)).toStrictEqual({ - sitemap: false, + sitemap: { contentChanged: false, indexChanged: false }, }); }); }); diff --git a/packages/vitnode/src/content/server/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts index b0c042f58..d32b96754 100644 --- a/packages/vitnode/src/content/server/delivery-writes.test.ts +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -26,6 +26,7 @@ import { applyContentDeliveryWrite } from "./delivery-writes"; const articleType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "writes.article", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { slug: field.slug({ source: "title" }), @@ -43,6 +44,7 @@ const articleType = defineContentType({ const localizedType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "writes.localized", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { slug: field.slug({ localized: true, source: "title" }), @@ -149,7 +151,8 @@ describe("a draft", () => { expect(outcome).toMatchObject({ canonicalPath: "/articles/hello", redirectCreated: false, - sitemapChanged: false, + // Neither public before nor after, so no sitemap file lists it either way. + sitemap: { contentChanged: false, indexChanged: false }, slugChanged: false, }); }); @@ -207,8 +210,12 @@ describe("publishing", () => { kind: "reserve", }, ]); - // A publish adds a sitemap line even though no URL moved. - expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + // A publish adds a sitemap line even though no URL moved, and changes how many + // URLs the index counts. + expect(outcome).toMatchObject({ + sitemap: { contentChanged: true, indexChanged: true }, + slugChanged: false, + }); }); it("refuses an address another record's history owns", async () => { @@ -250,7 +257,9 @@ describe("moving a published URL", () => { previousPath: "/articles/old", previousSlug: "old", redirectCreated: true, - sitemapChanged: true, + // The file's bytes moved - one line now reads a different URL - but the number + // of files an index lists did not. + sitemap: { contentChanged: true, indexChanged: false }, slugChanged: true, }); }); @@ -292,7 +301,10 @@ describe("unpublishing and deleting", () => { // No retire (the slug did not move) and no reserve (it is not public). The // resolver stops redirecting because it reads the live publication state. expect(calls).toStrictEqual([]); - expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + expect(outcome).toMatchObject({ + sitemap: { contentChanged: true, indexChanged: true }, + slugChanged: false, + }); }); it("writes nothing on a delete, and reports the lost sitemap line", async () => { @@ -309,7 +321,7 @@ describe("unpublishing and deleting", () => { expect(calls).toStrictEqual([]); expect(outcome).toMatchObject({ canonicalPath: null, - sitemapChanged: true, + sitemap: { contentChanged: true, indexChanged: true }, slug: null, slugChanged: false, }); @@ -396,10 +408,10 @@ describe("delivery without redirects", () => { expect(outcome).toMatchObject({ canonicalPath: "/a/new", previousPath: "/a/old", - // The URL moved and the sitemap changed - the engine simply cannot redirect - // the old address, because nothing recorded it. + // The URL moved and the file changed - the engine simply cannot redirect the + // old address, because nothing recorded it. redirectCreated: false, - sitemapChanged: true, + sitemap: { contentChanged: true, indexChanged: false }, slugChanged: true, }); }); 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 a55801b2a..629154aa9 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, + testDeliveredPostContentType, testEditorialPostContentType, testPostContentType, } from "@/tests/content-fixtures"; @@ -79,6 +80,27 @@ const slugTag = (slug: string) => `content:test.post:slug:${slug}`; const editorialSlugTag = (slug: string) => `content:test.editorial:slug:${slug}`; +/** The delivery fixture's own tags. */ +const DELIVERED = "test.delivered-post"; +const DELIVERY_ITEM = `content:${DELIVERED}:delivery:7`; +const DELIVERY_SITEMAP = `content:${DELIVERED}:sitemap`; +const deliveryRedirectTag = (slug: string) => + `content:${DELIVERED}:redirect:${slug}`; + +/** + * One `updatedAt` per call, monotonically increasing. + * + * A real write moves the timestamp; a no-op does not. That distinction is the whole + * signal the sitemap decision reads, so the fixtures have to be explicit about it + * rather than reusing one constant everywhere. + */ +let clock = Date.parse("2026-01-01T00:00:00.000Z"); +const tick = (): string => { + clock += 60_000; + + return new Date(clock).toISOString(); +}; + const tags = () => cacheCalls.map(call => call.tag); /** Which Next cache API was used - `updateTag` is the immediate one. */ const mode = () => [...new Set(cacheCalls.map(call => call.fn))]; @@ -164,6 +186,154 @@ describe("edit", () => { }); }); +/** + * The sitemap half of delivery invalidation. + * + * A sitemap entry carries `<lastmod>`, derived from `updatedAt` - so a plain title + * edit on a published record changes the **bytes** of its sitemap file even though the + * set of URLs is identical. Treating "the sitemap changed" as "membership changed" + * leaves a cached file serving a stale timestamp, which is what these tests pin down. + */ +describe("delivery sitemap invalidation", () => { + const published = (slug: string, updatedAt: string) => ({ + data: { + id: 7, + publishedAt: past, + slug, + status: "published", + updatedAt, + }, + status: 200, + }); + + beforeEach(() => { + definition = testDeliveredPostContentType; + }); + + it("expires the sitemap for a title edit that moved no URL", async () => { + const before = tick(); + responses = [published("same", before), published("same", tick())]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + // The URL did not move, so nothing was added to or removed from the file - but + // `updatedAt` did, so its `<lastmod>` is different and the cached bytes are stale. + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("expires the sitemap for an SEO-only edit", async () => { + const before = tick(); + responses = [published("same", before), published("same", tick())]; + + await editContentAction(DELIVERED, 7, { excerpt: "A new summary." }); + + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("leaves the sitemap alone for a no-op edit", async () => { + // The engine issues no `UPDATE` for an update that changed nothing, so + // `updatedAt` does not move and the cached sitemap is still byte-correct. + const unchanged = tick(); + responses = [published("same", unchanged), published("same", unchanged)]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + expect(tags()).not.toContain(DELIVERY_SITEMAP); + // The rest of the delivery invalidation still happens: the metadata tag and the + // slug's redirect lookup are expired whether or not the sitemap moved. + expect(tags()).toContain(DELIVERY_ITEM); + expect(tags()).toContain(deliveryRedirectTag("same")); + }); + + it("leaves the sitemap alone for a draft edit", async () => { + const draft = (updatedAt: string) => ({ + data: { + id: 7, + publishedAt: null, + slug: "draft", + status: "draft", + updatedAt, + }, + status: 200, + }); + responses = [draft(tick()), draft(tick())]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + // Not public before or after, so it is in no sitemap file either way. + expect(cacheCalls).toEqual([]); + }); + + it("expires the sitemap on a slug change", async () => { + responses = [published("old", tick()), published("new", tick())]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + expect(tags()).toContain(DELIVERY_SITEMAP); + expect(tags()).toContain(deliveryRedirectTag("old")); + expect(tags()).toContain(deliveryRedirectTag("new")); + }); + + it("expires the sitemap on publish and on unpublish", async () => { + responses = [ + { + data: { + changed: true, + row: { + id: 7, + publishedAt: past, + slug: "hello", + status: "published", + updatedAt: tick(), + }, + }, + status: 200, + }, + ]; + + await publishContentAction(DELIVERED, 7); + + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("expires the sitemap on delete when the record had been published", async () => { + responses = [published("hello", tick())]; + + await deleteContentAction(DELIVERED, 7, 1); + + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("leaves the sitemap alone when deleting a record that was never published", async () => { + responses = [ + { + data: { + id: 7, + publishedAt: null, + slug: "draft", + status: "draft", + updatedAt: tick(), + }, + status: 200, + }, + ]; + + await deleteContentAction(DELIVERED, 7, 1); + + expect(tags()).not.toContain(DELIVERY_SITEMAP); + }); + + it("adds no delivery tags at all to a content type without delivery", async () => { + // The Stage 1-7 promise: the tag list of an existing content type does not move. + definition = testPostContentType; + responses = [published("same", tick()), published("same", tick())]; + + await editContentAction("test.post", 7, { title: "Hello" }); + + expect(tags()).toEqual([LIST, ITEM, slugTag("same")]); + }); +}); + describe("publish and unpublish", () => { it("expires the list, the item and the slug on publish", async () => { responses = [ diff --git a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts new file mode 100644 index 000000000..a460ea38f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts @@ -0,0 +1,286 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ContentPublicLocaleState } from "@/content/cache"; + +import { + testDeliveredLocalizedContentType, + testLocalizedPageContentType, +} from "@/tests/content-fixtures"; + +const cacheTags: string[] = []; + +// The real `revalidate.server` runs, so what is asserted is the tag list +// `contentInvalidationTags` actually produces - mocking the layer in between would +// test the mock. +vi.mock("server-only", () => ({})); + +vi.mock("next/cache", () => ({ + revalidatePath: () => undefined, + revalidateTag: (tag: string) => { + cacheTags.push(tag); + }, + updateTag: (tag: string) => { + cacheTags.push(tag); + }, +})); + +vi.mock("@/content/admin/fetch.server", () => ({ + contentApiFetch: async () => await Promise.resolve({ status: 500 }), +})); + +const { invalidateContentLocales } = await import("./public-locale-cache"); + +/** + * The localized half of delivery sitemap invalidation. + * + * A localized sitemap entry's `lastModified` is `max(base.updatedAt, + * translation.updatedAt)`, so a real edit to a **published translation** changes that + * locale's sitemap file even when its URL does not move - and a shared field edit + * changes every published locale's file, because the base timestamp is in all of them. + * + * The distinction these tests pin down is which locale, and whether the *index* moved: + * a title edit rewrites bytes inside one existing file and changes neither which files + * exist nor how many. + */ +const DELIVERED = "test.delivered-localized"; +const sitemapTag = (locale?: string) => + locale === undefined + ? `content:${DELIVERED}:sitemap` + : `content:${DELIVERED}:sitemap:${locale}`; + +/** A locale with its own published translation. */ +const own = (locale: string, slug: string): ContentPublicLocaleState => ({ + hasOwnTranslation: true, + isPublic: true, + locale, + slug, +}); + +/** A locale served the default translation, with none of its own. */ +const fallbackOnly = ( + locale: string, + slug: string, +): ContentPublicLocaleState => ({ + hasOwnTranslation: false, + isPublic: true, + locale, + slug, +}); + +beforeEach(() => { + cacheTags.length = 0; +}); + +describe("a translation update", () => { + it("expires only that locale's sitemap file", () => { + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).toContain(sitemapTag("pl")); + // English did not move, so its file is still byte-correct. + expect(cacheTags).not.toContain(sitemapTag("en")); + }); + + it("leaves the sitemap index alone", () => { + // A title edit rewrites a `<lastmod>` inside one file. It does not change which + // files exist, so the index that enumerates them is untouched. + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).not.toContain(sitemapTag()); + }); +}); + +describe("a shared update", () => { + it("expires every published locale's sitemap file", () => { + // The base row's `updatedAt` is part of `max(base, translation)` for every + // language, so a shared edit changes what each of their files serializes. + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "shared" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).toContain(sitemapTag("pl")); + }); + + it("still leaves the index alone", () => { + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "shared" }, + ); + + expect(cacheTags).not.toContain(sitemapTag()); + }); + + it("skips a locale that is not public", () => { + const states = [ + own("en", "hello"), + { hasOwnTranslation: true, isPublic: false, locale: "pl", slug: "witaj" }, + ]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "shared" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + // A draft translation is in no sitemap, so nothing about it went stale. + expect(cacheTags).not.toContain(sitemapTag("pl")); + }); +}); + +describe("a default-locale update with a fallback consumer", () => { + it("follows the Stage 5 fan-out", () => { + // `fallback: "default"` makes Polish's *public page* the English translation, so + // Stage 5 reaches Polish - and this reuses that fan-out rather than inventing a + // second locale-propagation rule. + // + // Polish contributes **no sitemap URL**, because a sitemap never lists a fallback + // (the delivery Postgres suite asserts that directly), so expiring its file is + // conservative rather than necessary: a cache miss, never a stale document. + const states = [own("en", "hello"), fallbackOnly("pl", "hello")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "en" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).toContain(sitemapTag("pl")); + }); + + it("does not reach a locale with its own translation", () => { + // Nothing falls back to a language that has its own copy, so a default-locale + // edit leaves it entirely alone. + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "en" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).not.toContain(sitemapTag("pl")); + }); +}); + +describe("membership changes", () => { + it("expires the file and the index when a translation is published", () => { + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + [ + own("en", "hello"), + { + hasOwnTranslation: true, + isPublic: false, + locale: "pl", + slug: "witaj", + }, + ], + [own("en", "hello"), own("pl", "witaj")], + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).toContain(sitemapTag("pl")); + // A language gained a URL, so how many the index counts moved. + expect(cacheTags).toContain(sitemapTag()); + }); + + it("expires the file and the index when a translation is deleted", () => { + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + [own("en", "hello"), own("pl", "witaj")], + // Absent from the "after" side entirely: the translation is gone. + [own("en", "hello")], + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).toContain(sitemapTag("pl")); + expect(cacheTags).toContain(sitemapTag()); + }); + + it("expires every locale's file and the index when the record is unpublished", () => { + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + [own("en", "hello"), own("pl", "witaj")], + [ + { + hasOwnTranslation: true, + isPublic: false, + locale: "en", + slug: "hello", + }, + { + hasOwnTranslation: true, + isPublic: false, + locale: "pl", + slug: "witaj", + }, + ], + { changed: "shared" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).toContain(sitemapTag("pl")); + expect(cacheTags).toContain(sitemapTag()); + }); +}); + +describe("a localized content type without delivery", () => { + it("produces exactly the Stage 1-7 tag list", () => { + const states = [ + { hasOwnTranslation: true, isPublic: true, locale: "en", slug: "hello" }, + { hasOwnTranslation: true, isPublic: true, locale: "pl", slug: "witaj" }, + ]; + + invalidateContentLocales(testLocalizedPageContentType, 7, states, states, { + changed: "translation", + locale: "pl", + }); + + const id = testLocalizedPageContentType.id; + expect(cacheTags).toStrictEqual([ + `content:${id}:list:pl`, + `content:${id}:item:pl:7`, + `content:${id}:slug:pl:witaj`, + ]); + }); +}); diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index 9cf061ab8..b966301fd 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -951,6 +951,64 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { expect(cursor).toBeNull(); }); + it("moves lastModified on an ordinary edit that keeps the URL", async () => { + const article = await publishArticle({ title: "Timestamped" }); + const first = await delivery()?.sitemap(); + const before = first?.entries[0].lastModified.getTime() ?? 0; + + // A title edit. The slug is never re-derived on update, so the URL is + // unchanged - and the sitemap's `<lastmod>` still has to move, which is the + // whole reason `contentChanged` cannot be "did membership change". + await editorial()?.update( + article.id, + { excerpt: "A new summary." }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const second = await delivery()?.sitemap(); + + expect(second?.entries[0].path).toBe(first?.entries[0].path); + expect(second?.entries[0].lastModified.getTime()).toBeGreaterThan(before); + }); + + it("reports the edit as a sitemap content change but not an index change", async () => { + const article = await publishArticle({ title: "Timestamped two" }); + + const outcome = await editorial()?.update( + article.id, + { excerpt: "Changed." }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + // The invariant the cache layer reads: the file's bytes moved, the set of files + // did not. A stale sitemap is exactly what the first half prevents. + expect(outcome?.delivery?.sitemap).toStrictEqual({ + contentChanged: true, + indexChanged: false, + }); + }); + + it("reports no sitemap change for a no-op edit", async () => { + const article = await publishArticle({ title: "Untouched" }); + const before = await delivery()?.sitemap(); + + // Re-sending the stored value writes nothing, so `updatedAt` does not move and + // the cached sitemap is still byte-correct. + const outcome = await editorial()?.update( + article.id, + { title: "Untouched" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + expect(outcome?.changed).toBe(false); + expect(outcome?.delivery).toBeUndefined(); + + const after = await delivery()?.sitemap(); + expect(after?.entries[0].lastModified.getTime()).toBe( + before?.entries[0].lastModified.getTime(), + ); + }); + it("uses the base row's updatedAt for a nonlocalized entry", async () => { const article = await publishArticle({ title: "Timestamped" }); // Read through the same driver as the sitemap, never as `::text`: a @@ -1404,6 +1462,54 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { expect(article.id).toBeGreaterThan(0); }); + it("moves a translation's lastModified on an ordinary edit", async () => { + const article = await publishLocalized(); + const before = await advancedDelivery()?.sitemap({ locale: "en" }); + + const outcome = await translationEditorial()?.update( + article.id, + "en", + { seo: { description: "A new summary." } }, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + const after = await advancedDelivery()?.sitemap({ locale: "en" }); + + // Same URL, later timestamp - and the outcome says so, which is what expires + // `sitemap:en` and nothing else. + expect(after?.entries[0].path).toBe(before?.entries[0].path); + expect(after?.entries[0].lastModified.getTime()).toBeGreaterThan( + before?.entries[0].lastModified.getTime() ?? 0, + ); + expect(outcome?.delivery?.sitemap).toStrictEqual({ + contentChanged: true, + indexChanged: false, + }); + }); + + it("reports an index change when a translation is published", async () => { + const localized = localizedService(); + if (!localized) throw new Error("no localized service"); + + const created = await localized.create({ + shared: {}, + translation: { title: "Fresh" }, + }); + await advancedEditorial()?.publish(created.row.id, { actor: ACTOR }); + + const outcome = await translationEditorial()?.publish( + created.row.id, + "en", + { actor: ACTOR }, + ); + + // A language gained a URL, so how many the index counts moved too. + expect(outcome?.delivery?.sitemap).toStrictEqual({ + contentChanged: true, + indexChanged: true, + }); + }); + it("takes the later of the base and translation timestamps", async () => { const article = await publishLocalized(); From 8d75c7827ea069d86d3ba6b467545070710ac506 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:26 +0200 Subject: [PATCH 105/123] docs(content): correct the sitemap cache and Editorial requirements The caching page said an edit that only changed what an already-listed page says leaves the sitemap byte-identical. That is false while `<lastmod>` is derived from `updatedAt`, so it is replaced with the rule that is true, plus a per-mutation matrix that separates the sitemap **file** from the sitemap **index** and names the no-op rows explicitly. `slug-history-and-redirects` gains the Editorial requirement with the reason - one transaction for the slug, its version and its history - and a table of what does *not* need Editorial, so nobody reads the restriction as applying to all of `delivery`. `content-delivery-limitations` gains both new refusals: redirects without Editorial, and a delivery path colliding site-wide across two plugins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../docs/dev/content-engine/caching.mdx | 88 ++++++++++++++++--- .../content-delivery-limitations.mdx | 32 +++++++ .../dev/content-engine/content-delivery.mdx | 14 ++- .../docs/dev/content-engine/sitemaps.mdx | 17 ++++ .../slug-history-and-redirects.mdx | 40 ++++++++- 5 files changed, 174 insertions(+), 17 deletions(-) diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx index 2ad224197..ebb105d29 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -417,20 +417,75 @@ contentInvalidationTags({ }); ``` -| Mutation | delivery | redirect (old + new) | sitemap | -| --------------------------------- | -------- | -------------------- | ------- | -| Slug change (published) | ✅ | ✅ | ✅ | -| Publish / unpublish | ✅ | ✅ | ✅ | -| Delete | ✅ | ✅ | ✅ | -| Restore that moves a slug | ✅ | ✅ | ✅ | -| Translation create / delete | ✅ | ✅ | ✅ | -| SEO field edit (still published) | ✅ | ✅ | ❌ | - -The last row is the one worth reading twice: an edit that only changed what an -already-listed page *says* leaves the sitemap byte-identical, so its tag is not -expired. Everything that adds, removes or moves a line in the file expires it - and on -a localized content type that means each affected locale's file **and** the -locale-less index that enumerates them. +```ts +contentInvalidationTags({ + contentTypeId, + delivery: { sitemap: { contentChanged: true, indexChanged: false } }, + id, + isPublic, + slugs: [previousSlug, currentSlug], + wasPublic, +}); +``` + +The sitemap is **two** decisions rather than one, and the reason is `<lastmod>`. A +sitemap entry carries a `lastModified` derived from `updatedAt`, so a plain title edit +on a published record changes the *bytes* of its sitemap file even though the set of +URLs in it is identical: + +- **`contentChanged`** expires the sitemap **file** of each locale the mutation + reached. True for any real mutation of a record that is or was publicly reachable. +- **`indexChanged`** expires the locale-less tag, which for a localized content type is + the *index* of its per-locale files. True only when public reachability flipped, + because an index lists files and counts URLs. + +| Mutation | delivery | redirect (old + new) | sitemap file | sitemap index | +| ----------------------------------- | -------- | -------------------- | ------------ | ------------- | +| Title / SEO edit (still published) | ✅ | ✅ | ✅ | ❌ | +| Slug change (published) | ✅ | ✅ | ✅ | ❌ | +| Publish / unpublish | ✅ | ✅ | ✅ | ✅ | +| Delete (was published) | ✅ | ✅ | ✅ | ✅ | +| Restore that moves a slug | ✅ | ✅ | ✅ | ❌ | +| Translation publish / unpublish | ✅ | ✅ | ✅ | ✅ | +| Translation create / delete | ✅ | ✅ | ✅ | ✅ | +| No-op edit | ❌ | ❌ | ❌ | ❌ | +| Draft edited into another draft | ❌ | ❌ | ❌ | ❌ | + +The first row is the one worth reading twice. **A real update to a published +representation expires that locale's sitemap file, because its `lastModified` changes - +even when the canonical URL stays the same.** Anything else would leave a cached +sitemap serving a timestamp that is no longer true. + +The last two rows are the other half of the same rule: the engine issues no `UPDATE` +for an update that changed nothing, so `updatedAt` does not move and the cached file is +still byte-correct. A draft is in no sitemap either way. + +<Callout type="info" title="A nonlocalized content type has one file and no index"> + Its locale-less tag *is* its sitemap file, so `contentChanged` is what expires it. + The locale-less tag means "the index" only for a localized content type, whose files + are the per-locale ones. +</Callout> + +### Which locale's sitemap + +Per locale, reusing the Stage 5 fan-out above rather than a second rule: + +```text +PL translation edit → sitemap:pl +EN translation edit → sitemap:en +shared field edit → sitemap:en AND sitemap:pl (the base `updatedAt` is in both) +``` + +A shared edit reaches every locale because a localized entry's `lastModified` is +`max(base.updatedAt, translation.updatedAt)` - so a new base timestamp becomes the +effective value for every published translation. + +One conservative case is worth naming: with `fallback: "default"`, an edit to the +**default** locale's translation also reaches every locale that has no translation of +its own, because that is where their public pages come from. Those locales contribute +no sitemap URL at all - a sitemap never lists a fallback - so expiring their files is a +cache miss rather than a necessity. Following the Stage 5 fan-out is deliberate: one +locale-propagation rule, not two. <Callout type="warn" title="Delivery is opt-in at this layer too"> Omit `delivery` from the input - which is what every content type without the block @@ -445,6 +500,11 @@ A [scheduled](/docs/dev/content-engine/scheduling) publish reaches the web app t the same revalidation bridge, with the delivery tags included - there is no second cross-origin invalidation system, and the same all-origins-must-accept rule applies. +The bridge's request schema declares `delivery` explicitly, because an object schema +strips what it does not name: a body that carried delivery tags and arrived without +them would leave a stale sitemap behind every background transition. It stays optional, +so an API that has not been redeployed keeps working. + ## Where the Next imports live Exactly one place: `@vitnode/core/content/next`. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx index aea4c83b7..9d49d522c 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -43,6 +43,38 @@ arbitrary URLs. Slug history maps one record's old addresses to that record's cu one; a rule engine over paths is a different feature living in a different layer (a middleware, a CDN, a `next.config` `redirects` array). +## Redirects require Editorial + +```ts +delivery: { enabled: true, redirects: { enabled: true } } +// ✖ without `editorial: { enabled: true }` +``` + +Slug history has to be written in the same transaction as the slug mutation, its version +check and its revision - and only the editorial mutation paths own such a transaction. +Without `editorial` a content type writes through the plain repository, so +`redirects: { enabled: true }` there would record nothing. + +Refused at definition time rather than downgraded, and **only** `redirects` is affected: +canonical URLs, SEO, alternates, `hreflang`, the sitemap and every delivery read remain +available without Editorial, which keeps Stage 5's "publication and localization without +Editorial" promise intact. See +[Redirects require Editorial](/docs/dev/content-engine/slug-history-and-redirects#redirects-require-editorial). + +Lifting it would mean giving the plain mutation paths a version column and a +transactional history write - which is most of what `editorial` already is. + +## A delivery path is a site-wide namespace + +Two plugins may publish the same `publicApi.path` while neither has `delivery`, because +their API routes are `/api/{pluginId}/content/{path}`. Two **delivery-enabled** content +types may not, because a canonical delivery URL is `/articles/{slug}` with no plugin id +in it - one path would give one public URL two owners. + +The fix is a boot-time check rather than a prefix: adding the plugin id to canonical URLs +would make every public content URL uglier for everybody to avoid a collision almost +nobody hits. Rename one `publicApi.path`, or turn `delivery` off on one of them. + ## No og:image `delivery.seo.openGraph` projects a title and a description, and stops there. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx index a66f09af2..1d982e50b 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx @@ -34,6 +34,10 @@ export const articleContentType = defineContentType({ fields: ["id", "title", "slug", "excerpt", "publishedAt"], }, + // `redirects` below needs this: slug history is written in the same transaction as + // the slug mutation and its revision. + editorial: { enabled: true }, + delivery: { // [!code highlight] enabled: true, // [!code highlight] redirects: { enabled: true }, // [!code highlight] @@ -71,7 +75,7 @@ sitemap entry. | Block | What it adds | | ----------- | ------------------------------------------------------------------ | -| `redirects` | Durable [slug history](/docs/dev/content-engine/slug-history-and-redirects) and automatic 308s | +| `redirects` | Durable [slug history](/docs/dev/content-engine/slug-history-and-redirects) and automatic 308s. **Needs `editorial`** | | `seo` | [Title, description, Open Graph and robots](/docs/dev/content-engine/seo) projection | | `sitemap` | A paginated [sitemap service](/docs/dev/content-engine/sitemaps) | | `hreflang` | An `x-default` for [localized alternates](/docs/dev/content-engine/localization-and-hreflang) | @@ -163,6 +167,7 @@ being indexable - and that is not a symptom anybody notices. | Rule | Result | | ------------------------------------------------------ | -------------------------- | | `delivery` without `publicApi` | Compile error + throw | +| `redirects` without `editorial` | Compile error + throw | | `sitemap` without `publication` | Throw | | `redirects` on a localized type with a **shared** slug | Throw | | An SEO field not in `publicApi.fields` | Compile error + throw | @@ -176,7 +181,12 @@ being indexable - and that is not a symptom anybody notices. | A localized content type withholding `"id"` | Throw | | A fallback SEO field with no primary | Throw | -Two are worth a word. A **localized** content type has to expose `"id"` in +Three are worth a word. `redirects` needs `editorial: { enabled: true }`, because slug +history has to be written in the same transaction as the slug mutation and its revision - +and only the editorial mutation paths own one. Nothing else in `delivery` needs it; see +[Redirects require Editorial](/docs/dev/content-engine/slug-history-and-redirects#redirects-require-editorial). + +A **localized** content type has to expose `"id"` in `publicApi.fields`, because alternates and `hreflang` are resolved by identifier and delivery reads the public projection - so without it every localized response would carry an empty alternate set, which looks exactly like a record with one translation. diff --git a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx index f738b2040..21952d9ac 100644 --- a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx +++ b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx @@ -144,6 +144,23 @@ Both are omitted from the XML when unset, which is valid. There are deliberately per-record dynamic callbacks: a function that runs once per URL in a 50,000-URL file is a performance decision disguised as a configuration option. +### It is also what the cache tag follows + +Because `lastModified` comes from `updatedAt`, a real edit to a published record changes +that locale's sitemap file even when its URL does not move - so the file's cache tag is +expired for a plain title or SEO edit, not only for a publish or a slug change: + +```text +title edit on a published record +→ updatedAt moves +→ <lastmod> moves +→ sitemap file tag expired (the index is not) +``` + +A no-op edit writes no `UPDATE`, so `updatedAt` does not move and the cached file is +still byte-correct. See [Caching](/docs/dev/content-engine/caching#delivery-tags) for +the full matrix and for the file-versus-index distinction. + ## Excluding one record ```ts diff --git a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx index 50febcb19..437e9e550 100644 --- a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -12,7 +12,10 @@ that fixes that. delivery: { enabled: true, redirects: { enabled: true }, -} +}, + +// Required. See below. +editorial: { enabled: true }, ``` From then on: @@ -26,6 +29,41 @@ after commit: /articles/stary-slug 308 -> /articles/nowy-slug ``` +## Redirects require Editorial + +```ts +delivery: { enabled: true, redirects: { enabled: true } } +// ✖ without `editorial: { enabled: true }` +``` + +Slug history has to be written in the **same transaction** as the slug mutation, its +version check and its revision - otherwise a committed slug change could leave the old +URL unreserved, or a reservation could survive a rolled-back write. The only mutation +paths that own such a transaction are the editorial ones; without `editorial` a content +type writes through the plain repository, which has no version to guard and no history +to write. + +So `redirects` without `editorial` would be a feature that silently records nothing. +It is a **compile error** and a definition-time throw rather than a silent downgrade to +`redirects: { enabled: false }` - an author who asked for redirects and quietly got none +would find out from a broken link months later. + +The restriction is narrow. Everything else in `delivery` is a read over data the content +type already has, and stays available without `editorial`: + +| Feature | Needs Editorial? | +| -------------------------------- | ---------------- | +| Canonical URLs | ❌ | +| SEO / Open Graph / robots | ❌ | +| Alternates and `hreflang` | ❌ | +| Sitemap | ❌ | +| Delivery reads and the AdminCP panel | ❌ | +| **Slug history and redirects** | ✅ | + +The same rule applies to a localized content type, and for the same reason: localized +slug history is written by `translation-editorial-service`, which a content type without +`editorial` does not have either. + ## When a slug becomes redirectable This is the rule the whole feature rests on, so it is stated exactly: From 6c03810e75ec64c52980fc6be0b946589a481f5a Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:38:29 +0200 Subject: [PATCH 106/123] test(content): cover the restore path in the sitemap audit Restore is one of the fifteen Stage 8 mutation paths and shares `applyContentDeliveryWrite` with the rest, so it inherits the same rule - but the AdminCP Server Action reaches it through its own before/after pair, and that pair is what decides the sitemap tag. A restore that moves the slug expires the file and both redirect lookups; a no-op restore returns the row unchanged, so `updatedAt` does not move and nothing is expired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../content/actions/mutation-api.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) 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 629154aa9..44da8218a 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 @@ -323,6 +323,55 @@ describe("delivery sitemap invalidation", () => { expect(tags()).not.toContain(DELIVERY_SITEMAP); }); + it("expires the sitemap for a restore that moved the slug", async () => { + responses = [ + published("current", tick()), + { + data: { + changed: true, + row: { + id: 7, + publishedAt: past, + slug: "restored", + status: "published", + updatedAt: tick(), + }, + }, + status: 200, + }, + ]; + + await restoreContentRevisionAction(DELIVERED, 7, 3, 4); + + expect(tags()).toContain(DELIVERY_SITEMAP); + expect(tags()).toContain(deliveryRedirectTag("current")); + expect(tags()).toContain(deliveryRedirectTag("restored")); + }); + + it("leaves the sitemap alone for a restore that changed nothing", async () => { + const unchanged = tick(); + responses = [ + published("current", unchanged), + { + data: { + changed: false, + row: { + id: 7, + publishedAt: past, + slug: "current", + status: "published", + updatedAt: unchanged, + }, + }, + status: 200, + }, + ]; + + await restoreContentRevisionAction(DELIVERED, 7, 3, 4); + + expect(tags()).not.toContain(DELIVERY_SITEMAP); + }); + it("adds no delivery tags at all to a content type without delivery", async () => { // The Stage 1-7 promise: the tag list of an existing content type does not move. definition = testPostContentType; From 3ded6157dbed03ff5d9de14372aad1943ab036d2 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sun, 9 Aug 2026 17:34:11 +0200 Subject: [PATCH 107/123] fix(content): report delivery event failures, and audit the delivery routes Stage 7 made a post-commit event failure impossible to lose: `emit()` reports rather than throws, so `reportContentEventFailures` writes the dead listener to `core_logs` behind `[content-effects]`. The delivery events arrived on a branch cut before that and never joined it - so `delivery_slug_changed` was the one announcement nobody could find afterwards, and it is the one with the most expensive silence: a listener that purges a CDN or writes an edge redirect table misses it, and the old address keeps 404ing at the edge while the origin is entirely correct. Two of Stage 7's audits also stopped covering what they claim to. The permission matrix enumerates every generated route so a new one "cannot join the set silently" - but its maximal fixture had no `delivery`, so `GET /{id}/delivery` was outside it. The OpenAPI parity suite serves the real document and validates real bodies against it - and neither of its fixtures had delivery either, so the three public delivery routes, the AdminCP panel's `z.date()` pair and the new 409 arm were all unchecked. Both fixtures now carry the block, which is what the "maximal fixture" was for. Finally the reserved-address error joins `error-contracts.test.ts`, where every other expected failure states its status and its code: two constraints can refuse the same write with the same SQLSTATE, so a client has to be able to tell "that address is taken now" from "that address still redirects somewhere". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../slug-history-and-redirects.mdx | 14 + .../content/server/delivery-effects.test.ts | 113 +++++++- .../src/content/server/delivery-effects.ts | 91 ++++--- .../content/server/error-contracts.test.ts | 67 +++++ .../src/content/server/openapi-parity.test.ts | 242 ++++++++++++++++++ .../content/server/permission-matrix.test.ts | 18 +- 6 files changed, 512 insertions(+), 33 deletions(-) diff --git a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx index 437e9e550..4c7390353 100644 --- a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -342,6 +342,20 @@ event. Both are documented in [Built-in events](/docs/dev/events/built-in-events). +### When nobody hears them + +Both are emitted **after** the transaction commits, and `emit()` reports a dead +listener rather than throwing one - so a broker outage cannot roll a slug change +back, and the request still answers 200. It is not swallowed either: the failure +goes to `core_logs` behind `[content-effects]` with the content type, the item, the +locale and the listener that failed, exactly like the publication events. See +[Observability](/docs/dev/content-engine/content-engine-observability#the-logs). + +That log line is the one worth alerting on. A listener that purges a CDN or writes +an edge redirect table missing a `delivery_slug_changed` leaves the old address +404ing at the edge while the origin is entirely correct - which is the failure +nobody notices from the inside. + ## AdminCP Every delivery-enabled content type gets a read-only delivery panel on its row diff --git a/packages/vitnode/src/content/server/delivery-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts index 809f88763..3f5fc5987 100644 --- a/packages/vitnode/src/content/server/delivery-effects.test.ts +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -70,8 +70,17 @@ const outcome = ( ...overrides, }); -const buildContext = () => { +/** One dead listener, as `EventsModel.emit` reports it rather than throws it. */ +const DEAD_LISTENER = { + error: "Service unavailable", + listener: "warm-edge-cache", + module: "cdn", + pluginId: "@vitnode/edge", +}; + +const buildContext = ({ failing = false }: { failing?: boolean } = {}) => { const emitted: { name: string; payload: Record<string, unknown> }[] = []; + const logged: string[] = []; const c = { get: (key: string) => { @@ -80,7 +89,22 @@ const buildContext = () => { emit: async (name: string, payload: Record<string, unknown>) => { emitted.push({ name, payload }); - return await Promise.resolve({ failures: [] }); + return await Promise.resolve({ + delivered: failing ? 0 : 1, + eventId: `event-${emitted.length}`, + failures: failing ? [DEAD_LISTENER] : [], + status: "delivered", + }); + }, + }; + } + + if (key === "log") { + return { + error: async (message: string) => { + logged.push(message); + + return await Promise.resolve(); }, }; } @@ -89,7 +113,7 @@ const buildContext = () => { }, } as unknown as Context; - return { c, emitted }; + return { c, emitted, logged }; }; describe("contentDeliveryEffects", () => { @@ -198,6 +222,89 @@ describe("contentDeliveryEffects", () => { expect(emitted[0].payload).toMatchObject({ locale: "pl" }); }); + + /** + * The same post-commit rule the base and translation effects follow. + * + * `EventsModel.emit` reports rather than throws, so `failures` is the only place + * a dead listener is visible - and a missed `delivery_slug_changed` is the most + * expensive one to miss: the listener that purges a CDN or writes an edge + * redirect table never hears the URL moved, so the old address keeps 404ing at + * the edge while the origin is entirely correct. + */ + describe("reporting a delivery failure", () => { + it("logs the failed listener behind the effects prefix", async () => { + const { c, logged } = buildContext({ failing: true }); + + await contentDeliveryEffects(c, articleType, outcome(), { + pluginId: "@vitnode/test", + }); + + // One line per event, and both events fired for this outcome. + expect(logged).toHaveLength(2); + expect(logged[0]).toContain("[content-effects]"); + expect(logged[0]).toContain("effects.article"); + expect(logged[0]).toContain('"itemId":42'); + expect(logged[0]).toContain("warm-edge-cache"); + expect(logged[0]).toContain("Service unavailable"); + }); + + it("names the delivery action, so it is not read as a failed edit", async () => { + const { c, logged } = buildContext({ failing: true }); + + await contentDeliveryEffects(c, articleType, outcome(), { + pluginId: "@vitnode/test", + }); + + expect(logged[0]).toContain("delivery_slug_changed"); + expect(logged[1]).toContain("delivery_redirect_created"); + }); + + it("carries the locale, so a Polish URL is a distinct incident", async () => { + const { c, logged } = buildContext({ failing: true }); + + await contentDeliveryEffects( + c, + articleType, + outcome({ + canonicalPath: "/pl/articles/nowy", + locale: "pl", + previousPath: "/pl/articles/stary", + previousSlug: "stary", + redirectCreated: false, + slug: "nowy", + }), + { pluginId: "@vitnode/test" }, + ); + + expect(logged).toHaveLength(1); + expect(logged[0]).toContain('"locale":"pl"'); + }); + + it("still returns normally, because the write has already committed", async () => { + const { c, logged } = buildContext({ failing: true }); + + const result = await contentDeliveryEffects(c, articleType, outcome(), { + pluginId: "@vitnode/test", + }); + + // The events are still reported back to the caller, failures and all. + expect(result.events).toHaveLength(2); + expect(logged).toHaveLength(2); + }); + + it("writes nothing when every listener heard it", async () => { + const { c, logged } = buildContext(); + + await contentDeliveryEffects(c, articleType, outcome(), { + pluginId: "@vitnode/test", + }); + + // An expected success is not an error, and a log full of them is a log + // nobody reads. + expect(logged).toStrictEqual([]); + }); + }); }); describe("contentDeliveryInvalidation", () => { diff --git a/packages/vitnode/src/content/server/delivery-effects.ts b/packages/vitnode/src/content/server/delivery-effects.ts index acab0d012..0736041f8 100644 --- a/packages/vitnode/src/content/server/delivery-effects.ts +++ b/packages/vitnode/src/content/server/delivery-effects.ts @@ -5,6 +5,7 @@ import type { ContentDeliveryInvalidation } from "../cache"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDeliveryOutcome } from "./delivery-writes"; +import { reportContentEventFailures } from "./effects-log"; import { emitContentEvent } from "./emit"; export interface ContentDeliveryEffectsResult { @@ -51,6 +52,46 @@ export const contentDeliveryEffects = async ( ): Promise<ContentDeliveryEffectsResult> => { const events: EventEmitResult[] = []; + /** + * Emits one delivery event and reports whoever did not hear it. + * + * The reporting is not optional decoration. `EventsModel.emit` reports rather + * than throws, so `failures` is the only place a dead listener is visible - and + * these events are the ones with the most expensive silent failure in the engine: + * a listener that writes an edge redirect table or purges a CDN missing a + * `delivery_slug_changed` leaves a moved URL 404ing at the edge while the origin + * is perfectly correct. The base and translation effects log their own event for + * exactly this reason, and a delivery event that skipped the log would be the one + * announcement nobody could find afterwards. + * + * The write has already committed, so this never fails the request - see + * `reportContentEventFailures`. + */ + const announce = async ( + action: "delivery_redirect_created" | "delivery_slug_changed", + payload: Record<string, unknown>, + { itemId, locale }: { itemId: number; locale: null | string }, + ): Promise<void> => { + const event = await emitContentEvent( + c, + definition, + action, + payload as never, + { pluginId }, + ); + + events.push(event); + await reportContentEventFailures(c, { + action, + contentTypeId: definition.id, + event, + itemId, + // Present only for a localized URL: "nobody heard the Polish article moved" + // is a different incident from "nobody heard the article moved". + ...(locale === null ? {} : { locale }), + }); + }; + // A canonical path this engine could not build is a URL nobody can visit, so // there is no delivery fact to announce. It happens for a slug written straight // into the database, and for a localized content type whose slug is shared - which @@ -63,21 +104,17 @@ export const contentDeliveryEffects = async ( return { events }; } - events.push( - await emitContentEvent( - c, - definition, - "delivery_slug_changed", - { - canonicalPath: delivery.canonicalPath, - contentId: delivery.itemId, - locale: delivery.locale, - previousPath: delivery.previousPath, - previousSlug: delivery.previousSlug, - slug: delivery.slug, - } as never, - { pluginId }, - ), + await announce( + "delivery_slug_changed", + { + canonicalPath: delivery.canonicalPath, + contentId: delivery.itemId, + locale: delivery.locale, + previousPath: delivery.previousPath, + previousSlug: delivery.previousSlug, + slug: delivery.slug, + }, + { itemId: delivery.itemId, locale: delivery.locale }, ); if ( @@ -85,20 +122,16 @@ export const contentDeliveryEffects = async ( delivery.previousPath !== null && delivery.previousSlug !== null ) { - events.push( - await emitContentEvent( - c, - definition, - "delivery_redirect_created", - { - canonicalPath: delivery.canonicalPath, - contentId: delivery.itemId, - locale: delivery.locale, - previousPath: delivery.previousPath, - previousSlug: delivery.previousSlug, - } as never, - { pluginId }, - ), + await announce( + "delivery_redirect_created", + { + canonicalPath: delivery.canonicalPath, + contentId: delivery.itemId, + locale: delivery.locale, + previousPath: delivery.previousPath, + previousSlug: delivery.previousSlug, + }, + { itemId: delivery.itemId, locale: delivery.locale }, ); } diff --git a/packages/vitnode/src/content/server/error-contracts.test.ts b/packages/vitnode/src/content/server/error-contracts.test.ts index da2d3da91..1105a74cf 100644 --- a/packages/vitnode/src/content/server/error-contracts.test.ts +++ b/packages/vitnode/src/content/server/error-contracts.test.ts @@ -6,6 +6,7 @@ import { ZodError } from "zod"; import { ContentAdvancedInputError, ContentDefaultTranslationRequired, + ContentDeliverySlugReserved, ContentInputError, ContentLanguageError, ContentRevisionNotRestorable, @@ -210,6 +211,59 @@ describe("domain failures map onto their documented codes", () => { }); }); + /** + * A reserved historical address, which a `23505` could not have explained. + * + * Two constraints can refuse the same write - the live slug index and the + * history reservation - and the driver's code is identical for both. So the + * reservation is checked in the transaction and raised as a domain error, and + * this is the arm it lands on: a 409 that names the slug and the locale rather + * than a SQLSTATE the client would have to guess at. + */ + it("answers a reserved address with its own 409 code", async () => { + const result = await responseOf( + throwing( + new ContentDeliverySlugReserved({ + contentTypeId: CONTENT_TYPE_ID, + locale: null, + slug: "hello-world", + }), + ), + { itemId: 7, structured: true }, + ); + + expect(result.status).toBe(409); + expect(JSON.parse(result.body)).toEqual({ + code: "CONTENT_DELIVERY_SLUG_RESERVED", + contentTypeId: CONTENT_TYPE_ID, + locale: null, + slug: "hello-world", + }); + }); + + it("keeps the reserved address out of the unique-clash arm", async () => { + // The two share a status and mean different things: a unique clash is + // "another record holds that address now", and this is "another record used + // to hold it and it still redirects there". A client that could not tell them + // apart could not word either one. + const result = await responseOf( + throwing( + new ContentDeliverySlugReserved({ + contentTypeId: CONTENT_TYPE_ID, + locale: "pl", + slug: "stary-slug", + }), + ), + { itemId: 7, structured: true }, + ); + + expect(JSON.parse(result.body)).toMatchObject({ + code: "CONTENT_DELIVERY_SLUG_RESERVED", + locale: "pl", + }); + expect(result.body).not.toContain("CONTENT_UNIQUE_CONFLICT"); + }); + it("answers an unrestorable revision with 422 and the field names", async () => { const result = await responseOf( throwing( @@ -363,6 +417,19 @@ describe("translation failures keep their own union", () => { 409, "CONTENT_LANGUAGE_DISABLED", ], + [ + // In the delivery union rather than translated into the translation one: + // the translation mapper rewrites every 409 the shared mapper produces + // into the unique-clash arm, so this has to be caught before it. + "a localized address another record's history owns", + new ContentDeliverySlugReserved({ + contentTypeId: CONTENT_TYPE_ID, + locale: "pl", + slug: "stary-slug", + }), + 409, + "CONTENT_DELIVERY_SLUG_RESERVED", + ], ])("answers %s with %i and a code", async (_why, error, status, code) => { const result = await translationResponseOf(throwing(error)); diff --git a/packages/vitnode/src/content/server/openapi-parity.test.ts b/packages/vitnode/src/content/server/openapi-parity.test.ts index 3a0a9ec31..292b15d31 100644 --- a/packages/vitnode/src/content/server/openapi-parity.test.ts +++ b/packages/vitnode/src/content/server/openapi-parity.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { JsonSchemaLike } from "@/tests/openapi-validate"; import { + testDeliveredPostContentType, testEditorialPostContentType, testLocalizedPageContentType, } from "@/tests/content-fixtures"; @@ -15,6 +16,7 @@ import { validateAgainstJsonSchema } from "@/tests/openapi-validate"; import { ContentDefaultTranslationRequired, + ContentDeliverySlugReserved, ContentRevisionNotRestorable, ContentScheduleError, ContentTranslationVersionConflict, @@ -828,3 +830,243 @@ describe("public routes match their OpenAPI document", () => { }); }); }); + +/** + * The Stage 8 routes, held to the same contract as everything above. + * + * They are the ones with the most to get wrong: a `Date` that has to leave as an + * ISO string, a discriminated union a frontend branches on to decide between + * rendering a page and issuing a 308, and a third arm on the editorial `409`. A + * generated client is built from the document, so each of those is a promise the + * handler has to keep rather than a schema that merely looks right. + */ +describe("delivery routes match their OpenAPI document", () => { + const delivered = createContentModel(testDeliveredPostContentType); + + const metadata = { + alternates: [], + canonicalPath: "/delivered-posts/hello-world", + hreflang: { languages: {} }, + isFallback: false, + itemId: 42, + locale: null, + openGraph: { description: "Prose", title: "Hello world" }, + requestedLocale: null, + robots: { follow: true, index: true }, + seo: { description: "Prose", title: "Hello world" }, + }; + + const deliveryStub = () => ({ + alternates: vi.fn().mockResolvedValue([]), + findById: vi.fn().mockResolvedValue(metadata), + history: vi.fn().mockResolvedValue([ + { + createdAt: new Date("2026-01-01T00:00:00.000Z"), + itemId: 42, + languageId: null, + path: "/delivered-posts/hello-world", + retiredAt: null, + slug: "hello-world", + }, + { + createdAt: new Date("2025-12-01T00:00:00.000Z"), + itemId: 42, + languageId: null, + path: "/delivered-posts/old-address", + retiredAt: new Date("2026-01-01T00:00:00.000Z"), + slug: "old-address", + }, + ]), + resolvePath: vi.fn(), + resolveSlug: vi.fn().mockResolvedValue({ ...metadata, type: "content" }), + sitemap: vi.fn().mockResolvedValue({ + entries: [ + { + changeFrequency: "weekly", + itemId: 42, + // A `Date` in the service, an ISO string on the wire: exactly the pair + // this suite exists to keep honest. + lastModified: new Date("2026-01-02T03:04:05.000Z"), + locale: null, + path: "/delivered-posts/hello-world", + priority: 0.7, + }, + ], + nextCursor: null, + }), + }); + + let delivery: ReturnType<typeof deliveryStub>; + + /** The public delivery routes: resolve, item and sitemap. */ + const publicSuite = (): Suite => { + delivery = deliveryStub(); + vi.spyOn(delivered, "deliveryService", "get").mockReturnValue( + () => delivery, + ); + vi.spyOn( + delivered as unknown as { publicService: unknown }, + "publicService", + "get", + ).mockReturnValue(() => ({ + findById: vi.fn(), + findBySlug: vi.fn(), + findMany: vi.fn(), + })); + + return mount(buildContentPublicRoutes(delivered, { pluginId: PLUGIN_ID })); + }; + + /** The AdminCP delivery panel's route, plus the editorial routes around it. */ + const adminSuite = ( + editorialOverrides: Record<string, unknown> = {}, + ): Suite => { + delivery = deliveryStub(); + vi.spyOn(delivered, "deliveryService", "get").mockReturnValue( + () => delivery, + ); + vi.spyOn(delivered, "service").mockReturnValue(adminService() as never); + const editorial = { ...editorialStub(), ...editorialOverrides }; + vi.spyOn( + delivered as unknown as { editorialService: unknown }, + "editorialService", + "get", + ).mockReturnValue(() => editorial); + + return mount(buildContentRoutes(delivered, { pluginId: PLUGIN_ID })); + }; + + it("publishes nothing about pagination's own column", () => { + expect(JSON.stringify(publicSuite().document)).not.toContain( + "__cursorValue", + ); + expect(JSON.stringify(adminSuite().document)).not.toContain( + "__cursorValue", + ); + }); + + it("resolves a slug into the content arm", async () => { + const body = await expectParity(publicSuite(), { + expected: 200, + method: "GET", + path: "/delivery/resolve/hello-world", + template: "/delivery/resolve/{slug}", + }); + + expect(body).toMatchObject({ type: "content" }); + }); + + it("resolves a retired slug into the redirect arm", async () => { + const suite = publicSuite(); + delivery.resolveSlug.mockResolvedValue({ + location: "/delivered-posts/hello-world", + status: 308, + type: "redirect", + }); + + const body = await expectParity(suite, { + expected: 200, + method: "GET", + path: "/delivery/resolve/old-address", + template: "/delivery/resolve/{slug}", + }); + + // The discriminant a frontend branches on to issue a 308 rather than render. + expect(body).toMatchObject({ status: 308, type: "redirect" }); + }); + + it("answers an unknown slug with the not_found arm, still a 200", async () => { + const suite = publicSuite(); + delivery.resolveSlug.mockResolvedValue({ type: "not_found" }); + + const body = await expectParity(suite, { + expected: 200, + method: "GET", + path: "/delivery/resolve/nope", + template: "/delivery/resolve/{slug}", + }); + + expect(body).toStrictEqual({ type: "not_found" }); + }); + + it("reads one record's delivery metadata", async () => { + await expectParity(publicSuite(), { + expected: 200, + method: "GET", + path: "/delivery/item/42", + template: "/delivery/item/{id}", + }); + }); + + it("answers 404 for a record with no public version", async () => { + const suite = publicSuite(); + delivery.findById.mockResolvedValue(null); + + await expectParity(suite, { + expected: 404, + method: "GET", + path: "/delivery/item/42", + template: "/delivery/item/{id}", + }); + }); + + it("serves a sitemap page whose lastModified is the documented string", async () => { + const body = await expectParity(publicSuite(), { + expected: 200, + method: "GET", + path: "/delivery/sitemap", + template: "/delivery/sitemap", + }); + + expect(body).toMatchObject({ + entries: [{ lastModified: "2026-01-02T03:04:05.000Z" }], + nextCursor: null, + }); + }); + + it("serves the AdminCP delivery panel, dates and all", async () => { + const body = await expectParity(adminSuite(), { + expected: 200, + method: "GET", + path: "/7/delivery", + template: "/{id}/delivery", + }); + + // The storage columns behind a history row are not part of the contract, and + // the schema is closed, so the document validating is what proves it. + expect(body).toMatchObject({ + canonicalPath: "/delivered-posts/hello-world", + history: [{ slug: "hello-world" }, { slug: "old-address" }], + }); + expect(body).not.toHaveProperty("history.0.languageId"); + }); + + it("answers a reserved address with the documented 409 arm", async () => { + // The write fails the way a taken historical address fails: the slug is free + // on the live table and owned by another record's URL history. + const suite = adminSuite({ + update: vi.fn().mockRejectedValue( + new ContentDeliverySlugReserved({ + contentTypeId: testDeliveredPostContentType.id, + locale: null, + slug: "hello-world", + }), + ), + }); + + const body = await expectParity(suite, { + body: { expectedVersion: 4, values: { title: "Hello again" } }, + expected: 409, + method: "PUT", + path: "/7", + template: "/{id}", + }); + + // The third arm, and the reason it is a union rather than a replacement: a + // client generated before Stage 8 still parses the two it knows. + expect(body).toMatchObject({ + code: "CONTENT_DELIVERY_SLUG_RESERVED", + slug: "hello-world", + }); + }); +}); diff --git a/packages/vitnode/src/content/server/permission-matrix.test.ts b/packages/vitnode/src/content/server/permission-matrix.test.ts index e50345959..333051c1c 100644 --- a/packages/vitnode/src/content/server/permission-matrix.test.ts +++ b/packages/vitnode/src/content/server/permission-matrix.test.ts @@ -81,9 +81,21 @@ const kitchenSink = defineContentType({ publicApi: { enabled: true, path: "everything", - fields: ["title", "slug", "featured", "publishedAt"], + // `id` is exposed because delivery resolves alternates by identifier off the + // public projection, and a localized delivery content type is refused without + // it. + fields: ["id", "title", "slug", "featured", "publishedAt"], orderableFields: ["publishedAt"], }, + // Stage 8, so the delivery route is audited like every other one. `redirects` + // needs `editorial` and a localized slug, and this fixture has both - which is + // the whole reason it is the maximal one rather than a second fixture. + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { titleField: "title" }, + sitemap: { enabled: true }, + }, admin: { label: { plural: "Everythings", singular: "Everything" }, list: { columns: ["featured", "status"] }, @@ -219,6 +231,10 @@ describe("the generated permission matrix", () => { "GET /": "can_view", "GET /options/{field}": "can_view", "GET /{id}": "can_view", + // Read-only: it reports what the slug mutations already did, so the + // permission that allowed the mutation is the only one it needs. There is + // no manual redirect manager to gate separately. + "GET /{id}/delivery": "can_view", "GET /{id}/public-locales": "can_view", "GET /{id}/revisions": "can_view", "GET /{id}/revisions/{revisionId}": "can_view", From 549ca5ab3403ef1e08d62b99fb04f02b6230aa38 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sun, 9 Aug 2026 18:49:54 +0200 Subject: [PATCH 108/123] fix(content): keep the first moved URL, settle unseen slugs, and agree about null Three Stage 8 correctness bugs, each with a real-Postgres regression that fails without its fix. **The first slug change after upgrading lost the old URL.** Stage 8 writes no backfill migration, so a record published before the table existed has no row in it - and `retire()` only ever stamps a row that is already there. So the first rename of such a record retired nothing, reserved the new address, and forgot `/articles/hello` permanently, while the documentation promised that exact redirect. A global backfill cannot fix it: the database keeps one slug per row and no record of which historical values were public, so it would have to choose between missing live URLs and inventing redirects for slugs that only ever existed on a draft. The mutation does not have to choose - it holds the row on both sides of its own write, so `wasPublic` is evidence rather than inference. `ensureCurrent` spends that evidence, in the same transaction, exactly where a URL leaves service: a slug change, an unpublish, a delete. A draft still writes nothing. **Two writers could both take an address nobody had ever held.** The reservation was a `SELECT ... FOR UPDATE` followed by an insert, and there is no such thing as locking a row that does not exist - so both transactions read nothing, both decided the address was free, and the loser landed on the partial unique index as a raw `23505` that the shared mapper reads as a generic unique clash. The claim is now `INSERT ... ON CONFLICT DO NOTHING ... RETURNING`: the second insert takes a speculative-insertion lock, waits, and `RETURNING` says whether it won. The loser is identified rather than caught, so it gets `CONTENT_DELIVERY_SLUG_RESERVED` naming the slug and locale whichever order the two arrived in. **A nullable `noIndexField` was in the metadata and out of the sitemap.** The `robots` projection reads `value !== true`, so `null` means indexable; the sitemap asked `<> TRUE`, and `NULL <> TRUE` is `NULL`, which a `WHERE` clause drops. A boolean added to a table that already has rows arrives full of nulls, so an upgrade would have quietly emptied the sitemap of every record nobody had touched while each of their pages went on rendering `index: true`. `IS DISTINCT FROM TRUE` is the null-aware comparison, and `example.article` now carries a nullable flag so the whole sitemap suite exercises it rather than one helper test. The migrations guide also claimed a `publicApi.path` change could be redirected by inserting history rows. It cannot: `parseContentDeliveryPath` compares the prefix against the one the content type has now and returns `null` before history is consulted. Corrected, and stated as the limitation it is - a prefix move is one router rule for the whole namespace, not one row per record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../content-delivery-limitations.mdx | 26 +- .../content-delivery-migrations.mdx | 106 +- .../content/docs/dev/content-engine/seo.mdx | 22 + .../docs/dev/content-engine/sitemaps.mdx | 4 + .../slug-history-and-redirects.mdx | 54 +- ...0034_add_example_article_no_index_flag.sql | 10 + apps/docs/migrations/meta/0034_snapshot.json | 4042 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + .../src/content/server/delivery-sitemap.ts | 19 +- .../content/server/delivery-writes.test.ts | 353 +- .../src/content/server/delivery-writes.ts | 47 +- .../src/content/server/slug-history-model.ts | 164 +- plugins/example/src/const.ts | 4 + plugins/example/src/content/article.ts | 30 +- .../src/database/delivery-postgres.test.ts | 595 ++- plugins/example/src/database/postgres.test.ts | 6 + 16 files changed, 5366 insertions(+), 123 deletions(-) create mode 100644 apps/docs/migrations/0034_add_example_article_no_index_flag.sql create mode 100644 apps/docs/migrations/meta/0034_snapshot.json diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx index 9d49d522c..ab6f5a826 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -43,6 +43,30 @@ arbitrary URLs. Slug history maps one record's old addresses to that record's cu one; a rule engine over paths is a different feature living in a different layer (a middleware, a CDN, a `next.config` `redirects` array). +## Changing `publicApi.path` is not a redirect + +Slug history maps a record's old **slugs** to its current one. The prefix in front of +them is not part of that: + +```text +publicApi.path: "articles" → "blog" + +/articles/foo → not_found +/blog/foo → the record +``` + +`resolvePath()` splits an incoming URL against the prefix the content type has *now*, so +a request carrying the old one is refused before slug history is consulted at all - +`parseContentDeliveryPath` returns `null` on the prefix comparison. Inserting history +rows whose `path` carries the old prefix does not change that: the lookup is by slug, and +the request never reaches it. + +That is deliberate rather than missing. `publicApi.path` is the routing namespace itself, +so moving it moves every record at once for a reason no row records - and the fix is one +rule for the whole prefix (`/articles/:slug → /blog/:slug`) in your router, middleware, +`next.config` `redirects`, or CDN. Teaching the resolver to accept historical prefixes +would mean a second, path-shaped redirect engine living beside the record-shaped one. + ## Redirects require Editorial ```ts @@ -196,6 +220,6 @@ testing and no personalized URLs. <Card href="/docs/dev/content-engine/content-delivery-migrations" title="Delivery migrations" - description="What is not backfilled, and how to backfill it yourself." + description="How history is established lazily, and what a prefix change needs instead." /> </Cards> diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx index 3a55bc025..c3f9a2a31 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx @@ -1,6 +1,6 @@ --- title: Delivery migrations -description: One new core table, one deterministic migration, and a clear statement about what history does *not* get backfilled. +description: One new core table, one deterministic migration, how history establishes itself without a backfill, and what a route-prefix change needs instead. icon: Database --- @@ -66,21 +66,46 @@ Two partial uniques rather than one over a nullable `languageId`, because Postgr every `NULL` as distinct - a single key including it would enforce nothing at all for the shared case it exists to protect. -## History is not backfilled +## History is established lazily, not backfilled -**Nothing existing gets a history row.** History starts when Stage 8 begins tracking -future public slug changes, and that is a decision rather than an omission: +**No migration writes a history row.** Nothing scans your tables, and that is a decision +rather than an omission. What happens instead is that each record establishes its own +address the first time a mutation needs it: ```text existing published article, slug = hello → no history row → /articles/hello is canonical, as it always was - → the *next* slug change creates the redirect + +rename hello → world + → the mutation knows the record was public a moment ago + → it writes `hello`, retires it, and reserves `world` + → /articles/hello answers 308 → /articles/world ``` -A record's current slug does not need to be history - it is the canonical URL, and the -resolver finds it through the ordinary public read. The first row for a record is written -the next time it is published or the next time its live slug moves. +The mutation is the only thing that can do this correctly, and the reason is evidence. It +holds the row on both sides of its own write, so "that address was publicly reachable" +is a fact it already has - not something reconstructed afterwards from a table that never +recorded it. A migration has no such evidence, which is the whole of the next section. + +Three mutations spend it, and they are exactly the ones that take a live URL out of +service: + +| Mutation on a published record | What is written | +| ------------------------------ | ---------------- | +| slug change | the old address, then retired; the new one, current | +| unpublish | the address, kept current so nobody else can claim it | +| delete | the same - the record is gone and the URL stays reserved | + +A **draft** spends nothing. Its slug was never an address, so correcting it three times +before publishing writes nothing at all - which is the distinction an automatic backfill +cannot make. + +<Callout type="info" title="It is idempotent"> + A record that already has its row is left exactly as it stands, retired or not. + Establishing a missing fact and bringing a retired address back into service are + different operations, and only the second one un-retires anything. +</Callout> ### Why revisions are not scanned @@ -134,32 +159,41 @@ Verify with the AdminCP delivery panel: it lists exactly what the resolver will /articles → /blog ``` -This is **source configuration**, not a content mutation, and the engine creates no -redirects for it. Every record's URL changes at once, at deploy time, for a reason no -row in the database records. - -Automating it would mean writing one history row per record on boot - a migration -disguised as a config change, running inside a process that may be one of several -starting at the same moment. So it is left to you, deliberately: +**Stage 8 cannot redirect this, and no amount of history will make it.** The reason is +one line in the resolver rather than a missing feature: -```sql --- One row per published record, with the old prefix. -INSERT INTO "core_content_slug_history" - ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path", "retiredAt") -SELECT - '@vitnode/example', 'example.article', a."id", NULL, - a."slug", '/articles/' || a."slug", now() -FROM "example_articles" a -WHERE a."status" = 'published' AND a."publishedAt" IS NOT NULL -ON CONFLICT DO NOTHING; +```ts +// parseContentDeliveryPath +if (prefix !== definition.publicApi.path) return null; ``` -That is safe because the slug is unchanged - only the prefix moved - so the retired -`path` is the old URL and the resolver's destination is the record's current canonical -path under the new prefix. Run it in the same deploy as the config change. +`resolvePath()` splits an incoming URL against the prefix the content type has **now**. +After the config says `blog`, a request for `/articles/foo` is not a path this content +type produces, so it is `not_found` before slug history is consulted at all. History +answers "which record used to live at this *slug*", and the prefix is not part of the +slug. + +<Callout type="warn" title="Inserting history rows will not help"> + A hand-written row carrying `/articles/foo` never gets read, because the request is + refused one step earlier. Worse, a row whose `slug` is the one the record still holds + collides with its own current reservation - the partial unique index is on + `(contentTypeId, slug)`, not on `path`. +</Callout> + +`publicApi.path` is the routing namespace itself. Changing it moves every record at once, +at deploy time, for a reason no row in the database records - so the old prefix has to be +redirected by whatever owns routing: -Stage 8's automatic redirects are for **slug changes**. Route-prefix migrations are a -deployment decision, and they get explicit tooling or explicit SQL. +- a rewrite or redirect rule in your framework's router or middleware; +- a `redirects()` entry in `next.config`, or the equivalent for your host; +- a CDN or reverse-proxy rule, which is where a permanent prefix move usually belongs. + +All three are one rule for the whole prefix - `/articles/:slug → /blog/:slug` - rather +than one row per record, which is the right shape for a change that affected every record +identically. + +Stage 8's automatic redirects are for **slug changes**: the record moved and the engine +was there when it happened. A prefix move is a deployment decision, and it stays one. ## Turning delivery off @@ -178,7 +212,17 @@ Safe and additive: 2. Run the core migration if you have not already. 3. Existing published records keep their canonical URLs and gain sitemap entries immediately. -4. The first slug change on a published record creates the first redirect. +4. The first slug change on a published record creates the first redirect - the mutation + establishes the old address on its way past, so nothing is lost for having upgraded + late. There is no reindex, no rebuild and no backfill step - which is the practical consequence of delivery being a projection over data the content type already had. + +<Callout type="info" title="A nullable `noIndexField` is fine"> + Adding `field.boolean({ nullable: true })` and naming it as `delivery.seo.noIndexField` + needs no default and no table rewrite. `null` means indexable in both places that read + it: `robots` renders `index: true`, and the sitemap predicate is + `IS DISTINCT FROM TRUE`, so every existing row is listed rather than silently dropped. + Only an explicit `true` withholds a record. +</Callout> diff --git a/apps/docs/content/docs/dev/content-engine/seo.mdx b/apps/docs/content/docs/dev/content-engine/seo.mdx index fdc76d4a6..4c9389635 100644 --- a/apps/docs/content/docs/dev/content-engine/seo.mdx +++ b/apps/docs/content/docs/dev/content-engine/seo.mdx @@ -160,6 +160,28 @@ record. about indexing emits no `robots` meta tag rather than an affirmative "yes, index this". +### A nullable field is fine + +```ts +noIndex: field.boolean({ nullable: true }); +``` + +Only `true` withholds a record. `false` and `null` both mean indexable, in both places +that read the flag: + +| Value | `robots.index` | In the sitemap | +| ------- | -------------- | -------------- | +| `true` | `false` | no | +| `false` | `true` | yes | +| `null` | `true` | yes | + +That matters most on an upgrade, because a boolean added to a table that already has +rows arrives full of `NULL`. The sitemap predicate is `IS DISTINCT FROM TRUE` rather +than `<> TRUE` for exactly this reason: in SQL `NULL <> TRUE` is `NULL`, and a `WHERE` +clause drops every row it cannot prove - so the loose spelling would quietly empty the +sitemap of every record nobody had ever set the flag on, while each of their pages went +on rendering `index: true`. + ### It has to be shared ```ts diff --git a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx index 21952d9ac..2f4d18026 100644 --- a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx +++ b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx @@ -172,6 +172,10 @@ seo: { noIndexField: "syndication.noIndex" } single clause in the query rather than a post-filter, so a page of 1,000 entries is 1,000 listed URLs rather than however many survived. +Only `true` excludes. The clause is `IS DISTINCT FROM TRUE`, so a nullable field's `null` +is listed exactly like a `false` - which is what an upgrade produces, and what the +`robots` metadata says about the same row. + The field must be a shared boolean. See [SEO](/docs/dev/content-engine/seo#robots-and-noindex). ## The XML helper diff --git a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx index 4c7390353..48c7bae39 100644 --- a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -86,6 +86,28 @@ redirects at all, because none of those URLs was ever live. Without that rule a content type would accumulate a redirect per typo, and each one would be a permanent claim on an address nobody had visited. +### Records older than the table + +The rule holds for content published *before* slug history existed, and it does so +without a backfill migration. A mutation that takes a live address out of service +establishes that address first, then retires it: + +```text +upgraded install: published article, slug = hello, no history rows + +rename hello → world + → `hello` is written and retired, `world` reserved + → /articles/hello answers 308 → /articles/world +``` + +The mutation is holding the row on both sides of its own write, so "this was public a +moment ago" is evidence rather than a guess - which is exactly what a migration scanning +your tables would not have. It is idempotent (a record that already has the row is left +as it stands, retired or not) and it is spent only where a URL is leaving service: a slug +change, an unpublish, or a delete. A draft still writes nothing. + +See [Delivery migrations](/docs/dev/content-engine/content-delivery-migrations#history-is-established-lazily-not-backfilled). + ## What is stored One shared table, `core_content_slug_history`: @@ -304,9 +326,39 @@ history: exactly one retirement and one new reservation ``` Two *different* records racing for the same retired address both lose: the -reservation lookup takes a row lock, so they serialise rather than race, and the +reservation takes a row lock on the owner, so they serialise rather than race, and the address belongs to neither of them. +### An address nobody has ever held + +The harder case, and the reason a reservation is an **insert** rather than a check: + +```text +record A → "brand-new" +record B → "brand-new" (no history row exists for it) +``` + +`SELECT ... FOR UPDATE` locks rows that exist, and there is no such thing as locking a +row that is not there - so a check-then-insert lets both transactions read nothing, both +conclude the address is free, and the second one land on the unique index as a raw +`23505`. The database stays consistent and the *contract* does not: the loser would get a +driver failure that the shared mapper reads as a generic unique clash, where Stage 8 +promises `CONTENT_DELIVERY_SLUG_RESERVED`. + +So the reservation inserts first, with `ON CONFLICT DO NOTHING ... RETURNING`. The second +insert takes a speculative-insertion lock, waits for the first transaction to finish, and +then either does nothing (it committed) or inserts after all (it rolled back) - and +`RETURNING` says which happened. One writer gets the address; the other gets the +structured refusal, whichever order they arrived in. + +<Callout type="info" title="A slug field is also unique on its own table"> + So two *published* records renaming onto one brand-new slug usually collide there + first, one statement earlier, and the loser is told `CONTENT_UNIQUE_CONFLICT` - "that + address is taken now". `CONTENT_DELIVERY_SLUG_RESERVED` is the other sentence: "another + record used to hold it, and it still redirects there". Both are structured 409s; the + wording differs because the situations do. +</Callout> + ## Events Two events, each gated on a fact rather than an operation: diff --git a/apps/docs/migrations/0034_add_example_article_no_index_flag.sql b/apps/docs/migrations/0034_add_example_article_no_index_flag.sql new file mode 100644 index 000000000..892dcf524 --- /dev/null +++ b/apps/docs/migrations/0034_add_example_article_no_index_flag.sql @@ -0,0 +1,10 @@ +-- `example.article` gained `field.boolean({ nullable: true })` as its Stage 8 +-- `delivery.seo.noIndexField`. +-- +-- Nullable and undefaulted on purpose, because that is what an upgrade really +-- looks like: every row that already exists gets `NULL`, and `NULL` has to mean +-- "index me" in both places that read it - the `robots` metadata rendered into +-- the page and the predicate that decides what the sitemap lists. A boolean +-- added with `DEFAULT false NOT NULL` would rewrite the table and hide the case +-- worth testing. +ALTER TABLE "example_articles" ADD COLUMN "noIndex" boolean; diff --git a/apps/docs/migrations/meta/0034_snapshot.json b/apps/docs/migrations/meta/0034_snapshot.json new file mode 100644 index 000000000..6166a911e --- /dev/null +++ b/apps/docs/migrations/meta/0034_snapshot.json @@ -0,0 +1,4042 @@ +{ + "id": "0f660415-9144-44ed-9d96-78cd76711ebf", + "prevId": "318c5944-3dbe-4646-a80e-fd047f44db84", + "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 + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_content_slug_history": { + "name": "core_content_slug_history", + "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 + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_advanced_articles": { + "name": "example_advanced_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 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationNoIndex": { + "name": "syndicationNoIndex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "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()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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 + }, + "noIndex": { + "name": "noIndex", + "type": "boolean", + "primaryKey": false, + "notNull": 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 + }, + "public.example_localized_articles": { + "name": "example_localized_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 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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 1f44b074f..2ba6eff0f 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1786195724174, "tag": "0033_add_example_article_no_index", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1786292946013, + "tag": "0034_add_example_article_no_index_flag", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/vitnode/src/content/server/delivery-sitemap.ts b/packages/vitnode/src/content/server/delivery-sitemap.ts index 94e08eb94..9db546f84 100644 --- a/packages/vitnode/src/content/server/delivery-sitemap.ts +++ b/packages/vitnode/src/content/server/delivery-sitemap.ts @@ -6,7 +6,7 @@ import type { } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { and, asc, eq, gt, ne, sql } from "drizzle-orm"; +import { and, asc, eq, gt, sql } from "drizzle-orm"; import type { ContentSitemapEntry } from "../sitemap"; import type { AnyContentTypeDefinition } from "../types"; @@ -132,10 +132,19 @@ export const readContentDeliverySitemapPage = async < const conditions: (SQL | undefined)[] = [ publishedCondition(base), args.cursor === undefined ? undefined : gt(columns.id, args.cursor), - // `ne(..., true)` rather than `eq(..., false)`: the column is `NOT NULL` today, - // and a nullable one added later would silently drop every row whose value was - // never set if this asked for an exact `false`. - exclude === null ? undefined : ne(exclude, true), + // `IS DISTINCT FROM TRUE`, which is the only spelling that agrees with the + // metadata. `contentDeliveryRobots` reads `value !== true`, so a `noIndex` of + // `null` means `index: true` - and the sitemap has to list exactly what claims + // to be indexable, because "absent from the sitemap" and "robots says index + // me" is a contradiction a crawler resolves however it likes. + // + // `<> TRUE` cannot say it. In SQL `NULL <> TRUE` is `NULL`, not `TRUE`, and a + // `WHERE` clause drops every row it cannot prove - so a nullable `noIndexField` + // would quietly empty the sitemap of every record nobody had ever set the flag + // on, while every one of their pages rendered `index: true`. `IS DISTINCT + // FROM` is the null-aware comparison: `true` excludes, `false` and `null` + // include, which is the metadata rule written once more in SQL. + exclude === null ? undefined : sql`${exclude} is distinct from true`, ]; if (!localized) { diff --git a/packages/vitnode/src/content/server/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts index d32b96754..10fd141eb 100644 --- a/packages/vitnode/src/content/server/delivery-writes.test.ts +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -62,52 +62,75 @@ const localizedType = defineContentType({ interface Call { args: ContentSlugHistoryTarget | Omit<ContentSlugHistoryTarget, "locale">; - kind: "assertAvailable" | "reserve" | "retire"; + kind: "assertAvailable" | "ensureCurrent" | "reserve" | "retire"; } /** * A history model that records what it was asked to do. * - * `retired` is the interesting knob: it is the answer to "was that URL ever live", - * and the whole redirect decision hangs off it. + * Two knobs, and they answer different questions: + * + * - **`retired`** is the oracle for "was that URL ever live" when the rows on file + * are not being modelled. The whole redirect decision hangs off it. + * - **`existing`** models them instead: the set of slugs already in the table. + * Pass it and `retire` answers from the recorder's own rows rather than from + * the knob, which is what lets a test watch the bootstrap turn an address that + * *could not* be retired into one that can. `[]` is the state a record + * published before Stage 8 existed is actually in. */ const recorder = ({ + existing = null, reserved = null, retired = true, -}: { reserved?: null | string; retired?: boolean } = {}) => { +}: { + existing?: null | string[]; + reserved?: null | string; + retired?: boolean; +} = {}) => { const calls: Call[] = []; + const rows = new Set<string>(existing ?? []); + + const refuse = (args: ContentSlugHistoryTarget) => { + if (reserved !== null && args.slug === reserved) { + throw new ContentDeliverySlugReserved({ + contentTypeId: "writes.article", + locale: args.locale, + slug: args.slug, + }); + } + }; const model: ContentSlugHistoryModel = { assertAvailable: async (_tx, args) => { calls.push({ args, kind: "assertAvailable" }); - if (reserved !== null && args.slug === reserved) { - throw new ContentDeliverySlugReserved({ - contentTypeId: "writes.article", - locale: args.locale, - slug: args.slug, - }); - } + refuse(args); return await Promise.resolve(); }, + ensureCurrent: async (_tx, args) => { + calls.push({ args, kind: "ensureCurrent" }); + refuse(args); + const created = !rows.has(args.slug); + rows.add(args.slug); + + return await Promise.resolve({ created }); + }, list: async () => await Promise.resolve([]), owner: async () => await Promise.resolve(null), reserve: async (_tx, args) => { calls.push({ args, kind: "reserve" }); - if (reserved !== null && args.slug === reserved) { - throw new ContentDeliverySlugReserved({ - contentTypeId: "writes.article", - locale: args.locale, - slug: args.slug, - }); - } - - return await Promise.resolve({ created: true }); + refuse(args); + const created = !rows.has(args.slug); + rows.add(args.slug); + + return await Promise.resolve({ created }); }, retire: async (_tx, args) => { calls.push({ args, kind: "retire" }); - return await Promise.resolve({ retired }); + return await Promise.resolve({ + retired: existing === null ? retired : rows.has(args.slug), + }); }, }; @@ -119,7 +142,7 @@ const tx = {} as ContentDatabase; const apply = async ( definition: AnyContentTypeDefinition, transition: Parameters<typeof applyContentDeliveryWrite>[0]["transition"], - options?: { reserved?: null | string; retired?: boolean }, + options?: Parameters<typeof recorder>[0], ) => { const { calls, model } = recorder(options); const outcome = await applyContentDeliveryWrite({ @@ -249,9 +272,16 @@ describe("moving a published URL", () => { wasPublic: true, }); - // Retire first: a move from `a` to `b` and back to `a` would otherwise hit its - // own live reservation. - expect(calls.map(call => call.kind)).toStrictEqual(["retire", "reserve"]); + // Establish, retire, reserve - and the order is the whole correctness + // argument. The old address has to be on file before it can be retired (that + // is the bootstrap), it has to be retired before the new one is reserved (or a + // move from `a` to `b` and back to `a` would hit its own live reservation), + // and only then does the new address become current. + expect(calls.map(call => call.kind)).toStrictEqual([ + "ensureCurrent", + "retire", + "reserve", + ]); expect(outcome).toMatchObject({ canonicalPath: "/articles/new", previousPath: "/articles/old", @@ -287,7 +317,7 @@ describe("moving a published URL", () => { }); describe("unpublishing and deleting", () => { - it("writes nothing on an unpublish, and keeps the history", async () => { + it("keeps the address on file when a record stops being public", async () => { const { calls, outcome } = await apply(articleType, { isPublic: false, itemId: 1, @@ -298,16 +328,19 @@ describe("unpublishing and deleting", () => { wasPublic: true, }); - // No retire (the slug did not move) and no reserve (it is not public). The - // resolver stops redirecting because it reads the live publication state. - expect(calls).toStrictEqual([]); + // No retire (the slug did not move) and no reserve (it is not public) - but the + // address is established, because an unpublish is a public URL leaving service + // and the reservation is what stops an unrelated record inheriting it. The + // resolver stops answering because it reads the live publication state, not + // because the row went away. + expect(calls.map(call => call.kind)).toStrictEqual(["ensureCurrent"]); expect(outcome).toMatchObject({ sitemap: { contentChanged: true, indexChanged: true }, slugChanged: false, }); }); - it("writes nothing on a delete, and reports the lost sitemap line", async () => { + it("keeps the address on file after a delete, and reports the lost line", async () => { const { calls, outcome } = await apply(articleType, { isPublic: false, itemId: 1, @@ -318,7 +351,12 @@ describe("unpublishing and deleting", () => { wasPublic: true, }); - expect(calls).toStrictEqual([]); + // Deliberately left **current** rather than retired: the record is gone, so + // there is nothing to redirect to, and a retired row would advertise a + // destination that does not exist. Keeping it current keeps the address + // reserved, which is the whole point - somebody's incoming link must not start + // resolving to unrelated content. + expect(calls.map(call => call.kind)).toStrictEqual(["ensureCurrent"]); expect(outcome).toMatchObject({ canonicalPath: null, sitemap: { contentChanged: true, indexChanged: true }, @@ -326,6 +364,22 @@ describe("unpublishing and deleting", () => { slugChanged: false, }); }); + + it("writes nothing when a draft is deleted", async () => { + const { calls } = await apply(articleType, { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "never-live", + slug: null, + wasPublic: false, + }); + + // `wasPublic: false` is the whole difference. A draft's slug was never an + // address, so there is nothing to reserve and nobody to keep it from. + expect(calls).toStrictEqual([]); + }); }); describe("a localized slug", () => { @@ -341,6 +395,19 @@ describe("a localized slug", () => { }); expect(calls).toStrictEqual([ + { + args: { + itemId: 7, + languageId: 2, + locale: "pl", + // The path the old address served, recorded as the historical fact it + // is - locale prefix and all, so a Polish redirect never points at an + // English URL. + path: "/pl/articles/stary", + slug: "stary", + }, + kind: "ensureCurrent", + }, { args: { itemId: 7, languageId: 2, slug: "stary" }, kind: "retire" }, { args: { @@ -374,6 +441,230 @@ describe("a localized slug", () => { }); }); +/** + * A record that was published before this table existed. + * + * Stage 8 ships no backfill migration, on purpose: the database keeps one slug per + * row and no record of which historical values were ever public, so a global scan + * would have to choose between missing live URLs and inventing redirects for slugs + * that only ever existed on a draft. The mutation does not have to choose - it is + * holding the row on both sides of its own write - so the address is established + * lazily, at the moment it leaves service, on the evidence the mutation already has. + * + * `existing: []` is that record: publicly reachable, and with nothing on file. + * Every test here failed before the bootstrap existed. + */ +describe("a record that predates slug history", () => { + it("redirects its first slug change instead of losing the URL", async () => { + const { calls, outcome } = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "world", + wasPublic: true, + }, + { existing: [] }, + ); + + // The bootstrap puts `hello` on file, so the retire that follows has something + // to retire - which is what makes `/articles/hello` answer 308 rather than 404. + // Without it `retire` reported `false` and the first previous address of every + // pre-Stage-8 record was lost permanently. + expect(calls.map(call => call.kind)).toStrictEqual([ + "ensureCurrent", + "retire", + "reserve", + ]); + expect(calls[0].args).toMatchObject({ + path: "/articles/hello", + slug: "hello", + }); + expect(outcome).toMatchObject({ + previousPath: "/articles/hello", + previousSlug: "hello", + redirectCreated: true, + }); + }); + + it("reserves the address it is deleted from, so nobody inherits it", async () => { + const { calls } = await apply( + articleType, + { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: null, + wasPublic: true, + }, + { existing: [] }, + ); + + expect(calls.map(call => call.kind)).toStrictEqual(["ensureCurrent"]); + expect(calls[0].args).toMatchObject({ slug: "hello" }); + }); + + it("reserves the address it is unpublished from", async () => { + const { calls } = await apply( + articleType, + { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: true, + }, + { existing: [] }, + ); + + expect(calls.map(call => call.kind)).toStrictEqual(["ensureCurrent"]); + }); + + it("invents no history for a draft whose slug is corrected", async () => { + const { calls, outcome } = await apply( + articleType, + { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "draft-old", + slug: "draft-new", + wasPublic: false, + }, + { existing: [] }, + ); + + // `wasPublic: false` withholds the evidence, and without evidence nothing is + // written. A redirect from an address nobody could ever visit is worse than no + // redirect: it permanently reserves a URL against the next record that wants it. + expect(calls.map(call => call.kind)).toStrictEqual([ + "retire", + "assertAvailable", + ]); + expect(outcome.redirectCreated).toBe(false); + }); + + it("bootstraps nothing when it stays published at the same address", async () => { + const { calls } = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: true, + }, + { existing: [] }, + ); + + // An ordinary title edit. Nothing is leaving service, so `reserve` alone + // establishes the current address - the bootstrap is for addresses being given + // up, not for every write. + expect(calls.map(call => call.kind)).toStrictEqual(["reserve"]); + }); + + it("stays idempotent when the address is already on file", async () => { + const { calls, outcome } = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "world", + wasPublic: true, + }, + { existing: ["hello"] }, + ); + + // Same call sequence as the pre-Stage-8 case; the difference is invisible from + // out here, which is the point. `ensureCurrent` established nothing because the + // row was already there, and it left it exactly as it found it. + expect(calls.map(call => call.kind)).toStrictEqual([ + "ensureCurrent", + "retire", + "reserve", + ]); + expect(outcome.redirectCreated).toBe(true); + }); + + it("keeps a move away and back idempotent", async () => { + // `a -> b`, on a record whose history predates Stage 8. + const away = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "a", + slug: "b", + wasPublic: true, + }, + { existing: [] }, + ); + expect(away.outcome.redirectCreated).toBe(true); + + // `b -> a`, now that both addresses are on file. `b` retires and `a` comes back + // into service through `reserve`, which is the one call allowed to un-retire. + const back = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "b", + slug: "a", + wasPublic: true, + }, + { existing: ["a", "b"] }, + ); + + expect(back.calls.map(call => call.kind)).toStrictEqual([ + "ensureCurrent", + "retire", + "reserve", + ]); + expect(back.outcome).toMatchObject({ + canonicalPath: "/articles/a", + previousSlug: "b", + redirectCreated: true, + }); + }); + + it("skips an address the engine cannot build a path for", async () => { + const { calls } = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + // A row written straight into the database. It has no buildable URL, so it + // was never addressable and there is nothing to preserve. + previousSlug: " ", + slug: "world", + wasPublic: true, + }, + { existing: [] }, + ); + + expect(calls.map(call => call.kind)).toStrictEqual(["retire", "reserve"]); + }); +}); + describe("delivery without redirects", () => { it("reports the paths and writes no history at all", async () => { const withoutRedirects = defineContentType({ diff --git a/packages/vitnode/src/content/server/delivery-writes.ts b/packages/vitnode/src/content/server/delivery-writes.ts index 551f6ce75..666d1e086 100644 --- a/packages/vitnode/src/content/server/delivery-writes.ts +++ b/packages/vitnode/src/content/server/delivery-writes.ts @@ -128,6 +128,50 @@ export const applyContentDeliveryWrite = async ({ let redirectCreated = false; if (slugHistory !== null) { + /** + * Whether this mutation takes the previous public address out of service. + * + * Three ways it can: the slug moved, the record was deleted (`slug === null`), + * or it stopped being publicly reachable. All three end with a URL that used to + * answer and now does not, which is precisely when history has to hold it. + */ + const leavingService = slugChanged || slug === null || !isPublic; + + // The lazy bootstrap, and the reason Stage 8 ships no backfill migration. + // + // A record published *before* this table existed has no row in it, so the + // first mutation that moved it would find nothing to retire and + // `/articles/hello` would simply be forgotten - a redirect the documentation + // promises and the engine never wrote. Backfilling every content type at + // migration time cannot fix that: the database has one slug per row and no + // record of which historical values were ever public, so it would have to + // choose between missing the same URLs and inventing redirects for slugs that + // only ever existed on a draft. + // + // This mutation does not have to choose. It is holding the row on both sides + // of its own write, so `wasPublic` is evidence: that address was reachable a + // moment ago, and it is going away now. `previousPath !== null` is the second + // half - an address the engine cannot even build a path for was never + // addressable, so there is nothing to preserve. + // + // `ensureCurrent` rather than `reserve`: a row that is already on file is left + // exactly as it stands. Establishing a missing fact and resurrecting a retired + // address are different operations, and only the first one belongs here. + if ( + wasPublic && + previousSlug !== null && + previousPath !== null && + leavingService + ) { + await slugHistory.ensureCurrent(tx, { + itemId, + languageId, + locale, + path: previousPath, + slug: previousSlug, + }); + } + if (slugChanged && previousSlug !== null) { const { retired } = await slugHistory.retire(tx, { itemId, @@ -135,7 +179,8 @@ export const applyContentDeliveryWrite = async ({ slug: previousSlug, }); // A retired row is proof the URL was live: it is only ever written by a - // publish or by a slug change on an already-public record. + // publish, by a slug change on an already-public record, or by the + // bootstrap above - which runs on the same proof. redirectCreated = retired; } diff --git a/packages/vitnode/src/content/server/slug-history-model.ts b/packages/vitnode/src/content/server/slug-history-model.ts index be1ce7b41..432fbbf36 100644 --- a/packages/vitnode/src/content/server/slug-history-model.ts +++ b/packages/vitnode/src/content/server/slug-history-model.ts @@ -73,6 +73,35 @@ export interface ContentSlugHistoryModel { tx: ContentDatabase, args: ContentSlugHistoryTarget, ) => Promise<void>; + /** + * Records an address the caller can **prove** was live, if it is not on file. + * + * The lazy half of slug history, and the reason Stage 8 needs no backfill + * migration. A record published before this table existed has no row at all, so + * the first mutation that moves it off that address would find nothing to + * retire - and `/articles/hello` would be lost the moment somebody renamed it. + * A global backfill is not the answer: it would have to guess which historical + * values were ever public, and a draft's discarded slug must never become a + * redirect. The mutation itself does not have to guess. It is holding the row on + * both sides of its own write, so `wasPublic` is evidence rather than inference, + * and this is where that evidence is spent. + * + * Distinct from {@link reserve} in one deliberate way: a row that already exists + * is left **exactly** as it is, retired or not. This establishes a missing fact; + * it never brings a retired address back into service, which is `reserve`'s job + * and only correct when the record really is live there. + * + * Idempotent, transactional, and throws {@link ContentDeliverySlugReserved} when + * another record owns the address. Writes nothing but the row: no event, no + * cache tag, no search document. + */ + ensureCurrent: ( + tx: ContentDatabase, + args: ContentSlugHistoryTarget & { + /** The path that address served, recorded as the historical fact it is. */ + path: string; + }, + ) => Promise<{ created: boolean }>; /** * Every address one record has ever had, newest first. * @@ -102,10 +131,8 @@ export interface ContentSlugHistoryModel { * task and a double-clicked publish button harmless. * * Throws {@link ContentDeliverySlugReserved} when another record owns the - * address. That check is a `SELECT ... FOR UPDATE` inside the caller's - * transaction rather than a caught unique violation, so the error names the slug - * and the locale instead of a Postgres constraint - and so two concurrent - * reservations of the same URL serialise instead of racing. + * address, whichever order two concurrent writers arrive in - see the `claim` + * helper for why that is an insert rather than a check. */ reserve: ( tx: ContentDatabase, @@ -202,6 +229,62 @@ export const createContentSlugHistoryModel = ({ return row ? toEntry(row) : null; }; + /** + * Takes one address for one record, or says who already has it. + * + * **Insert first, ask afterwards**, and that ordering is the whole point. + * `SELECT ... FOR UPDATE` locks rows that exist; there is no such thing as + * locking a row that does not. So two transactions reserving one *previously + * unseen* address both read nothing, both decide the address is free, and both + * insert - one of them straight into a `23505` from the partial unique index. + * The database stays consistent, but the contract does not: the loser gets a raw + * driver failure that the shared mapper reads as a generic unique clash, where + * Stage 8 promises `CONTENT_DELIVERY_SLUG_RESERVED` naming the slug and the + * locale. Which of the two a caller saw depended on timing. + * + * `ON CONFLICT DO NOTHING` moves the decision into the one place that can make + * it. The second insert takes a speculative-insertion lock, waits for the first + * transaction to finish, and then either does nothing (it committed) or inserts + * after all (it rolled back). `RETURNING` reports which happened, so the loser is + * identified rather than caught, and the address is never claimed by two writers. + * + * The follow-up read still takes `FOR UPDATE`: the row exists by then, and + * locking it is what serialises this claim against a concurrent `retire` of the + * same row. + */ + const claim = async ( + tx: ContentDatabase, + { + itemId, + languageId, + locale, + path, + slug, + }: ContentSlugHistoryTarget & { path: string }, + ): Promise<{ created: boolean }> => { + const [inserted] = await tx + .insert(core_content_slug_history) + .values({ contentTypeId, itemId, languageId, path, pluginId, slug }) + .onConflictDoNothing() + .returning({ id: core_content_slug_history.id }); + + if (inserted) return { created: true }; + + const owner = await findOwner(tx, { languageId, slug }, { lock: true }); + + // A missing owner here means the row that blocked the insert is not visible to + // this transaction's snapshot. Under READ COMMITTED that cannot happen - the + // insert waited for the other transaction and the next statement sees its + // commit - and under a stricter isolation level it means somebody else has the + // address and we simply cannot see them yet. Either way this transaction did + // not get it, and refusing is the only answer that is never wrong. + if (owner?.itemId !== itemId) { + throw new ContentDeliverySlugReserved({ contentTypeId, locale, slug }); + } + + return { created: false }; + }; + return { assertAvailable: async (tx, { itemId, languageId, locale, slug }) => { const owner = await findOwner(tx, { languageId, slug }); @@ -210,6 +293,11 @@ export const createContentSlugHistoryModel = ({ throw new ContentDeliverySlugReserved({ contentTypeId, locale, slug }); }, + // Establish and stop. A row that is already here is left as it stands - a + // retired address stays retired, because this call means "this was live once" + // and never "this is live now". + ensureCurrent: async (tx, args) => await claim(tx, args), + list: async ({ itemId, languageId, limit }, database) => { const conditions = [scope, eq(core_content_slug_history.itemId, itemId)]; if (languageId !== undefined) { @@ -238,55 +326,29 @@ export const createContentSlugHistoryModel = ({ owner: async (args, database) => await findOwner(database ?? c.get("db"), args), - reserve: async (tx, { itemId, languageId, locale, path, slug }) => { - // Locked, so two writers reserving the same address in two transactions - // serialise here rather than both reaching the unique index and one of them - // surfacing a raw `23505`. - const existing = await findOwner( - tx, - { languageId, slug }, - { lock: true }, - ); - - if (existing !== null) { - if (existing.itemId !== itemId) { - throw new ContentDeliverySlugReserved({ - contentTypeId, - locale, - slug, - }); - } - - // Its own row, coming back into service: a slug that moved away and then - // moved back, or a republish of the address it already had. - await tx - .update(core_content_slug_history) - .set({ path, retiredAt: null }) - .where( - and( - scope, - eq(core_content_slug_history.itemId, itemId), - eq(core_content_slug_history.slug, slug), - languageCondition( - languageId, - core_content_slug_history.languageId, - ), - ), - ); - - return { created: false }; - } + reserve: async (tx, args) => { + const { created } = await claim(tx, args); + if (created) return { created: true }; - await tx.insert(core_content_slug_history).values({ - contentTypeId, - itemId, - languageId, - path, - pluginId, - slug, - }); + const { itemId, languageId, path, slug } = args; + + // Its own row, coming back into service: a slug that moved away and then + // moved back, or a republish of the address it already had. `path` is + // rewritten as well as `retiredAt`, because a content type whose + // `publicApi.path` changed serves the address from a different prefix now. + await tx + .update(core_content_slug_history) + .set({ path, retiredAt: null }) + .where( + and( + scope, + eq(core_content_slug_history.itemId, itemId), + eq(core_content_slug_history.slug, slug), + languageCondition(languageId, core_content_slug_history.languageId), + ), + ); - return { created: true }; + return { created: false }; }, retire: async (tx, { itemId, languageId, slug }) => { diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 122c10184..311c83e70 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -43,4 +43,8 @@ export const EXAMPLE_MIGRATIONS = [ // exclusion and the `robots` metadata together. Additive and defaulted, so every // existing row becomes indexable rather than silently disappearing from a sitemap. "0033_add_example_article_no_index.sql", + // The same field on `example.article`, but **nullable** - the shape an upgrade + // actually produces. `NULL` has to mean "indexable" identically in the metadata + // and in the sitemap predicate, and only a nullable column can prove it. + "0034_add_example_article_no_index_flag.sql", ]; diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index ae2fa389c..31001b473 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -16,6 +16,18 @@ export const articleContentType = defineContentType({ excerpt: field.textarea({ maxLength: 500, nullable: true }), views: field.number({ integer: true, min: 0, defaultValue: 0 }), featured: field.boolean({ defaultValue: false }), + /** + * The Stage 8 `noIndexField`, and **nullable** on purpose. + * + * `example.advanced-article` models the other shape - a `NOT NULL` boolean + * with a default - so between the two every state a `noIndexField` can be in + * is exercised against real Postgres. Nullable is the one that matters, + * because `null` has to mean the same thing in two places at once: the + * `robots` metadata reads `value !== true`, and the sitemap predicate has to + * agree with it. A column added to an existing table arrives full of nulls, + * so this is also what an upgrade actually looks like. + */ + noIndex: field.boolean({ nullable: true }), author: field.user(), category: field.relation({ required: true, @@ -29,7 +41,18 @@ export const articleContentType = defineContentType({ publicApi: { enabled: true, path: "articles", - fields: ["title", "slug", "excerpt", "featured", "category", "publishedAt"], + fields: [ + "title", + "slug", + "excerpt", + "featured", + "category", + // Public because delivery projects it: `robots: { index: false }` is + // rendered into the page, so the field behind it has to be one the public + // API would already have said out loud. + "noIndex", + "publishedAt", + ], searchableFields: ["title", "excerpt"], orderableFields: ["publishedAt", "title"], filterableFields: ["category", "featured"], @@ -67,6 +90,11 @@ export const articleContentType = defineContentType({ seo: { titleField: "title", descriptionField: "excerpt", + // Nullable, so `null` and `false` both mean "list it and let it be indexed" + // while only `true` withholds it. One boolean drives the `robots` metadata + // and the sitemap predicate together, which is what stops the page saying + // `index: true` while the sitemap quietly leaves it out. + noIndexField: "noIndex", // Same fields in both slots, which is the common case: an author who wants a // different social title names a different field, and one who does not says // so in two lines rather than four. diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index b966301fd..9bb2118f5 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -1,5 +1,8 @@ import type { SearchDocument } from "@vitnode/core/api/models/search"; -import type { ContentDeliverySitemapPage } from "@vitnode/core/content/server"; +import type { + ContentDatabase, + ContentDeliverySitemapPage, +} from "@vitnode/core/content/server"; import type { Context } from "hono"; import { @@ -10,8 +13,11 @@ import { contentDeliveryEffects, contentEditorialEffects, contentTranslationEffects, + createContentSlugHistoryModel, + withHttpErrors, } from "@vitnode/core/content/server"; import { drizzle } from "drizzle-orm/postgres-js"; +import { HTTPException } from "hono/http-exception"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -889,6 +895,305 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // of the two may take it. expect(results.every(result => result.status === "rejected")).toBe(true); }); + + /** + * Two writers reaching for an address **nobody has ever held**. + * + * A different case from the one above, and the one a check-then-insert cannot + * survive. `SELECT ... FOR UPDATE` locks rows that exist; there is nothing to + * lock when the row is missing, so both transactions read nothing, both + * conclude the address is free, and the second insert lands on the partial + * unique index as a raw `23505` - a driver failure where the contract promises + * `CONTENT_DELIVERY_SLUG_RESERVED`, and a different answer depending on which + * transaction happened to be quicker. + * + * The overlap here is constructed rather than hoped for: the first transaction + * is parked open after its insert, so the second one is genuinely inside the + * window while the first is uncommitted. + */ + it("refuses the loser when two writers insert one unseen address at once", async () => { + const history = createContentSlugHistoryModel({ + c: context, + definition: articleContent.definition, + pluginId: PLUGIN, + }); + const rivalDb = rivalContext.get("db") as ContentDatabase; + + const first = await publishArticle({ title: "First" }); + const second = await publishArticle({ title: "Second" }); + + const target = { + languageId: null, + locale: null, + path: "/articles/totally-new-contested-slug", + slug: "totally-new-contested-slug", + }; + + // Nothing owns it. That is the whole premise. + expect( + await history.owner({ languageId: null, slug: target.slug }), + ).toBeNull(); + + let inserted!: () => void; + const hasInserted = new Promise<void>(resolve => { + inserted = resolve; + }); + let release!: () => void; + const mayCommit = new Promise<void>(resolve => { + release = resolve; + }); + + const winner = db.transaction(async tx => { + await history.reserve(tx, { + ...target, + itemId: first.id, + }); + inserted(); + // Parked: the row is written and the transaction is still open, which is + // exactly the window the second writer has to survive. + await mayCommit; + + return "first" as const; + }); + + await hasInserted; + + const loser = rivalDb.transaction( + async tx => + await history.reserve(tx as ContentDatabase, { + ...target, + itemId: second.id, + }), + ); + + // Long enough for the second INSERT to reach the speculative-insertion lock + // and block on it, then let the first transaction commit. + await new Promise(resolve => setTimeout(resolve, 150)); + release(); + + const results = await Promise.allSettled([winner, loser]); + + expect(results.map(result => result.status)).toStrictEqual([ + "fulfilled", + "rejected", + ]); + // The domain error, naming the address - never a bare `23505` for the shared + // mapper to read as a generic unique clash. + const rejection = results[1] as PromiseRejectedResult; + expect(rejection.reason).toBeInstanceOf(ContentDeliverySlugReserved); + expect(rejection.reason).toMatchObject({ + contentTypeId: "example.article", + locale: null, + slug: target.slug, + }); + + // Exactly one owner, and it is the writer that won. + const owners = await sql<{ itemId: number }[]>` + SELECT "itemId" FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.article' AND "slug" = ${target.slug} + `; + expect(owners.map(row => row.itemId)).toStrictEqual([first.id]); + }); + + /** + * The same unseen address, in two languages, at the same time. + * + * Both must succeed: uniqueness is scoped per locale, so `/en/x/hello` and + * `/pl/x/hello` are two addresses. The conflict is resolved by the partial + * unique index rather than by a target named in the insert, so this is what + * catches the reservation ever becoming locale-blind - a mistake that would + * only show up as one language silently unable to reuse the other's slug. + */ + it("lets two locales take the same unseen address concurrently", async () => { + const history = createContentSlugHistoryModel({ + c: context, + definition: advancedArticleContent.definition, + pluginId: PLUGIN, + }); + const rivalDb = rivalContext.get("db") as ContentDatabase; + + const article = await publishLocalized({ + pl: "Polski", + title: "English", + }); + const slug = "shared-across-locales"; + + const results = await Promise.allSettled([ + db.transaction( + async tx => + await history.ensureCurrent(tx, { + itemId: article.id, + languageId: localeIds.en, + locale: "en", + path: `/en/advanced-articles/${slug}`, + slug, + }), + ), + rivalDb.transaction( + async tx => + await history.ensureCurrent(tx, { + itemId: article.id, + languageId: localeIds.pl, + locale: "pl", + path: `/pl/advanced-articles/${slug}`, + slug, + }), + ), + ]); + + expect(results.map(result => result.status)).toStrictEqual([ + "fulfilled", + "fulfilled", + ]); + + const rows = await sql<{ languageId: number }[]>` + SELECT "languageId" FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.advanced-article' AND "slug" = ${slug} + ORDER BY "languageId" + `; + expect(rows.map(row => row.languageId)).toStrictEqual( + [localeIds.en, localeIds.pl].sort((a, b) => a - b), + ); + }); + + /** + * The same race, driven through the whole editorial mutation. + * + * The refusal comes from a different index than the test above, and the + * distinction is the contract rather than an implementation detail. A slug + * field carries a unique index on the **content table**, so two published + * records moving onto one brand-new address collide there first - one + * statement before delivery runs - and the loser is told the address is taken + * *now*. That is `CONTENT_UNIQUE_CONFLICT`, and it is the honest answer: + * `CONTENT_DELIVERY_SLUG_RESERVED` means "another record used to hold this and + * it still redirects there", which is not what happened. + * + * Reordering the write so delivery answered first would be worse than the + * wording: it would reserve an address for a writer that had not yet won its + * version race. + * + * What this pins down is what a caller observes end to end: one winner, one + * refusal, and a loser whose transaction left nothing at all behind. + */ + it("keeps one winner and rolls the loser back completely", async () => { + const first = await publishArticle({ title: "Racer one" }); + const second = await publishArticle({ title: "Racer two" }); + + const results = await Promise.allSettled([ + editorial()?.update( + first.id, + { slug: "totally-new-contested-slug" }, + { actor: ACTOR, expectedVersion: first.version }, + ), + editorial(rivalContext)?.update( + second.id, + { slug: "totally-new-contested-slug" }, + { actor: ACTOR, expectedVersion: second.version }, + ), + ]); + + expect( + results.filter(result => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter(result => result.status === "rejected"), + ).toHaveLength(1); + + // One owner of the contested address, in the live table and in history alike. + const live = await sql<{ id: number }[]>` + SELECT "id" FROM "example_articles" + WHERE "slug" = 'totally-new-contested-slug' + `; + expect(live).toHaveLength(1); + + const owners = await sql<{ itemId: number }[]>` + SELECT "itemId" FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.article' + AND "slug" = 'totally-new-contested-slug' + `; + expect(owners.map(row => row.itemId)).toStrictEqual([live[0].id]); + + const winnerId = live[0].id; + const loserId = winnerId === first.id ? second.id : first.id; + const loserVersion = + loserId === first.id ? first.version : second.version; + + // The loser's transaction left nothing at all: not the slug, not the version + // bump, not a revision, not a history row. + const [row] = await sql<{ slug: string; version: number }[]>` + SELECT "slug", "version" FROM "example_articles" WHERE "id" = ${loserId} + `; + expect(row.slug).not.toBe("totally-new-contested-slug"); + expect(row.version).toBe(loserVersion); + + const loserHistory = await historyRows(loserId); + expect( + loserHistory.filter( + entry => entry.slug === "totally-new-contested-slug", + ), + ).toStrictEqual([]); + + const revisions = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_revisions" + WHERE "contentTypeId" = 'example.article' + AND "itemId" = ${loserId} + AND "snapshot"->>'slug' = 'totally-new-contested-slug' + `; + expect(revisions[0].count).toBe(0); + }); + + /** + * What the loser is actually told, at the boundary a client can see. + * + * A service throws whatever refused the write - that is the Stage 1-7 design, + * and the routes wrap every mutation in `withHttpErrors` for exactly this + * reason. So the assertion belongs here rather than on the service call: a + * SQLSTATE, a constraint name and a table name must never reach a response + * body, whichever of the two indexes did the refusing. + */ + it("maps the losing write onto a structured 409, never a SQLSTATE", async () => { + const holder = await publishArticle({ title: "Holder" }); + const other = await publishArticle({ title: "Other" }); + + const response = await withHttpErrors( + "update", + async () => + await editorial()?.update( + other.id, + // Taken on the live table, and by a record that still holds it. + { slug: "holder" }, + { actor: ACTOR, expectedVersion: other.version }, + ), + { + contentTypeId: "example.article", + itemId: other.id, + structured: true, + }, + ).catch(async (error: unknown) => { + if (!(error instanceof HTTPException)) throw error; + // Once: reading the body twice would consume it. + const raw = error.getResponse(); + + return { body: await raw.text(), status: raw.status }; + }); + + expect(response).toMatchObject({ status: 409 }); + const { body } = response as { body: string }; + expect(JSON.parse(body)).toStrictEqual({ + code: "CONTENT_UNIQUE_CONFLICT", + contentTypeId: "example.article", + itemId: other.id, + }); + for (const internal of [ + "23505", + "example_articles_slug_key", + "example_articles", + "Failed query", + ]) { + expect(body).not.toContain(internal); + } + expect(holder.id).not.toBe(other.id); + }); }); // ------------------------------------------------------------------------- @@ -1606,6 +1911,294 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // Stage 1-7 regression // ------------------------------------------------------------------------- + /** + * Records that existed before `core_content_slug_history` did. + * + * Stage 8 ships no backfill migration, so an install that upgrades has published + * rows with no history at all. Deleting the rows after publishing reproduces that + * state exactly - the article is live at an address the table has never heard of. + * + * Every test here failed before the lazy bootstrap: the first mutation that moved + * such a record found nothing to retire, and its previous public URL was gone. + */ + describe("a record that predates slug history", () => { + /** Publishes, then forgets - an article as an upgraded install would have it. */ + const publishWithoutHistory = async (values: Record<string, unknown>) => { + const article = await publishArticle(values); + await sql`DELETE FROM "core_content_slug_history"`; + + return article; + }; + + it("redirects its first slug change instead of losing the URL", async () => { + const article = await publishWithoutHistory({ title: "Hello" }); + expect(await historyRows(article.id)).toStrictEqual([]); + + await editorial()?.update( + article.id, + { slug: "world" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + // The address it used to answer to, retired; the one it answers to now, + // current. Both established by this one mutation. + expect(await historyRows(article.id)).toStrictEqual([ + { path: "/articles/hello", retired: true, slug: "hello" }, + { path: "/articles/world", retired: false, slug: "world" }, + ]); + + // The promise the documentation makes, actually kept. + expect(await delivery()?.resolvePath("/articles/hello")).toStrictEqual({ + location: "/articles/world", + status: 308, + type: "redirect", + }); + }); + + it("emits the redirect event, because the URL really was live", async () => { + const article = await publishWithoutHistory({ title: "Announced" }); + + const outcome = await editorial()?.update( + article.id, + { slug: "announced-two" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + emitted.length = 0; + await contentDeliveryEffects( + context, + articleContent.definition, + outcome.delivery, + { pluginId: PLUGIN }, + ); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.example.article.delivery_slug_changed", + "content.example.article.delivery_redirect_created", + ]); + }); + + it("invents no history when a draft's slug is corrected", async () => { + const draft = await createArticle({ title: "Draft one" }); + await sql`DELETE FROM "core_content_slug_history"`; + + await editorial()?.update( + draft.id, + { slug: "draft-two" }, + { actor: ACTOR, expectedVersion: draft.version }, + ); + + // Never public, so there is no evidence and nothing is written. A redirect + // here would permanently reserve an address nobody could ever have visited. + expect(await historyRows(draft.id)).toStrictEqual([]); + expect( + await delivery()?.resolvePath("/articles/draft-one"), + ).toStrictEqual({ type: "not_found" }); + }); + + it("keeps the address reserved after a delete", async () => { + const article = await publishWithoutHistory({ title: "Retired" }); + + await editorial()?.delete(article.id, { + actor: ACTOR, + expectedVersion: article.version, + }); + + // The record is gone and the address is still spoken for, which is what stops + // an unrelated article inheriting somebody's incoming links. + expect(await historyRows(article.id)).toStrictEqual([ + { path: "/articles/retired", retired: false, slug: "retired" }, + ]); + + const other = await createArticle({ title: "Opportunist" }); + await expect( + editorial()?.update( + other.id, + { slug: "retired" }, + { actor: ACTOR, expectedVersion: other.version }, + ), + ).rejects.toThrow(ContentDeliverySlugReserved); + }); + + it("keeps the address reserved after an unpublish, and serves it again", async () => { + const article = await publishWithoutHistory({ title: "Paused" }); + + const unpublished = await editorial()?.unpublish(article.id, { + actor: ACTOR, + }); + if (!unpublished) throw new Error("unpublish returned nothing"); + + expect(await historyRows(article.id)).toStrictEqual([ + { path: "/articles/paused", retired: false, slug: "paused" }, + ]); + // Withdrawn, so the URL answers nothing while it is down. + expect(await delivery()?.resolvePath("/articles/paused")).toStrictEqual({ + type: "not_found", + }); + + await editorial()?.publish(article.id, { actor: ACTOR }); + + expect(await delivery()?.resolvePath("/articles/paused")).toMatchObject({ + canonicalPath: "/articles/paused", + type: "content", + }); + expect(await historyRows(article.id)).toStrictEqual([ + { path: "/articles/paused", retired: false, slug: "paused" }, + ]); + }); + + it("stays idempotent across a move away and back", async () => { + const article = await publishWithoutHistory({ title: "Wanderer" }); + + const away = await editorial()?.update( + article.id, + { slug: "elsewhere" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!away) throw new Error("update returned nothing"); + + await editorial()?.update( + article.id, + { slug: "wanderer" }, + { actor: ACTOR, expectedVersion: away.version }, + ); + + // Two rows, never three: the original address came back into service through + // its own row rather than gaining a duplicate. + expect(await historyRows(article.id)).toStrictEqual([ + { path: "/articles/wanderer", retired: false, slug: "wanderer" }, + { path: "/articles/elsewhere", retired: true, slug: "elsewhere" }, + ]); + expect( + await delivery()?.resolvePath("/articles/elsewhere"), + ).toStrictEqual({ + location: "/articles/wanderer", + status: 308, + type: "redirect", + }); + }); + + it("refuses to bootstrap an address another record owns", async () => { + // Two records, one address: the first retires `contested`, and the second is + // a pre-Stage-8 row that Postgres says is also sitting on it. That cannot be + // silently absorbed into the second record's history. + const first = await publishArticle({ title: "Contested" }); + await editorial()?.update( + first.id, + { slug: "moved-on" }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + const second = await publishArticle({ title: "Second" }); + await sql` + DELETE FROM "core_content_slug_history" WHERE "itemId" = ${second.id} + `; + await sql` + UPDATE "example_articles" SET "slug" = 'contested' WHERE "id" = ${second.id} + `; + + await expect( + editorial()?.update( + second.id, + { slug: "second-moved" }, + { actor: ACTOR, expectedVersion: second.version }, + ), + ).rejects.toThrow(ContentDeliverySlugReserved); + }); + }); + + /** + * A `noIndexField` that is nullable, which is what an upgrade actually produces. + * + * The metadata reads `value !== true`, so `null` means "index me". The sitemap + * has to say the same thing, and `<> TRUE` cannot: `NULL <> TRUE` is `NULL`, and + * a `WHERE` clause drops every row it cannot prove. So a nullable flag used to + * empty the sitemap of every record nobody had ever set it on, while each of + * their pages rendered `robots: { index: true }` - a contradiction that only + * shows up in a crawler's log. + */ + describe("a nullable noIndex flag", () => { + const sitemapSlugs = async (): Promise<string[]> => { + const page = await delivery()?.sitemap(); + + return (page?.entries ?? []).map(entry => entry.path); + }; + + it("treats null as indexable, in the metadata and the sitemap alike", async () => { + const article = await publishArticle({ title: "Never set" }); + + // Exactly the state `ALTER TABLE ... ADD COLUMN "noIndex" boolean` leaves. + const [row] = await sql<{ noIndex: boolean | null }[]>` + SELECT "noIndex" FROM "example_articles" WHERE "id" = ${article.id} + `; + expect(row.noIndex).toBeNull(); + + const metadata = await delivery()?.findById(article.id); + expect(metadata?.robots).toStrictEqual({ follow: true, index: true }); + expect(await sitemapSlugs()).toStrictEqual(["/articles/never-set"]); + }); + + it("treats false the same way", async () => { + const article = await publishArticle({ title: "Explicitly false" }); + await editorial()?.update( + article.id, + { noIndex: false }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const metadata = await delivery()?.findById(article.id); + expect(metadata?.robots).toStrictEqual({ follow: true, index: true }); + expect(await sitemapSlugs()).toStrictEqual([ + "/articles/explicitly-false", + ]); + }); + + it("withholds only an explicit true", async () => { + const article = await publishArticle({ title: "Hidden" }); + await editorial()?.update( + article.id, + { noIndex: true }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const metadata = await delivery()?.findById(article.id); + expect(metadata?.robots).toStrictEqual({ follow: true, index: false }); + expect(await sitemapSlugs()).toStrictEqual([]); + }); + + it("keeps the sitemap and the metadata agreeing across all three states", async () => { + const nullish = await publishArticle({ title: "State null" }); + const explicit = await publishArticle({ title: "State false" }); + await editorial()?.update( + explicit.id, + { noIndex: false }, + { actor: ACTOR, expectedVersion: explicit.version }, + ); + const hidden = await publishArticle({ title: "State true" }); + await editorial()?.update( + hidden.id, + { noIndex: true }, + { actor: ACTOR, expectedVersion: hidden.version }, + ); + + const listed = await sitemapSlugs(); + + // One boolean drives both, so "in the sitemap" and "robots says index me" + // have to be the same set. Asserted as a pair rather than separately, + // because the bug was that they disagreed. + for (const article of [nullish, explicit, hidden]) { + const metadata = await delivery()?.findById(article.id); + expect([ + article.id, + listed.includes(metadata?.canonicalPath ?? ""), + ]).toStrictEqual([article.id, metadata?.robots?.index ?? false]); + } + + expect(listed).toHaveLength(2); + }); + }); + describe("a content type without delivery", () => { it("has no delivery service and writes no history", async () => { expect(categoryContent.deliveryService).toBeUndefined(); diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index 7413ebd4f..ee2588d0b 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -831,6 +831,9 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { "category", "excerpt", "featured", + // Public because Stage 8 projects it into `robots`: a field the page renders + // has to be one the public API would already have said out loud. + "noIndex", "publishedAt", "slug", "title", @@ -2086,6 +2089,9 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { "excerpt", "featured", "id", + // `delivery.seo.noIndexField`. Nullable, so the column arrives without a + // default and every existing row reads as indexable. + "noIndex", "publishedAt", "slug", "status", From dd4327645f117ecc822ddec26c05119db2a8e607 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Mon, 10 Aug 2026 11:12:26 +0200 Subject: [PATCH 109/123] feat(content): support page and custom-layout admin forms Two additions to the generated AdminCP, both opt-in and both defaulting to exactly what a content type does today. `admin.create.mode` and `admin.edit.mode` take `"dialog"` or `"page"`, and default to `"dialog"` - so nothing about an existing content type moves. Page mode is served by the *same* catch-all route as the list, resolved exact-content-type-first so an id ending in `.create` keeps its own screen: /admin/content/blog/post list /admin/content/blog/post/create create /admin/content/blog/post/42/edit edit The Create button and the row pencil become links rather than dialogs that mount and redirect, and both pages check `can_view` plus their own permission on the server - a URL typed into the address bar answers the way the button would have. A create hands over to the new record's edit page when there is one, using the id the mutation now returns. `forms.layout` on the frontend registration lets a plugin place the fields itself. It is presentation only: the engine keeps the schema, the validation, the defaults, the mutation, the version precondition, the structured errors, the toast, the invalidation, the events, the search write and the delivery effects. `ContentFormField` renders the element the engine already built - including its field override - so overrides and layouts compose. The layout is a client component referenced from a *server* config, so its props are serialisable and everything else reaches it through client context. A `renderField(name)` callback would read better and would be a server closure, which cannot cross that boundary at all. Two fixes fall out of the same work: - the locale tabs rendered no inputs at all. `TranslationPanel` handed AutoForm bare field ids, and AutoForm renders nothing for a field with no component - so every localized content type had a form nobody could type into. - `GET /{id}` now answers with `labels`, the way the list already did. It is the read a form makes, and a relation picker showing `3` instead of a name was the only thing stopping page mode from opening on a complete record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/package.json | 5 + .../vitnode/src/components/form/auto-form.tsx | 68 ++++ .../vitnode/src/content/admin/route.test.ts | 125 +++++++ packages/vitnode/src/content/admin/route.ts | 89 +++++ packages/vitnode/src/content/const.ts | 21 ++ packages/vitnode/src/content/define.test-d.ts | 41 +++ packages/vitnode/src/content/define.test.ts | 34 ++ packages/vitnode/src/content/define.ts | 38 ++- packages/vitnode/src/content/index.ts | 6 + packages/vitnode/src/content/registry.test.ts | 16 + packages/vitnode/src/content/registry.ts | 30 ++ .../src/content/server/openapi-parity.test.ts | 3 +- .../vitnode/src/content/server/routes.test.ts | 11 +- packages/vitnode/src/content/server/routes.ts | 9 +- .../src/content/server/search-sync.test.ts | 1 + .../vitnode/src/content/server/service.ts | 41 +++ packages/vitnode/src/content/types.ts | 35 +- packages/vitnode/src/lib/plugin.test.ts | 39 +++ packages/vitnode/src/lib/plugin.ts | 79 +++++ packages/vitnode/src/locales/en.json | 3 + .../admin/content/[...slug]/page.tsx | 56 +++- .../views/content/actions/content-form.tsx | 170 ++++++---- .../views/content/actions/create-action.tsx | 20 +- .../views/content/actions/edit-action.tsx | 27 ++ .../content/actions/mutation-api.server.ts | 10 +- .../views/content/actions/page-links.test.tsx | 97 ++++++ .../actions/translations/locale-editor.tsx | 2 + .../translations/translation-panel.test.tsx | 126 +++++++ .../translations/translation-panel.tsx | 69 +++- .../views/content/content-admin-view.tsx | 143 +++++--- .../admin/views/content/form/context.tsx | 108 ++++++ .../views/admin/views/content/form/index.ts | 26 ++ .../admin/views/content/form/layout.test.tsx | 263 +++++++++++++++ .../admin/views/content/form/primitives.tsx | 200 ++++++++++++ .../views/content/form/publication-status.tsx | 44 +++ .../views/content/page/content-form-page.tsx | 93 ++++++ .../views/content/page/page-views.test.tsx | 309 ++++++++++++++++++ .../admin/views/content/page/page-views.tsx | 229 +++++++++++++ .../content/table/content-table-view.tsx | 9 +- 39 files changed, 2568 insertions(+), 127 deletions(-) create mode 100644 packages/vitnode/src/content/admin/route.test.ts create mode 100644 packages/vitnode/src/content/admin/route.ts create mode 100644 packages/vitnode/src/lib/plugin.test.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/context.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/index.ts create mode 100644 packages/vitnode/src/views/admin/views/content/form/layout.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/primitives.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/publication-status.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/page/page-views.tsx diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index a2dfe6a95..045807316 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -101,6 +101,11 @@ "types": "./dist/src/content/next/revalidate-route.server.d.ts", "default": "./dist/src/content/next/revalidate-route.server.js" }, + "./content/admin-form": { + "import": "./dist/src/views/admin/views/content/form/index.js", + "types": "./dist/src/views/admin/views/content/form/index.d.ts", + "default": "./dist/src/views/admin/views/content/form/index.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 79eb3a58d..24677827a 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -10,7 +10,9 @@ import { type FieldValues, type Mode, useForm, + useFormContext, type UseFormReturn, + useFormState, } from "react-hook-form"; import z from "zod"; @@ -98,6 +100,40 @@ function AutoFormField({ return <Field data-invalid={invalid} ref={scope} {...props} />; } +/** + * The submit button of the surrounding `AutoForm`, for a `layout` that has to + * place it itself. + * + * Reads the form through context rather than taking props, so it stays in step + * with validity and submission exactly like the built-in one - and so a layout + * cannot wire up a button that submits a different form. + */ +export const AutoFormSubmitButton = ({ + children, + className, + variant, +}: { + children?: React.ReactNode; + className?: string; + variant?: React.ComponentProps<typeof Button>["variant"]; +}) => { + const t = useTranslations("core.global"); + const { control } = useFormContext(); + const { isSubmitting, isValid } = useFormState({ control }); + + return ( + <Button + className={className} + disabled={!isValid || isSubmitting} + isLoading={isSubmitting} + type="submit" + variant={variant} + > + {children ?? t("submit")} + </Button> + ); +}; + export type AutoFormOnSubmit< T extends z.ZodObject<z.ZodRawShape>, TContext = unknown, @@ -118,6 +154,7 @@ export function AutoForm< onSubmit: onSubmitProp, captcha, fields, + layout, tabs, submitButtonProps, children, @@ -126,6 +163,19 @@ export function AutoForm< captcha?: z.infer<typeof routeMiddlewareSchema>["captcha"]; fields: ItemAutoFormProps<T>[]; formSchema: T; + /** + * Places the fields yourself instead of stacking them in declaration order. + * + * Called with every field already rendered and keyed by its `id`, so a layout + * puts an element where it wants it and each one stays wired into this form's + * validation, dirty state and error display. One `<form>`, one schema, one + * submit - a layout cannot accidentally create a second of any of them. + * + * The automatic submit button is **not** rendered in this mode: a layout that + * decides where the fields go has to decide where the button goes too. + * Mutually exclusive with `tabs`. + */ + layout?: (renderedFields: Record<string, React.ReactNode>) => React.ReactNode; mode?: Mode; onSubmit?: AutoFormOnSubmit<T, TContext>; submitButtonProps?: Omit< @@ -272,6 +322,24 @@ export function AutoForm< </Button> ); + if (layout) { + return ( + <Form form={form} onSubmit={onSubmit} {...props}> + {layout( + Object.fromEntries( + fields + .filter(isFieldVisible) + .map(item => [item.id, renderField(item)]), + ), + )} + + {children} + + {captcha && <div id="vitnode_captcha" />} + </Form> + ); + } + return ( <Form form={form} onSubmit={onSubmit} {...props}> {tabs?.length ? ( diff --git a/packages/vitnode/src/content/admin/route.test.ts b/packages/vitnode/src/content/admin/route.test.ts new file mode 100644 index 000000000..060bafbae --- /dev/null +++ b/packages/vitnode/src/content/admin/route.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { resolveContentAdminRoute } from "./route"; + +const define = ( + id: string, + admin: Partial<Parameters<typeof defineContentType>[0]["admin"]> = {}, +): AnyContentTypeDefinition => + defineContentType({ + id, + tableName: id.split(".").join("_"), + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Posts", singular: "Post" }, ...admin }, + }) as AnyContentTypeDefinition; + +const dialogPost = define("blog.post"); +const pagePost = define("blog.post", { + create: { mode: "page" }, + edit: { mode: "page" }, +}); +const createOnly = define("blog.post", { create: { mode: "page" } }); + +const lookupOf = + (...definitions: AnyContentTypeDefinition[]) => + (id: string) => + definitions.find(definition => definition.id === id); + +describe("resolveContentAdminRoute", () => { + it("resolves the list of a registered content type", () => { + expect( + resolveContentAdminRoute(["blog", "post"], lookupOf(dialogPost)), + ).toEqual({ action: "list", contentTypeId: "blog.post" }); + }); + + it("resolves nothing for an unknown content type", () => { + expect( + resolveContentAdminRoute(["blog", "nope"], lookupOf(dialogPost)), + ).toBeUndefined(); + }); + + it("resolves nothing for an empty slug", () => { + expect(resolveContentAdminRoute([], lookupOf(dialogPost))).toBeUndefined(); + }); + + describe("page mode", () => { + it("resolves the create page", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(pagePost), + ), + ).toEqual({ action: "create", contentTypeId: "blog.post" }); + }); + + it("resolves the edit page", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "42", "edit"], + lookupOf(pagePost), + ), + ).toEqual({ action: "edit", contentTypeId: "blog.post", itemId: 42 }); + }); + + it("refuses a form URL of a dialog-mode content type", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(dialogPost), + ), + ).toBeUndefined(); + expect( + resolveContentAdminRoute( + ["blog", "post", "1", "edit"], + lookupOf(dialogPost), + ), + ).toBeUndefined(); + }); + + it("gates each action on its own mode", () => { + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(createOnly), + ), + ).toEqual({ action: "create", contentTypeId: "blog.post" }); + expect( + resolveContentAdminRoute( + ["blog", "post", "1", "edit"], + lookupOf(createOnly), + ), + ).toBeUndefined(); + }); + + it.each([ + ["a missing identifier", ["blog", "post", "edit"]], + ["a non-numeric identifier", ["blog", "post", "abc", "edit"]], + ["a zero identifier", ["blog", "post", "0", "edit"]], + ["a padded identifier", ["blog", "post", "01", "edit"]], + ["a negative identifier", ["blog", "post", "-1", "edit"]], + ["a fractional identifier", ["blog", "post", "1.5", "edit"]], + ])("resolves nothing for %s", (_name, slug) => { + expect( + resolveContentAdminRoute(slug, lookupOf(pagePost)), + ).toBeUndefined(); + }); + + it("prefers an exact content type id over a create page", () => { + // `blog.post.create` is a legal id, so the content type that really is + // called that keeps its own list screen. + const literal = define("blog.post.create"); + + expect( + resolveContentAdminRoute( + ["blog", "post", "create"], + lookupOf(pagePost, literal), + ), + ).toEqual({ action: "list", contentTypeId: "blog.post.create" }); + }); + }); +}); diff --git a/packages/vitnode/src/content/admin/route.ts b/packages/vitnode/src/content/admin/route.ts new file mode 100644 index 000000000..6b67506ef --- /dev/null +++ b/packages/vitnode/src/content/admin/route.ts @@ -0,0 +1,89 @@ +import type { AnyContentTypeDefinition } from "../types"; + +import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, +} from "../const"; +import { pathToContentTypeId } from "../registry"; + +/** What `/admin/content/[...slug]` was actually asked for. */ +export type ContentAdminAction = "create" | "edit" | "list"; + +export interface ContentAdminRoute { + action: ContentAdminAction; + /** The content type id the slug resolved to. */ + contentTypeId: string; + /** The record being edited. Only ever set for `edit`. */ + itemId?: number; +} + +/** + * A "does this id exist" predicate, so the resolver stays a pure function. + * + * It has to be able to ask, rather than just split on the last segment: a + * content type is free to be called `blog.post.create`, and the exact match has + * to win over the create page of `blog.post`. + */ +export type ContentTypeLookup = ( + contentTypeId: string, +) => AnyContentTypeDefinition | undefined; + +/** Only a positive integer is a record id - `01`, `1.5` and `-1` are not. */ +const parseItemId = (segment: string | undefined): null | number => { + if (segment === undefined || !/^[1-9][0-9]*$/.test(segment)) return null; + + const id = Number(segment); + + return Number.isSafeInteger(id) ? id : null; +}; + +/** + * Maps the catch-all slug onto a content type and one of three screens. + * + * One route serves all of them, which is the same trade the list screen already + * made: a second Next.js router keyed on content type ids would mean two files + * per app per screen, and the whole point of the generated AdminCP is that a + * plugin adds a content type without adding a file. + * + * Resolution order is exact-match-first, and that matters. `blog.post.create` is + * a legal content type id, so `["blog", "post", "create"]` has two readings; the + * one where a registered content type keeps its own list screen wins, and the + * create page of `blog.post` is then simply unreachable - which is a name clash + * its author can see and fix, rather than a screen that silently disappeared. + * + * `undefined` for anything that resolves to nothing, and for a form URL of a + * content type that did not opt into page mode: a dialog-mode content type + * answering `/create` would be a second, unstyled way into the same form. + */ +export const resolveContentAdminRoute = ( + slug: readonly string[], + lookup: ContentTypeLookup, +): ContentAdminRoute | undefined => { + if (slug.length === 0) return undefined; + + const exact = pathToContentTypeId(slug); + if (lookup(exact)) return { action: "list", contentTypeId: exact }; + + const last = slug[slug.length - 1]; + + if (last === CONTENT_ADMIN_CREATE_SEGMENT) { + const contentTypeId = pathToContentTypeId(slug.slice(0, -1)); + const definition = lookup(contentTypeId); + if (definition?.admin.create.mode !== "page") return undefined; + + return { action: "create", contentTypeId }; + } + + if (last === CONTENT_ADMIN_EDIT_SEGMENT) { + const itemId = parseItemId(slug[slug.length - 2]); + if (itemId === null) return undefined; + + const contentTypeId = pathToContentTypeId(slug.slice(0, -2)); + const definition = lookup(contentTypeId); + if (definition?.admin.edit.mode !== "page") return undefined; + + return { action: "edit", contentTypeId, itemId }; + } + + return undefined; +}; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 1db79fcab..a55759f26 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -556,6 +556,27 @@ export const CONTENT_DELIVERY_CODES = { slugReserved: "CONTENT_DELIVERY_SLUG_RESERVED", } as const; +/** + * How the AdminCP may present a create or an edit form. + * + * `dialog` is first because it is the default, and the default is the whole + * point: a content type written before page mode existed keeps the screen it + * already had, and nothing about its behaviour moves until somebody says so. + */ +export const CONTENT_ADMIN_FORM_MODES = ["dialog", "page"] as const; + +/** + * The last URL segment of a generated create page, and of an edit one. + * + * Reserved rather than free-form: `/admin/content/[...slug]` resolves a content + * type id from the same slug, so these two words are what tells + * `/admin/content/blog/post` from `/admin/content/blog/post/create`. A content + * type that genuinely wants to be called `blog.post.create` still wins - the + * exact match is tried first. + */ +export const CONTENT_ADMIN_CREATE_SEGMENT = "create"; +export const CONTENT_ADMIN_EDIT_SEGMENT = "edit"; + /** * Every content type gets the first four staff permissions. `can_publish` is * generated only for content types with `publication: { enabled: true }`, diff --git a/packages/vitnode/src/content/define.test-d.ts b/packages/vitnode/src/content/define.test-d.ts index 659bec45e..e0959fd24 100644 --- a/packages/vitnode/src/content/define.test-d.ts +++ b/packages/vitnode/src/content/define.test-d.ts @@ -178,4 +178,45 @@ describe("content type inference", () => { expectTypeOf<HasColumnDefault<Fields["author"]>>().toEqualTypeOf<false>(); }); }); + + describe("admin form presentation", () => { + it("accepts the two presentation modes", () => { + expectTypeOf( + defineContentType({ + id: "test.modes", + tableName: "test_modes", + fields: { title: field.text({ required: true }) }, + admin: { + create: { mode: "page" }, + edit: { mode: "dialog" }, + label: { plural: "Modes", singular: "Mode" }, + }, + }).admin.create.mode, + ).toEqualTypeOf<"dialog" | "page">(); + }); + + it("refuses anything else", () => { + defineContentType({ + id: "test.modes", + tableName: "test_modes", + fields: { title: field.text({ required: true }) }, + admin: { + // @ts-expect-error - only "dialog" and "page" are presentation modes + create: { mode: "drawer" }, + label: { plural: "Modes", singular: "Mode" }, + }, + }); + + defineContentType({ + id: "test.modes", + tableName: "test_modes", + fields: { title: field.text({ required: true }) }, + admin: { + // @ts-expect-error - only "dialog" and "page" are presentation modes + edit: { mode: "sheet" }, + label: { plural: "Modes", singular: "Mode" }, + }, + }); + }); + }); }); diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts index 80e2fd8aa..3099565ed 100644 --- a/packages/vitnode/src/content/define.test.ts +++ b/packages/vitnode/src/content/define.test.ts @@ -462,6 +462,40 @@ describe("defineContentType", () => { }); }); + describe("admin form presentation", () => { + it("defaults create and edit to the dialog", () => { + const dialog = define(); + + expect(dialog.admin.create.mode).toBe("dialog"); + expect(dialog.admin.edit.mode).toBe("dialog"); + }); + + it("takes page mode for create and edit independently", () => { + const pageCreate = define({ + admin: { create: { mode: "page" }, label }, + }); + const pageEdit = define({ admin: { edit: { mode: "page" }, label } }); + + expect(pageCreate.admin.create.mode).toBe("page"); + expect(pageCreate.admin.edit.mode).toBe("dialog"); + expect(pageEdit.admin.create.mode).toBe("dialog"); + expect(pageEdit.admin.edit.mode).toBe("page"); + }); + + it.each(["create", "edit"] as const)("rejects an unknown %s mode", key => { + expect(() => + define({ + admin: { + label, + // Only reachable from JavaScript, or from a value that widened + // upstream - the type refuses it outright. + [key]: { mode: "drawer" as unknown as "dialog" }, + }, + }), + ).toThrow(ContentEngineError); + }); + }); + describe("admin validation", () => { it("rejects a searchable field that is not text-like", () => { expect(() => diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 9be48f575..97efe4b47 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1,6 +1,8 @@ import type { AnyContentTypeDefinition, + ContentAdminActionConfig, ContentAdminConfig, + ContentAdminFormMode, ContentDeliveryConfig, ContentDeliveryDescriptionField, ContentDeliveryEnabled, @@ -38,6 +40,7 @@ import { resolveContentAdvanced, } from "./advanced"; import { + CONTENT_ADMIN_FORM_MODES, CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FIELD_NAME_PATTERN, @@ -435,6 +438,33 @@ const isAdminColumnField = (fieldValue: ContentFieldDescriptor): boolean => !NON_COLUMN_KINDS.has(fieldValue.kind) && !isContentRelationCollection(fieldValue); +const adminFormModes: readonly string[] = CONTENT_ADMIN_FORM_MODES; + +/** + * `admin.create.mode` / `admin.edit.mode`, defaulted and checked. + * + * Defaults to `dialog`, which is what keeps every content type written before + * page mode existed behaving exactly as it did. The runtime check is here for a + * JavaScript caller and for a value that widened somewhere upstream - the type + * already refuses anything outside the union. + */ +const resolveFormMode = ( + id: string, + label: string, + action: ContentAdminActionConfig | undefined, +): ContentAdminFormMode => { + const mode = action?.mode ?? "dialog"; + + if (!adminFormModes.includes(mode)) { + throw new ContentEngineError( + `${label} is "${mode}". Expected one of ${adminFormModes.map(value => `"${value}"`).join(", ")}.`, + { contentTypeId: id }, + ); + } + + return mode; +}; + const resolveAdmin = <TFields>( id: string, fields: ContentFieldMap, @@ -553,7 +583,11 @@ const resolveAdmin = <TFields>( ? (columnFieldNames.find(name => SEARCHABLE_KINDS.has(fields[name].kind), ) ?? null) - : String(admin.titleField); + : // `null` is a decision, not an omission: it says this content type has + // no shared title rather than "pick one for me". + admin.titleField === null + ? null + : String(admin.titleField); if (titleField !== null && !columnFieldNames.includes(titleField)) { throw new ContentEngineError( `admin.titleField references unknown field "${titleField}".`, @@ -562,6 +596,8 @@ const resolveAdmin = <TFields>( } return { + create: { mode: resolveFormMode(id, "admin.create.mode", admin.create) }, + edit: { mode: resolveFormMode(id, "admin.edit.mode", admin.edit) }, form: { fields: formFields }, label: admin.label, list: { diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index eebb070dc..6e8fc8400 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -220,7 +220,11 @@ export { } from "./localization"; export type { ContentFieldPartition } from "./localization"; export { + CONTENT_EDIT_HREF_PLACEHOLDER, contentAdminHref, + contentCreateHref, + contentEditHref, + contentEditHrefTemplate, contentPermissionEntries, contentTypeToPath, findContentTypeById, @@ -269,7 +273,9 @@ export type { ContentSitemapEntry, ContentSitemapIndexEntry } from "./sitemap"; export { slugify } from "./slug"; export type { AnyContentTypeDefinition, + ContentAdminActionConfig, ContentAdminConfig, + ContentAdminFormMode, ContentAdminLabel, ContentAdminListConfig, ContentBooleanField, diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index 64afd614c..6f69035ce 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -13,7 +13,11 @@ import { defineContentType } from "./define"; import { ContentEngineError } from "./errors"; import { field } from "./fields"; import { + CONTENT_EDIT_HREF_PLACEHOLDER, contentAdminHref, + contentCreateHref, + contentEditHref, + contentEditHrefTemplate, contentTypeToPath, findContentTypeById, orderableColumns, @@ -276,6 +280,18 @@ describe("routing helpers", () => { ); }); + it("builds the generated form page URLs off the list one", () => { + expect(contentCreateHref("example.article")).toBe( + "/admin/content/example/article/create", + ); + expect(contentEditHref("example.article", 42)).toBe( + "/admin/content/example/article/42/edit", + ); + expect(contentEditHrefTemplate("example.article")).toBe( + `/admin/content/example/article/${CONTENT_EDIT_HREF_PLACEHOLDER}/edit`, + ); + }); + it("round-trips the catch-all slug", () => { expect(pathToContentTypeId(["example", "article"])).toBe("example.article"); }); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 05230e15b..038cceaf5 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -6,6 +6,8 @@ import type { import type { AnyContentTypeDefinition } from "./types"; import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, CONTENT_EDITORIAL_FIELDS, CONTENT_PERMISSIONS, CONTENT_PUBLICATION_FIELDS, @@ -304,6 +306,34 @@ export const pathToContentTypeId = (slug: readonly string[]): string => export const contentAdminHref = (id: string): string => `/admin/content/${contentTypeToPath(id)}`; +/** + * `/admin/content/example/article/create` - the generated create **page**. + * + * Built off `contentAdminHref` rather than spelled out again, so the list URL + * and the two form URLs cannot drift apart. Only meaningful for a content type + * whose `admin.create.mode` is `page`; the resolver refuses it otherwise. + */ +export const contentCreateHref = (id: string): string => + `${contentAdminHref(id)}/${CONTENT_ADMIN_CREATE_SEGMENT}`; + +/** `/admin/content/example/article/42/edit` - the generated edit **page**. */ +export const contentEditHref = (id: string, itemId: number): string => + `${contentAdminHref(id)}/${itemId}/${CONTENT_ADMIN_EDIT_SEGMENT}`; + +/** + * The edit URL with `{id}` still in it. + * + * A create page is a server component and the identifier only exists once the + * mutation has answered, so the client half is handed a template rather than a + * callback - a function cannot cross an RSC boundary, and a second copy of the + * URL shape would be free to drift from {@link contentEditHref}. + */ +export const contentEditHrefTemplate = (id: string): string => + contentEditHref(id, CONTENT_EDIT_HREF_PLACEHOLDER as unknown as number); + +/** The token {@link contentEditHrefTemplate} leaves behind for the client. */ +export const CONTENT_EDIT_HREF_PLACEHOLDER = "{id}"; + /** * The permissions every content type gets. `can_view` gates the list and the * nav item; the writes depend on it so a role cannot create rows it cannot see. diff --git a/packages/vitnode/src/content/server/openapi-parity.test.ts b/packages/vitnode/src/content/server/openapi-parity.test.ts index 292b15d31..689904832 100644 --- a/packages/vitnode/src/content/server/openapi-parity.test.ts +++ b/packages/vitnode/src/content/server/openapi-parity.test.ts @@ -244,6 +244,7 @@ const adminService = () => ({ delete: vi.fn(), findById: vi.fn().mockResolvedValue(row), findDetail: vi.fn(), + findRowById: vi.fn().mockResolvedValue({ ...row, labels: {} }), findMany: vi.fn().mockResolvedValue({ edges: [{ ...row, labels: {} }], pageInfo: { @@ -343,7 +344,7 @@ describe("admin routes match their OpenAPI document", () => { it("answers 404 for a record that is not there", async () => { const suite = editorialSuite(); - service.findById.mockResolvedValue(null); + service.findRowById.mockResolvedValue(null); await expectParity(suite, { expected: 404, diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index 192ccf3f5..f8ad0f708 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -116,6 +116,7 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), + findRowById: vi.fn(), findMany: vi.fn(), options: vi.fn(), update: vi.fn(), @@ -164,6 +165,7 @@ const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => { advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), + findRowById: vi.fn(), findMany: vi.fn(), options: vi.fn(), publish: vi.fn(), @@ -294,17 +296,19 @@ describe("generated content routes", () => { describe("detail", () => { it("returns the row", async () => { const { app, service } = harness(); - service.findById.mockResolvedValue(row); + // The detail route reads the row *with* its reference labels, so a form + // opening on it can show the name behind a relation rather than its id. + service.findRowById.mockResolvedValue({ ...row, labels: {} }); const res = await app.request("/7"); expect(res.status).toBe(200); - await expect(res.json()).resolves.toMatchObject({ id: 7 }); + await expect(res.json()).resolves.toMatchObject({ id: 7, labels: {} }); }); it("returns 404 for a missing row", async () => { const { app, service } = harness(); - service.findById.mockResolvedValue(null); + service.findRowById.mockResolvedValue(null); expect((await app.request("/7")).status).toBe(404); }); @@ -712,6 +716,7 @@ describe("generated content routes", () => { relations: {}, repeatable: {}, findById: vi.fn(), + findRowById: vi.fn(), findMany: vi.fn(), options: vi.fn(), publish: vi.fn(), diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 451bf96bf..f030abb19 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -126,6 +126,7 @@ export const buildContentRoutes = < }) .nullable(); + const detailRow = schemas.selectObject.extend({ labels: zodLabels }); const listRow = schemas.selectObject.extend({ labels: zodLabels, ...(localized ? { translation: zodRowTranslation.optional() } : {}), @@ -426,13 +427,17 @@ export const buildContentRoutes = < description: `Get one ${label.singular}`, request: { params: schemas.params }, responses: { - 200: jsonResponse(schemas.selectObject, `${label.singular} found`), + // `labels` alongside the record, the same way the list returns them: + // a `relation` holds an identifier, and the form that edits it has to + // show the name behind it. Additive to the row every earlier client + // already parses. + 200: jsonResponse(detailRow, `${label.singular} found`), 400: invalidIdentifier, 404: { description: `${label.singular} not found` }, }, }, handler: async c => { - const row = await model.service(c).findById(identifier(c)); + const row = await model.service(c).findRowById(identifier(c)); if (!row) throw notFound(definition); return c.json(row, 200); diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index 2900c7dd5..df24826be 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -83,6 +83,7 @@ const harness = ({ advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), + findRowById: vi.fn(), relations: {}, repeatable: {}, findMany: vi.fn(), diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 8feec0465..7b0206814 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -335,6 +335,22 @@ export interface ContentServiceBase<TDefinition> { edges: ContentListRow<TDefinition>[]; pageInfo: ContentPageInfo; }>; + /** + * One record **with its reference labels**, exactly as the list returns them. + * + * The read a form makes: a `relation` or `user` value is an identifier, and an + * editor has to be shown the name behind it. `findById` deliberately stays a + * plain row - the labels cost one LEFT JOIN per reference field, and the + * callers that only want the record should not pay for them. + * + * Administrative, like every label: it is read from the target's + * `admin.titleField`, which may name something the target never publishes. The + * public projection does not use it. + */ + findRowById: ( + id: number, + options?: ContentServiceOptions, + ) => Promise<ContentListRow<TDefinition> | null>; /** Options for a `user` or `relation` picker, filtered by a search term. */ options: ( field: ContentReferenceFieldName<TDefinition>, @@ -695,6 +711,31 @@ export const createContentService = < return row ? toRow(row) : null; }, + findRowById: async (id, options) => { + const selection: Record<string, PgColumn | SQL<string>> = { + ...ownSelection(), + ...Object.fromEntries( + Object.entries(references).map(([name, target]) => [ + `${LABEL_PREFIX}${name}`, + target.labelColumn, + ]), + ), + }; + + let builder = db(options).select(selection).from(table).$dynamic(); + + for (const target of Object.values(references)) { + builder = builder.leftJoin( + target.aliased, + eq(target.owner, target.idColumn), + ); + } + + const [row] = await builder.where(eq(primaryCursor, id)).limit(1); + + return row ? splitLabels(row) : null; + }, + findDetail: async (id, options) => { const database = db(options); const row = await readOne(id, database); diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 3e3d89abe..7e2947d38 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,4 +1,5 @@ import type { + CONTENT_ADMIN_FORM_MODES, CONTENT_DELIVERY_DESCRIPTION_KINDS, CONTENT_DELIVERY_NO_INDEX_KINDS, CONTENT_DELIVERY_TITLE_KINDS, @@ -735,11 +736,34 @@ export interface ContentAdminListConfig< searchableFields?: ScalarColumnFieldKeys<TFields>[]; } +/** + * How the AdminCP presents a create or an edit form. + * + * `dialog` is the default and always will be: every content type written before + * this existed keeps the screen it had, and opting into `page` is one line. + */ +export type ContentAdminFormMode = (typeof CONTENT_ADMIN_FORM_MODES)[number]; + +/** + * One AdminCP action's presentation. + * + * An object rather than a bare string so the shape has somewhere to grow - and + * so `create: { mode: "page" }` reads the same as every other block in the + * descriptor. + */ +export interface ContentAdminActionConfig { + mode?: ContentAdminFormMode; +} + export interface ContentAdminConfig< TFields = ContentFieldMap, TPublication extends boolean = boolean, TEditorial extends boolean = boolean, > { + /** Presentation of the create form. Defaults to `{ mode: "dialog" }`. */ + create?: ContentAdminActionConfig; + /** Presentation of the edit form. Defaults to `{ mode: "dialog" }`. */ + edit?: ContentAdminActionConfig; form?: { fields?: SharedFieldKeys<TFields>[] }; label: ContentAdminLabel; list?: ContentAdminListConfig<TFields, TPublication, TEditorial>; @@ -754,9 +778,14 @@ export interface ContentAdminConfig< * * Shared fields only. A localized title has a different value per language, so * naming one here would make a toast depend on whose locale the reader is in; - * Stage 5B gives the AdminCP a locale-aware title of its own. + * the AdminCP's locale tabs are where a localized value appears. + * + * `null` says the content type genuinely has no shared title - which is the + * honest answer for one whose every text field is localized. Left `undefined` + * the first shared text field is picked, and that guess is wrong for, say, a + * category whose only shared column is a colour. */ - titleField?: ScalarColumnFieldKeys<TFields>; + titleField?: null | ScalarColumnFieldKeys<TFields>; } /** @@ -769,6 +798,8 @@ export interface ContentAdminConfig< * the narrower type bought nothing. */ export interface ResolvedContentAdminConfig { + create: { mode: ContentAdminFormMode }; + edit: { mode: ContentAdminFormMode }; form: { fields: string[] }; label: ContentAdminLabel; list: { diff --git a/packages/vitnode/src/lib/plugin.test.ts b/packages/vitnode/src/lib/plugin.test.ts new file mode 100644 index 000000000..9afa74711 --- /dev/null +++ b/packages/vitnode/src/lib/plugin.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { ContentFormLayout } from "./plugin"; + +import { resolveContentFormLayout } from "./plugin"; + +const shared = (() => null) as ContentFormLayout; +const createOnly = (() => null) as ContentFormLayout; +const editOnly = (() => null) as ContentFormLayout; + +describe("resolveContentFormLayout", () => { + it("has no layout when a plugin registered none", () => { + expect(resolveContentFormLayout(undefined, "create")).toBeUndefined(); + expect(resolveContentFormLayout({}, "edit")).toBeUndefined(); + }); + + it("uses one shared layout for both actions", () => { + expect(resolveContentFormLayout({ layout: shared }, "create")).toBe(shared); + expect(resolveContentFormLayout({ layout: shared }, "edit")).toBe(shared); + }); + + it("lets one action override the shared layout", () => { + const forms = { create: { layout: createOnly }, layout: shared }; + + expect(resolveContentFormLayout(forms, "create")).toBe(createOnly); + expect(resolveContentFormLayout(forms, "edit")).toBe(shared); + }); + + it("takes per-action layouts with no shared fallback", () => { + const forms = { + create: { layout: createOnly }, + edit: { layout: editOnly }, + }; + + expect(resolveContentFormLayout(forms, "create")).toBe(createOnly); + expect(resolveContentFormLayout(forms, "edit")).toBe(editOnly); + }); +}); diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 445eaf677..9959939e8 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -47,6 +47,73 @@ export interface ContentCellProps< row: ContentSelect<TDefinition>; } +/** + * Which of a content type's two form surfaces a layout is being rendered in. + * + * A localized content type has both at once: `shared` holds the fields that are + * columns on the base table, and `translation` holds one language's own. The + * same layout is rendered in each, and a `ContentFormField` naming a field that + * is not in this surface renders nothing - so one layout can place `title` and + * `category` wherever it likes without knowing which table either lives on. + */ +export type ContentFormSurface = "shared" | "translation"; + +/** + * Everything a custom form layout is handed, and nothing more. + * + * Deliberately all serialisable: a layout is a client component referenced from + * `config.tsx`, which is a **server** module, so React props cross an RSC + * boundary to reach it. Field elements, the form instance and the submit action + * are not here for exactly that reason - they come from + * `useContentForm()`/`ContentFormField`, which are client context and therefore + * never cross anything. + * + * There is no database handle, Drizzle table, Hono context or mutation model in + * this shape, and there is not going to be: a layout decides where a field + * appears, and the Content Engine decides what happens when it is submitted. + */ +export interface ContentFormLayoutProps { + contentTypeId: string; + /** `undefined` while creating - the record does not exist yet. */ + itemId?: number; + /** The locale being written, on a `translation` surface. */ + locale?: string; + mode: "create" | "edit"; + pluginId: string; + /** Whether the content type has the draft/published lifecycle. */ + publication: boolean; + singular: string; + surface: ContentFormSurface; + /** The record's resolved title while editing, for headings. */ + title?: string; +} + +export type ContentFormLayout = ( + props: ContentFormLayoutProps, +) => React.ReactNode; + +/** + * Layout overrides for the generated create and edit forms. + * + * `layout` alone covers the common case - one editor screen used for both - and + * `create`/`edit` override it when they genuinely differ. Normalised by + * `resolveContentFormLayout`, so nothing downstream has to know about the + * fallback. + */ +export interface ContentTypeFormsRegistration { + create?: { layout?: ContentFormLayout }; + edit?: { layout?: ContentFormLayout }; + /** Used by both create and edit unless one of them overrides it. */ + layout?: ContentFormLayout; +} + +/** The layout for one action, or `undefined` for the generated one. */ +export const resolveContentFormLayout = ( + forms: ContentTypeFormsRegistration | undefined, + mode: "create" | "edit", +): ContentFormLayout | undefined => + forms?.[mode]?.layout ?? forms?.layout ?? undefined; + /** * A content type registration once its definition generic has been erased, so * one plugin can list content types with different field maps in one array. @@ -61,6 +128,8 @@ export interface ContentTypeFrontendRegistration { string, { component: (props: ItemAutoFormComponentProps) => React.ReactNode } >; + /** Custom create/edit form layouts. Presentation only - see `forms`. */ + forms?: ContentTypeFormsRegistration; icon?: React.ReactNode; } @@ -82,6 +151,16 @@ interface TypedContentTypeRegistration< { component: (props: ItemAutoFormComponentProps) => React.ReactNode } > >; + /** + * Replace the generated form **layout** - where the fields are, not what they + * do. + * + * The Content Engine still owns the form schema, the validation, the defaults, + * the mutation, the version precondition, the structured errors, the toast and + * the cache invalidation. A layout places `<ContentFormField name="..." />` + * and `<ContentFormActions />` inside one shared form instance. + */ + forms?: ContentTypeFormsRegistration; /** Sidebar icon. Defaults to a generic document icon. */ icon?: React.ReactNode; } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 1042340bd..4056fb74d 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -426,6 +426,9 @@ "submit": "Save changes", "success": "{name} has been updated." }, + "page": { + "back": "Back to {name}" + }, "delete": { "title": "Delete {name}", "desc": "Are you sure you want to delete <title>? This action cannot be undone.", diff --git a/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx index 37262ff44..2a079abeb 100644 --- a/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx +++ b/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx @@ -1,22 +1,68 @@ +import { getTranslations } from "next-intl/server"; + +import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, +} from "@/content/const"; +import { contentAdminHref, contentTypeToPath } from "@/content/registry"; import { BreadcrumbAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-admin"; import { getContentLabels, - resolveContentType, + resolveContentRoute, } from "@/views/admin/views/content/content-admin-view"; +/** + * The breadcrumb of every generated Content Engine screen. + * + * The list keeps the trail it always had. A create or an edit **page** appends + * one more crumb, labelled from `core.content` with the content type's own + * singular - so it reads "Blog / Articles / Create article" in whatever language + * the AdminCP is in, and "Articles" becomes a link back to the list. + * + * The record id is deliberately **not** a crumb of its own: `/42/` would render + * as a dead "42" between two words, and the page it would point at is the one + * being read. + */ export default async function BreadcrumbSlot({ params, }: { params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - const entry = await resolveContentType(params); - const labels = entry ? await getContentLabels(entry) : undefined; + const route = await resolveContentRoute(params); + const labels = route ? await getContentLabels(route.entry) : undefined; + + if (!route || route.action === "list") { + return ( + + ); + } + + const t = await getTranslations("core.content"); + const { definition } = route.entry; return ( ); } 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 f3e271fe5..810421de7 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -2,17 +2,15 @@ // `create-action`/`edit-action`, which are already client entries. Declaring // it again would make this a nested client entry, and `next/dynamic` cannot // resolve one from inside a published package - the dialog spins forever. -import { CircleCheckIcon, FileClockIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import React from "react"; import { toast } from "sonner"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentFormLayout } from "@/lib/plugin"; -import { DateFormat } from "@/components/date-format"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; -import { Badge } from "@/components/ui/badge"; import { useDialog } from "@/components/ui/dialog"; import { buildFormSchemaFromSpec, @@ -23,6 +21,8 @@ import { usePathname, useRouter } from "@/lib/navigation"; import type { ContentConflictState } from "./conflict-notice"; +import { ContentFormProvider } from "../form/context"; +import { ContentFormPublication } from "../form/publication-status"; import { ContentField } from "../lib/field-component"; import { contentErrorKey } from "../lib/mutation-feedback"; import { ConflictNotice } from "./conflict-notice"; @@ -33,43 +33,6 @@ import { reloadContentRowAction, } from "./mutation-api.server"; -/** - * A read-only line saying where the row is in the lifecycle. - * - * Read-only on purpose: `status` and `publishedAt` are not in the form schema, - * and the one place that moves them is the table's publish action. Two - * competing mutation paths in one dialog is how a form ends up fighting its own - * optimistic state. - */ -const PublicationStatus = ({ - publishedAt, - status, -}: { - publishedAt: unknown; - status: unknown; -}) => { - const t = useTranslations("core.content.status"); - const published = status === "published"; - const date = typeof publishedAt === "string" ? new Date(publishedAt) : null; - - return ( -
    - {t("label")} - - {published ? ( - - ) : ( - - )} - {published ? t("published") : t("draft")} - - - {date ? : t("never_published")} - -
    - ); -}; - export interface ContentFormProps { /** Existing values when editing; absent when creating. */ data?: Record & { id: number }; @@ -78,6 +41,18 @@ export interface ContentFormProps { string, (props: ItemAutoFormComponentProps) => React.ReactNode >; + /** Custom layout declared in `buildPlugin`. Presentation only. */ + layout?: ContentFormLayout; + /** + * Where a page-mode create hands the new record over. Ignored in a dialog, + * which closes and refreshes the list instead. + */ + onCreated?: (id: number) => void; + /** + * Where the form is. A dialog closes itself and refreshes the list behind it; + * a page navigates instead, because there is nothing behind it to refresh. + */ + presentation?: "dialog" | "page"; /** Whether the content type has the draft/published lifecycle. */ publication?: boolean; /** The content type's singular label, used in the success toast. */ @@ -90,6 +65,9 @@ export interface ContentFormProps { export const ContentForm = ({ data, fieldOverrides = {}, + layout, + onCreated, + presentation = "dialog", publication = false, singular, spec, @@ -105,7 +83,7 @@ export const ContentForm = ({ null, ); - // The version this dialog opened with, and the one every save is checked + // The version this form 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(() => @@ -148,7 +126,7 @@ export const ContentForm = ({ : await createContentAction(spec.contentTypeId, payload); if (mutation.error !== undefined) { - // A lost update is the one failure with somewhere to go: the dialog stays + // A lost update is the one failure with somewhere to go: the form stays // open with everything the editor typed, and the banner offers to show // what changed underneath them. if (mutation.conflict?.code === "CONTENT_VERSION_CONFLICT") { @@ -181,16 +159,63 @@ export const ContentForm = ({ }, ); + if (presentation === "page") { + // A page has nothing behind it to refresh, so a create hands over to + // whoever knows where the record should be opened next, and an edit stays + // put with fresh server data. + if (!data && mutation.id !== undefined) { + onCreated?.(mutation.id); + + return; + } + + push(pathname); + + return; + } + // Close first, then navigate: a refresh fired while the dialog is still // animating out leaves its overlay stranded over the page. setOpen?.(false); push(pathname); }; + const fields = spec.fields.map( + ( + fieldSpec, + ): { + component: (props: ItemAutoFormComponentProps) => React.ReactNode; + id: string; + } => ({ + id: fieldSpec.name, + + // MUST NOT be async: `AutoForm` calls this to get an element, and an + // async function hands it a fresh Promise every render - React 19 + // suspends on promise children, so the dialog spins forever. + // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above + component: props => { + const override = fieldOverrides[fieldSpec.name]; + if (override) return override(props); + + return ( + + await loadContentOptionsAction(spec.contentTypeId, field, search) + } + spec={fieldSpec} + {...props} + /> + ); + }, + }), + ); + + const Layout = layout; + return ( <> - {publication && data ? ( - @@ -206,33 +231,38 @@ export const ContentForm = ({ ) : null} ({ - id: fieldSpec.name, - - // MUST NOT be async: `AutoForm` calls this to get an element, and an - // async function hands it a fresh Promise every render - React 19 - // suspends on promise children, so the dialog spins forever. - // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above - component: props => { - const override = fieldOverrides[fieldSpec.name]; - if (override) return override(props); - - return ( - - await loadContentOptionsAction( - spec.contentTypeId, - field, - search, - ) - } - spec={fieldSpec} - {...props} - /> - ); - }, - }))} + fields={fields} formSchema={formSchema} + layout={ + Layout + ? renderedFields => ( + field.name), + fields: renderedFields, + mode: data ? "edit" : "create", + publication: { + enabled: publication, + publishedAt: data?.publishedAt, + status: data?.status, + }, + surface: "shared", + }} + > + + + ) + : undefined + } onSubmit={onSubmit} submitButtonProps={{ children: t(data ? "edit.submit" : "create.submit"), diff --git a/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx index bd4930e02..038ae404c 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx @@ -15,6 +15,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Loader } from "@/components/ui/loader"; +import { Link } from "@/lib/navigation"; import type { ContentFormProps } from "./content-form"; @@ -24,12 +25,29 @@ const ContentForm = dynamic(async () => import("./content-form").then(mod => ({ default: mod.ContentForm })), ); +/** + * The Create button. + * + * With `admin.create.mode: "page"` the content type is given `href`, and this is + * an ordinary link - not a dialog that mounts and immediately redirects. Nothing + * of the form is downloaded until the page it points at is actually requested. + */ export const CreateContentAction = ({ + href, singular, ...props -}: Omit) => { +}: Omit & { href?: string }) => { const t = useTranslations("core.content.create"); + if (href) { + return ( + + ); + } + return ( }> diff --git a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx index c3b6bc8d4..75060ab29 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx @@ -25,6 +25,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { CONTENT_PERMISSIONS } from "@/content/const"; +import { Link } from "@/lib/navigation"; import type { ContentFormProps } from "./content-form"; @@ -52,6 +53,7 @@ const LocaleEditor = dynamic(async () => export const EditContentAction = ({ defaultLocale, editorial = false, + href, permissionModule, pluginId, singular, @@ -61,6 +63,8 @@ export const EditContentAction = ({ /** The content type's default locale. Required when `translationSpec` is set. */ defaultLocale?: string; editorial?: boolean; + /** Set by `admin.edit.mode: "page"` - navigates instead of opening a dialog. */ + href?: string; permissionModule: string; pluginId: string; /** Localized-field form spec, or `null` when the content type is not localized. */ @@ -82,6 +86,29 @@ export const EditContentAction = ({ if (!canEdit && !(localized && canTranslate)) return null; + if (href) { + return ( + + + } + size="icon" + variant="ghost" + > + + + } + /> + + {t("title", { name: singular })} + + + ); + } + return ( 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 806c2d441..fd02e7006 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 @@ -64,6 +64,14 @@ interface MutationResult { */ delivery?: ContentDeliveryConflict; error?: string; + /** + * The identifier of a newly created record. + * + * Only set by `createContentAction`, and only on success - a page-mode create + * navigates to the record's own edit page, and guessing at the id would open + * the wrong one. + */ + id?: number; /** Why a schedule was refused, when the API said. */ rejection?: ContentScheduleRejection; /** Lets the UI tell a restricted delete (409) from a generic failure. */ @@ -305,7 +313,7 @@ export const createContentAction = async ( before: [], }); - return {}; + return { id: created }; }; export const editContentAction = async ( diff --git a/packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx new file mode 100644 index 000000000..207582c20 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/page-links.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +vi.mock("server-only", () => ({})); + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), + getPathname: () => "", + redirect: () => undefined, + usePathname: () => "", + useRouter: () => ({ push: () => undefined, refresh: () => undefined }), +})); + +vi.mock("@/components/staff-permission/provider", () => ({ + useAdminStaffPermission: () => true, +})); + +const { CreateContentAction } = await import("./create-action"); +const { EditContentAction } = await import("./edit-action"); + +const spec: ContentFormSpec = { + contentTypeId: "blog.post", + fields: [], + pluginId: "@vitnode/blog", + titleField: null, +}; + +/** + * Page mode has to be a **link**, not a dialog that redirects. + * + * A dialog that mounted and then navigated would download the whole form - every + * field component, the editor, the lot - to show it for one frame. + */ +describe("page-mode actions", () => { + it("creates through a link when the content type asked for a page", () => { + render( + , + ); + + expect(screen.getByTestId("link").getAttribute("href")).toBe( + "/admin/content/blog/post/create", + ); + }); + + it("keeps the dialog when it did not", () => { + render(); + + expect(screen.queryByTestId("link")).toBeNull(); + expect(screen.getByRole("button")).toBeTruthy(); + }); + + it("edits through a link when the content type asked for a page", () => { + render( + , + ); + + expect(screen.getByTestId("link").getAttribute("href")).toBe( + "/admin/content/blog/post/42/edit", + ); + }); + + it("keeps the edit dialog when it did not", () => { + render( + , + ); + + expect(screen.queryByTestId("link")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx index 090ed775b..ee1b8caee 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx @@ -124,11 +124,13 @@ export const LocaleEditor = ({ { setReloads(count => count + 1); diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx new file mode 100644 index 000000000..55f422b27 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +vi.mock("server-only", () => ({})); + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: () => null, + getPathname: () => "", + redirect: () => undefined, + usePathname: () => "", + useRouter: () => ({ push: () => undefined, refresh: () => undefined }), +})); + +vi.mock("@/components/staff-permission/provider", () => ({ + useAdminStaffPermission: () => true, +})); + +vi.mock("../translation-api.server", () => ({ + createContentTranslationAction: vi.fn(), + deleteContentTranslationAction: vi.fn(), + editContentTranslationAction: vi.fn(), + getContentTranslationAction: async () => { + await Promise.resolve(); + + return { row: null }; + }, + publishContentTranslationAction: vi.fn(), + unpublishContentTranslationAction: vi.fn(), +})); + +vi.mock("../mutation-api.server", () => ({ + loadContentOptionsAction: async () => await Promise.resolve([]), +})); + +const { TranslationPanel } = await import("./translation-panel"); + +const spec: ContentFormSpec = { + contentTypeId: "test.localized", + pluginId: "@vitnode/test", + titleField: "title", + fields: [ + { + kind: "text", + label: "Title", + name: "title", + nullable: false, + required: true, + }, + { + kind: "textarea", + label: "Body", + name: "body", + nullable: false, + required: true, + }, + ], +}; + +const renderPanel = ( + props: Partial> = {}, +) => + render( + undefined} + permissionModule="pages" + pluginId="@vitnode/test" + publication={false} + spec={spec} + {...props} + />, + ); + +/** + * A locale tab has to render **inputs**. + * + * It once did not: the panel handed `AutoForm` a list of bare field ids, and + * `AutoForm` renders nothing for a field with no component - so every localized + * content type had a form with a submit button and no way to type into it. + */ +describe("TranslationPanel", () => { + it("renders an input for every localized field", async () => { + renderPanel(); + + await waitFor(() => { + expect(screen.getByLabelText("Title")).toBeTruthy(); + }); + expect(screen.getByLabelText("Body")).toBeTruthy(); + }); + + it("uses a registered field override, exactly as the shared form does", async () => { + renderPanel({ + fieldOverrides: { + body: () =>
    , + }, + }); + + await waitFor(() => { + expect(screen.getByTestId("custom-editor")).toBeTruthy(); + }); + // The override replaced the generated input, and nothing else moved. + expect(screen.getByLabelText("Title")).toBeTruthy(); + }); + + it("hands a registered layout the localized surface", async () => { + renderPanel({ + layout: ({ surface }) =>
    {surface}
    , + }); + + await waitFor(() => { + expect(screen.getByTestId("layout").textContent).toBe("translation"); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx index d47fb00c5..5f7babfce 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx @@ -3,8 +3,10 @@ import { useTranslations } from "next-intl"; import React from "react"; import { toast } from "sonner"; +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; import type { ContentTranslationConflict } from "@/content/conflicts"; +import type { ContentFormLayout } from "@/lib/plugin"; import { DateFormat } from "@/components/date-format"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; @@ -20,7 +22,10 @@ import type { TranslationRow, } from "../translation-api.server"; +import { ContentFormProvider } from "../../form/context"; +import { ContentField } from "../../lib/field-component"; import { contentErrorKey } from "../../lib/mutation-feedback"; +import { loadContentOptionsAction } from "../mutation-api.server"; import { createContentTranslationAction, deleteContentTranslationAction, @@ -39,11 +44,18 @@ export interface TranslationPanelProps { contentTypeId: string; /** Enables the history and restore sections. */ editorial: boolean; + /** Per-field component overrides declared in `buildPlugin`. */ + fieldOverrides?: Record< + string, + (props: ItemAutoFormComponentProps) => React.ReactNode + >; /** `true` when this locale is the content type's default - never deletable. */ isDefaultLocale: boolean; itemId: number; /** Human name of the language, for headings and toasts. */ languageName: string; + /** Custom layout declared in `buildPlugin`. Presentation only. */ + layout?: ContentFormLayout; locale: string; /** Reloads the tab strip after a mutation, so its badges stay honest. */ onMutated: () => void; @@ -97,7 +109,9 @@ const conflictMessage = ( export const TranslationPanel = ({ contentTypeId, editorial, + fieldOverrides = {}, isDefaultLocale, + layout, itemId, languageName, locale, @@ -276,6 +290,8 @@ export const TranslationPanel = ({ if (!settled) return ; + const Layout = layout; + const publishedAt = typeof row?.publishedAt === "string" ? new Date(row.publishedAt) : null; @@ -324,8 +340,59 @@ export const TranslationPanel = ({ {canTranslate ? ( ({ id: fieldSpec.name }))} + fields={spec.fields.map(fieldSpec => ({ + id: fieldSpec.name, + + // MUST NOT be async, for the same reason the shared form's is not: + // `AutoForm` calls this to get an element, and an async function + // hands it a fresh Promise every render. + // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above + component: props => { + const override = fieldOverrides[fieldSpec.name]; + if (override) return override(props); + + return ( + + await loadContentOptionsAction(contentTypeId, field, search) + } + spec={fieldSpec} + {...props} + /> + ); + }, + }))} formSchema={formSchema} + layout={ + Layout + ? renderedFields => ( + field.name), + fields: renderedFields, + mode: present ? "edit" : "create", + publication: { + enabled: publication, + publishedAt: row?.publishedAt, + status: row?.status, + }, + surface: "translation", + }} + > + + + ) + : undefined + } onSubmit={onSubmit} submitButtonProps={{ children: present ? t("save") : t("create"), diff --git a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx index b8099c8c8..0ee66537f 100644 --- a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx @@ -3,22 +3,25 @@ import { notFound } from "next/navigation"; import React from "react"; import type { RegisteredFrontendContentType } from "@/content/admin/config"; +import type { ContentAdminRoute } from "@/content/admin/route"; import { I18nProvider } from "@/components/i18n-provider"; import { DataTableSkeleton } from "@/components/table/data-table"; import { HeaderContent } from "@/components/ui/header-content"; import { findFrontendContentType } from "@/content/admin/config"; import { contentI18nKeys, humanizeFieldName } from "@/content/admin/labels"; +import { resolveContentAdminRoute } from "@/content/admin/route"; import { buildContentColumnSpec, buildContentFormSpec, buildContentTranslationFormSpec, } from "@/content/admin/spec"; import { CONTENT_PERMISSIONS } from "@/content/const"; -import { pathToContentTypeId } from "@/content/registry"; +import { contentCreateHref } from "@/content/registry"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { CreateContentAction } from "./actions/create-action"; +import { ContentCreatePageView, ContentEditPageView } from "./page/page-views"; import { ContentTableView } from "./table/content-table-view"; export interface ContentAdminViewProps { @@ -27,17 +30,40 @@ export interface ContentAdminViewProps { } /** - * Resolves a registered content type from the catch-all slug, or `undefined`. - * Shared with `generateMetadata` and the breadcrumb slot. + * Resolves what the catch-all slug was asking for: which content type, and + * whether it wants the list, the create page or an edit page. + * + * Shared with `generateMetadata` and the breadcrumb slot, so all three agree + * about a URL rather than each parsing it their own way. */ -export const resolveContentType = async ( +export const resolveContentRoute = async ( params: ContentAdminViewProps["params"], -): Promise => { +): Promise< + (ContentAdminRoute & { entry: RegisteredFrontendContentType }) | undefined +> => { const { slug } = await params; + const route = resolveContentAdminRoute( + slug, + contentTypeId => findFrontendContentType(contentTypeId)?.definition, + ); + if (!route) return undefined; + + const entry = findFrontendContentType(route.contentTypeId); - return findFrontendContentType(pathToContentTypeId(slug)); + return entry ? { ...route, entry } : undefined; }; +/** + * Resolves a registered content type from the catch-all slug, or `undefined`. + * + * Kept as its own export because that is what `generateMetadata` and the + * breadcrumb slot in every app already call. + */ +export const resolveContentType = async ( + params: ContentAdminViewProps["params"], +): Promise => + (await resolveContentRoute(params))?.entry; + /** * Resolves the display strings for a content type. * @@ -70,13 +96,19 @@ export const getContentLabels = async ( }; }; -export const ContentAdminView = async ({ - params, +/** + * The generated list screen. + * + * Split out from `ContentAdminView` so the dispatcher below reads as the three + * screens it serves rather than as one function with a mode flag in it. + */ +const ContentListView = async ({ + entry, searchParams, -}: ContentAdminViewProps) => { - const entry = await resolveContentType(params); - if (!entry) notFound(); - +}: { + entry: RegisteredFrontendContentType; + searchParams: ContentAdminViewProps["searchParams"]; +}) => { const { definition, pluginId, registration } = entry; const [labels, canView, canCreate, query] = await Promise.all([ @@ -117,34 +149,67 @@ export const ContentAdminView = async ({ }); return ( - -
    - - {canCreate && ( - [name, override.component], - ), - )} - singular={definition.admin.label.singular} - spec={formSpec} - /> - )} - - - } - > - + + {canCreate && ( + [name, override.component], + ), + )} + // Page mode makes this a link. The dialog is not mounted at all, + // so none of the form's chunks are downloaded until the page is. + href={ + definition.admin.create.mode === "page" + ? contentCreateHref(definition.id) + : undefined + } + singular={definition.admin.label.singular} + spec={formSpec} /> - -
    + )} + + + } + > + + +
    + ); +}; + +/** + * One route, three screens. + * + * `/admin/content/blog/post` is the list, `.../create` and `.../42/edit` are the + * generated form pages - and the last two exist only for a content type that + * opted into `admin.create.mode` / `admin.edit.mode` of `page`, so nothing about + * an existing content type moves. + */ +export const ContentAdminView = async ({ + params, + searchParams, +}: ContentAdminViewProps) => { + const route = await resolveContentRoute(params); + if (!route) notFound(); + + return ( + + {route.action === "list" ? ( + + ) : route.action === "create" ? ( + + ) : ( + + )} ); }; diff --git a/packages/vitnode/src/views/admin/views/content/form/context.tsx b/packages/vitnode/src/views/admin/views/content/form/context.tsx new file mode 100644 index 000000000..c880e5378 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/context.tsx @@ -0,0 +1,108 @@ +// No "use client" here on purpose: this module is only reached from +// `content-form` / `translation-panel`, which are already client entries. +// Declaring it again would make it a nested client entry, and `next/dynamic` +// cannot resolve one from inside a published package. +import React from "react"; + +import type { ContentFormSurface } from "@/lib/plugin"; + +export interface ContentFormContextValue { + /** Names in this surface, in declaration order. */ + fieldNames: string[]; + /** Every field of this surface, already rendered and keyed by name. */ + fields: Record; + /** Records what a layout actually placed, so nothing goes missing silently. */ + markRendered?: (name: string) => void; + mode: "create" | "edit"; + /** + * Where the record sits in the lifecycle, read-only. + * + * Values rather than controls: `status` and `publishedAt` are not in the form + * schema, and the publish action on the list is the one thing that moves them. + */ + publication: { + enabled: boolean; + publishedAt?: unknown; + status?: unknown; + }; + surface: ContentFormSurface; +} + +const ContentFormContext = React.createContext( + null, +); + +/** + * The state a custom layout reads, from inside the one `AutoForm` instance. + * + * Context rather than props, and that is the whole architecture decision: a + * layout is a client component *referenced* from `config.tsx`, which is a server + * module, so anything handed to it as a prop crosses an RSC boundary. Rendered + * field elements and a `renderField(name)` callback cannot cross one - the first + * is not serialisable and the second is a server closure. Both are perfectly + * ordinary values on the client, where the provider and the layout both run. + */ +export const useContentForm = (): ContentFormContextValue => { + const value = React.useContext(ContentFormContext); + + if (!value) { + throw new Error( + "useContentForm must be used inside a Content Engine form layout.", + ); + } + + return value; +}; + +/** + * Same value, but `null` outside a layout. + * + * For a primitive that is legitimately optional - `ContentFormActions` is used + * by layouts only, but a field component may be reused in a plain dialog. + */ +export const useContentFormOptional = (): ContentFormContextValue | null => + React.useContext(ContentFormContext); + +export const ContentFormProvider = ({ + children, + value, +}: { + children: React.ReactNode; + value: Omit; +}) => { + const rendered = React.useRef>(new Set()); + + const markRendered = React.useCallback((name: string) => { + rendered.current.add(name); + }, []); + + const { fieldNames } = value; + + /** + * A layout that forgets a field silently drops it from the payload, which is + * the one failure mode this API has that the generated form does not. Saying + * so in development costs nothing and turns a data-loss bug into a console + * line naming the field. + * + * Runs after the children, which is what makes the set complete - and clears + * it afterwards, so a layout that *stops* placing a field is noticed on the + * very next render rather than remembered as still placing it. + */ + React.useEffect(() => { + const missing = fieldNames.filter(name => !rendered.current.has(name)); + rendered.current.clear(); + + if (process.env.NODE_ENV === "production" || missing.length === 0) return; + + // eslint-disable-next-line no-console -- development-only diagnostic + console.warn( + `[vitnode] Content form layout did not render: ${missing.join(", ")}. Add for each, or remove them from admin.form.fields.`, + ); + }); + + return ( + + {children} + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts new file mode 100644 index 000000000..00048ec58 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -0,0 +1,26 @@ +/** + * The primitives a custom Content Engine form layout is built from. + * + * Published as `@vitnode/core/content/admin-form`. Everything here runs inside + * the one `AutoForm` instance the Content Engine created: one schema, one submit + * path, one set of errors. A layout decides *where* a field appears and nothing + * else - validation, defaults, mutations, version preconditions, structured + * errors, publication state, translations, permissions, toasts, cache + * invalidation, events, search and delivery all stay where they were. + */ +export { + type ContentFormContextValue, + useContentForm, + useContentFormOptional, +} from "./context"; +export { + ContentFormActions, + ContentFormField, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormRemainingFields, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "./primitives"; +export { ContentFormPublication } from "./publication-status"; diff --git a/packages/vitnode/src/views/admin/views/content/form/layout.test.tsx b/packages/vitnode/src/views/admin/views/content/form/layout.test.tsx new file mode 100644 index 000000000..6731abd07 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/layout.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import type { ContentFormLayoutProps } from "@/lib/plugin"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; + +import { ContentFormProvider } from "./context"; +import { + ContentFormActions, + ContentFormField, + ContentFormMain, + ContentFormRemainingFields, + ContentFormSidebar, + ContentFormStatus, +} from "./primitives"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + usePathname: () => "/admin/content/blog/post", + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +const schema = z.object({ + category: z.string().default("Reference"), + content: z.string().default("Body"), + title: z.string().min(1).default("Hello"), +}); + +/** + * The whole point of the layout API, exercised end to end: two named fields in + * two different places, one form, one submit. + */ +const Harness = ({ + fieldNames = ["title", "content", "category"], + layout, + mode = "edit", + onSubmit = vi.fn(), + publication = { enabled: false }, +}: { + fieldNames?: string[]; + layout: (props: ContentFormLayoutProps) => React.ReactNode; + mode?: "create" | "edit"; + onSubmit?: () => void; + publication?: { enabled: boolean; publishedAt?: unknown; status?: unknown }; +}) => ( + , + }, + { + id: "content", + component: props => , + }, + { + id: "category", + component: props => , + }, + ]} + formSchema={schema} + layout={fields => ( + + {layout({ + contentTypeId: "blog.post", + mode, + pluginId: "@vitnode/blog", + publication: publication.enabled, + singular: "Article", + surface: "shared", + })} + + )} + onSubmit={onSubmit} + /> +); + +describe("content form layouts", () => { + it("places named fields wherever the layout puts them", () => { + render( + ( + <> + +
    + + +
    +
    + +
    + +
    +
    + + )} + />, + ); + + expect(screen.getByTestId("main").contains(screen.getByLabelText("Title"))); + expect( + screen.getByTestId("main").contains(screen.getByLabelText("Content")), + ).toBe(true); + expect( + screen.getByTestId("sidebar").contains(screen.getByLabelText("Category")), + ).toBe(true); + expect( + screen.getByTestId("main").contains(screen.getByLabelText("Category")), + ).toBe(false); + }); + + it("renders nothing for a field this surface does not have", () => { + render( + ( + <> + + + + )} + />, + ); + + expect(screen.getByLabelText("Title")).toBeTruthy(); + expect(screen.queryByLabelText("Content")).toBeNull(); + }); + + it("submits every placed field through one form", async () => { + const onSubmit = vi.fn(); + render( + ( + <> + + + + + + )} + onSubmit={onSubmit} + />, + ); + + // The submit button stays disabled until react-hook-form has validated + // once, which is what typing does - same as the generated form. + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: "Hello world" }, + }); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Save" }).getAttribute("disabled"), + ).toBeNull(); + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { category: "Reference", content: "Body", title: "Hello world" }, + expect.anything(), + expect.anything(), + ); + }); + }); + + it("keeps a validation error attached to its own field", async () => { + const onSubmit = vi.fn(); + render( + ( + <> + + + + + + )} + onSubmit={onSubmit} + />, + ); + + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: "" }, + }); + fireEvent.submit(screen.getByLabelText("Title").closest("form") as Element); + + await waitFor(() => { + expect(screen.getByLabelText("Title").getAttribute("aria-invalid")).toBe( + "true", + ); + }); + expect( + screen.getByLabelText("Content").getAttribute("aria-invalid"), + ).not.toBe("true"); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("fills in the fields a layout did not name", () => { + render( + ( + <> + +
    + +
    + + )} + />, + ); + + expect( + screen.getByTestId("rest").contains(screen.getByLabelText("Content")), + ).toBe(true); + expect( + screen.getByTestId("rest").contains(screen.getByLabelText("Category")), + ).toBe(true); + expect( + screen.getByTestId("rest").contains(screen.getByLabelText("Title")), + ).toBe(false); + }); + + it("shows the publication line only when there is one to show", () => { + const { rerender } = render( + } mode="create" />, + ); + + expect(screen.queryByText("draft")).toBeNull(); + + rerender( + } + mode="edit" + publication={{ enabled: true, publishedAt: null, status: "draft" }} + />, + ); + + expect(screen.getByText("draft")).toBeTruthy(); + }); + + it("warns in development about a field the layout forgot", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + render( } />); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("content, category"), + ); + warn.mockRestore(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx new file mode 100644 index 000000000..63479754c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx @@ -0,0 +1,200 @@ +// No "use client": reached only from a layout, which is reached only from +// `content-form` / `translation-panel` - both already client entries. +import { useTranslations } from "next-intl"; +import React from "react"; + +import { AutoFormSubmitButton } from "@/components/form/auto-form"; +import { Button } from "@/components/ui/button"; +import { Link } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; + +import { useContentForm } from "./context"; +import { ContentFormPublication } from "./publication-status"; + +/** + * One field of the surrounding form, wherever the layout puts it. + * + * Renders **nothing** for a name this surface does not have, and that is load + * bearing rather than lenient: a localized content type splits its fields across + * a shared surface and a per-language one, so one layout naming `title` and + * `category` places each on the tab it belongs to without ever asking which + * table it lives on. + * + * A field override registered in `buildPlugin` is already baked into the element + * this renders - overrides and layouts compose, neither replaces the other. + */ +export const ContentFormField = ({ name }: { name: string }) => { + const { fields, markRendered } = useContentForm(); + + markRendered?.(name); + + return <>{fields[name] ?? null}; +}; + +/** + * Every field this surface has that the layout has not named itself. + * + * The escape hatch for a layout that wants to place two fields deliberately and + * let the rest fall where they may - and the reason a field added to the + * definition later does not silently vanish from a layout written today. + */ +export const ContentFormRemainingFields = ({ + exclude = [], +}: { + exclude?: readonly string[]; +}) => { + const { fieldNames, fields, markRendered } = useContentForm(); + const skip = new Set(exclude); + const remaining = fieldNames.filter(name => !skip.has(name)); + + for (const name of remaining) markRendered?.(name); + + return ( + <> + {remaining.map(name => ( + {fields[name]} + ))} + + ); +}; + +/** + * The read-only publication line, for a layout that wants it in its sidebar. + * + * Renders nothing for a content type without `publication`, and nothing while + * creating - there is no lifecycle to report before the record exists. + */ +export const ContentFormStatus = () => { + const { mode, publication } = useContentForm(); + + if (!publication.enabled || mode === "create") return null; + + return ( + + ); +}; + +/** + * The submit row. + * + * The button is the surrounding `AutoForm`'s own, so it disables while + * submitting and while the schema is unsatisfied exactly like the generated + * one - a layout cannot accidentally ship a button that allows a double write. + */ +export const ContentFormActions = ({ + cancelHref, + children, + className, + submitLabel, + ...props +}: React.ComponentProps<"div"> & { + /** Renders a Cancel link back to the list. */ + cancelHref?: string; + submitLabel?: React.ReactNode; +}) => { + const t = useTranslations("core.global"); + const tContent = useTranslations("core.content"); + const { mode } = useContentForm(); + + return ( +
    + {children} + {cancelHref ? ( + + ) : null} + + {submitLabel ?? + tContent(mode === "create" ? "create.submit" : "edit.submit")} + +
    + ); +}; + +/** + * The two-column editor shell: a wide main column and a sidebar. + * + * Single column below `lg`, which is the only responsive decision worth making + * here - a metadata sidebar next to a 40-character-wide editor is worse than no + * sidebar at all. + */ +export const ContentFormLayoutGrid = ({ + children, + className, + ...props +}: React.ComponentProps<"div">) => ( +
    + {children} +
    +); + +export const ContentFormMain = ({ + children, + className, + ...props +}: React.ComponentProps<"div">) => ( +
    + {children} +
    +); + +/** + * The metadata column. Sticky on large screens so the actions stay reachable + * while a long body scrolls, and static below that, where sticky would eat the + * viewport. + */ +export const ContentFormSidebar = ({ + children, + className, + ...props +}: React.ComponentProps<"div">) => ( +
    + {children} +
    +); + +/** A titled card. Renders no heading element when it has no title. */ +export const ContentFormSection = ({ + children, + className, + desc, + title, + ...props +}: Omit, "title"> & { + desc?: React.ReactNode; + title?: React.ReactNode; +}) => ( +
    + {title ? ( +
    +

    {title}

    + {desc ? ( +

    + {desc} +

    + ) : null} +
    + ) : null} + +
    {children}
    +
    +); diff --git a/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx b/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx new file mode 100644 index 000000000..9f68bafa0 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx @@ -0,0 +1,44 @@ +// No "use client": reached only from `content-form` / a layout, both of which +// are already inside a client entry. +import { CircleCheckIcon, FileClockIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; + +/** + * A read-only line saying where the record is in the lifecycle. + * + * Read-only on purpose, and the rule is the same in a dialog and on a page: + * `status` and `publishedAt` are not in the form schema, and the one thing that + * moves them is the publish action on the list. Two competing mutation paths in + * one form is how a form ends up fighting its own optimistic state. + */ +export const ContentFormPublication = ({ + publishedAt, + status, +}: { + publishedAt: unknown; + status: unknown; +}) => { + const t = useTranslations("core.content.status"); + const published = status === "published"; + const date = typeof publishedAt === "string" ? new Date(publishedAt) : null; + + return ( +
    + {t("label")} + + {published ? ( + + ) : ( + + )} + {published ? t("published") : t("draft")} + + + {date ? : t("never_published")} + +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx new file mode 100644 index 000000000..06a99cf43 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import React from "react"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentFormLayout } from "@/lib/plugin"; + +import { useRouter } from "@/lib/navigation"; + +import { ContentForm } from "../actions/content-form"; +import { LocaleEditor } from "../actions/translations/locale-editor"; + +export interface ContentFormPageProps { + /** Where Cancel goes, and where a create lands when there is no edit page. */ + backHref: string; + /** + * Where a successful create goes: the new record's edit page when the content + * type has one, and the list when it does not. + * + * A template rather than a callback, because this component is rendered from a + * server one - `{id}` is substituted with the identifier the mutation returned. + */ + createdHrefTemplate?: string; + /** Existing values when editing; absent when creating. */ + data?: Record & { id: number }; + /** The content type's default locale. Set when `translationSpec` is. */ + defaultLocale?: string; + editorial?: boolean; + fieldOverrides?: Record< + string, + (props: ItemAutoFormComponentProps) => React.ReactNode + >; + layout?: ContentFormLayout; + permissionModule: string; + pluginId: string; + publication?: boolean; + singular: string; + spec: ContentFormSpec; + title?: string; + /** Localized-field form spec, or `null` when the content type is not localized. */ + translationSpec?: ContentFormSpec | null; +} + +/** + * The client half of a generated create/edit **page**. + * + * Renders exactly what the dialog renders - the same `ContentForm`, the same + * `LocaleEditor` for a localized content type - so page mode is a change of + * where the form is, not of what it does. Every mutation, precondition, toast + * and invalidation still comes from the Content Engine. + */ +export const ContentFormPage = ({ + backHref, + createdHrefTemplate, + data, + defaultLocale, + editorial = false, + permissionModule, + pluginId, + translationSpec = null, + ...props +}: ContentFormPageProps) => { + const { push } = useRouter(); + + const onCreated = (id: number) => { + push( + createdHrefTemplate + ? createdHrefTemplate.replace("{id}", String(id)) + : backHref, + ); + }; + + const form = { ...props, data, onCreated, presentation: "page" as const }; + + // A localized record is edited one language at a time, and the tab strip needs + // a record to exist first - so a create page writes the shared fields, then + // hands over to the edit page where the locales live. + if (translationSpec && data) { + return ( + + ); + } + + return ; +}; diff --git a/packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx b/packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx new file mode 100644 index 000000000..a468151d3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/page/page-views.test.tsx @@ -0,0 +1,309 @@ +import type { ReactElement } from "react"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RegisteredFrontendContentType } from "@/content/admin/config"; +import type { AnyContentTypeDefinition } from "@/content/types"; +import type { ContentFormLayout } from "@/lib/plugin"; + +import { defineContentType } from "@/content/define"; +import { field } from "@/content/fields"; + +vi.mock("server-only", () => ({})); + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => { + await Promise.resolve(); + + // `getContentLabels` asks `t.has` before reading, so a plugin that + // translates nothing still gets readable labels. + return Object.assign((key: string) => key, { has: () => false }); + }, +})); + +vi.mock("@/lib/navigation", () => ({ + Link: () => null, + getPathname: () => "", + redirect: () => undefined, + usePathname: () => "", + useRouter: () => ({ push: () => undefined, refresh: () => undefined }), +})); + +const permissions = new Set(); +vi.mock("@/lib/api/get-session-admin-api", () => ({ + checkAdminPermissionApi: async ({ permission }: { permission: string }) => { + await Promise.resolve(); + + return permissions.has(permission); + }, +})); + +const fetched = { data: undefined as unknown, status: 200 }; +vi.mock("@/content/admin/fetch.server", () => ({ + contentApiFetch: async () => { + await Promise.resolve(); + + return fetched; + }, +})); + +const notFoundCalls = { count: 0 }; +vi.mock("next/navigation", () => ({ + notFound: () => { + notFoundCalls.count += 1; + throw new Error("NEXT_NOT_FOUND"); + }, +})); + +const { ContentCreatePageView, ContentEditPageView } = + await import("./page-views"); + +const pageArticle = defineContentType({ + id: "test.page-article", + tableName: "test_page_articles", + fields: { + title: field.text({ required: true, minLength: 1 }), + excerpt: field.textarea({ nullable: true }), + }, + admin: { + label: { plural: "Page Articles", singular: "Page Article" }, + create: { mode: "page" }, + edit: { mode: "page" }, + }, +}); + +const entryOf = ( + registration: Partial = {}, +): RegisteredFrontendContentType => ({ + definition: pageArticle, + pluginId: "@vitnode/test", + registration: { + definition: pageArticle, + ...registration, + }, +}); + +/** The `ContentFormPage` element the view returns, wherever it sits. */ +const formPage = ( + element: ReactElement, +): ReactElement> => { + const walk = ( + node: unknown, + ): null | ReactElement> => { + if (node === null || typeof node !== "object") return null; + if (Array.isArray(node)) { + for (const child of node) { + const found = walk(child); + if (found) return found; + } + + return null; + } + if (!("props" in node)) return null; + + const element = node as ReactElement>; + if ("spec" in element.props && "backHref" in element.props) return element; + + return walk(element.props.children); + }; + + const found = walk(element); + if (!found) throw new Error("No ContentFormPage in the rendered tree."); + + return found; +}; + +const render = async (view: Promise) => + formPage(await view).props; + +beforeEach(() => { + permissions.clear(); + notFoundCalls.count = 0; + fetched.status = 200; + fetched.data = { id: 7, labels: {}, title: "Hello" }; +}); + +describe("the generated create page", () => { + it("renders the generated form for someone who may create", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + + const props = await render(ContentCreatePageView({ entry: entryOf() })); + + expect(props.backHref).toBe("/admin/content/test/page-article"); + expect(props.layout).toBeUndefined(); + expect((props.spec as { fields: { name: string }[] }).fields).toHaveLength( + 2, + ); + }); + + it("hands a new record over to its own edit page", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + + const props = await render(ContentCreatePageView({ entry: entryOf() })); + + expect(props.createdHrefTemplate).toBe( + "/admin/content/test/page-article/{id}/edit", + ); + }); + + it("goes back to the list when there is no edit page to go to", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + const dialogEdit = defineContentType({ + id: "test.page-article", + tableName: "test_page_articles", + fields: { title: field.text({ required: true }) }, + admin: { + label: { plural: "Page Articles", singular: "Page Article" }, + create: { mode: "page" }, + }, + }); + + const props = await render( + ContentCreatePageView({ + entry: { + ...entryOf(), + definition: dialogEdit, + }, + }), + ); + + expect(props.createdHrefTemplate).toBeUndefined(); + }); + + it("404s without can_create, however the URL was reached", async () => { + permissions.add("can_view"); + + await expect(ContentCreatePageView({ entry: entryOf() })).rejects.toThrow( + "NEXT_NOT_FOUND", + ); + expect(notFoundCalls.count).toBe(1); + }); + + it("404s without can_view", async () => { + permissions.add("can_create"); + + await expect(ContentCreatePageView({ entry: entryOf() })).rejects.toThrow( + "NEXT_NOT_FOUND", + ); + }); + + it("uses the registered layout when there is one", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + const layout: ContentFormLayout = () => null; + + const props = await render( + ContentCreatePageView({ entry: entryOf({ forms: { layout } }) }), + ); + + expect(props.layout).toBe(layout); + }); + + it("carries the field overrides into the layout's fields", async () => { + permissions.add("can_view"); + permissions.add("can_create"); + const component = () => null; + + const props = await render( + ContentCreatePageView({ + entry: entryOf({ + fields: { title: { component } }, + forms: { layout: () => null }, + }), + }), + ); + + expect(props.fieldOverrides).toEqual({ title: component }); + }); +}); + +describe("the generated edit page", () => { + it("opens on the record the URL named", async () => { + permissions.add("can_view"); + permissions.add("can_edit"); + + const props = await render( + ContentEditPageView({ entry: entryOf(), itemId: 7 }), + ); + + expect(props.data).toMatchObject({ id: 7 }); + expect(props.title).toBe("Hello"); + }); + + it("404s for a record that is not there", async () => { + permissions.add("can_view"); + permissions.add("can_edit"); + fetched.status = 404; + fetched.data = undefined; + + await expect( + ContentEditPageView({ entry: entryOf(), itemId: 99 }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + }); + + it("404s without can_edit on a content type with no translations", async () => { + permissions.add("can_view"); + permissions.add("can_translate"); + + await expect( + ContentEditPageView({ entry: entryOf(), itemId: 7 }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + }); + + it("opens for a translator on a localized content type", async () => { + permissions.add("can_view"); + permissions.add("can_translate"); + + const localized = defineContentType({ + id: "test.page-localized", + tableName: "test_page_localized", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + featured: field.boolean({ defaultValue: false }), + title: field.text({ localized: true, required: true }), + }, + admin: { + label: { plural: "Pages", singular: "Page" }, + create: { mode: "page" }, + edit: { mode: "page" }, + }, + }); + + const props = await render( + ContentEditPageView({ + entry: { + ...entryOf(), + definition: localized, + }, + itemId: 7, + }), + ); + + // The locale tabs are what a translator came for, so the translation spec + // has to reach the client half. + expect(props.translationSpec).toMatchObject({ + fields: [{ name: "title" }], + }); + }); + + it("uses the edit layout, not the create one", async () => { + permissions.add("can_view"); + permissions.add("can_edit"); + const create: ContentFormLayout = () => null; + const edit: ContentFormLayout = () => null; + + const props = await render( + ContentEditPageView({ + entry: entryOf({ + forms: { create: { layout: create }, edit: { layout: edit } }, + }), + itemId: 7, + }), + ); + + expect(props.layout).toBe(edit); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx new file mode 100644 index 000000000..a144bb628 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx @@ -0,0 +1,229 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import type { RegisteredFrontendContentType } from "@/content/admin/config"; +import type { ContentFormSpec } from "@/content/admin/spec"; + +import { Button } from "@/components/ui/button"; +import { HeaderContent } from "@/components/ui/header-content"; +import { contentApiFetch } from "@/content/admin/fetch.server"; +import { + buildContentFormSpec, + buildContentTranslationFormSpec, +} from "@/content/admin/spec"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { contentAdminHref, contentEditHrefTemplate } from "@/content/registry"; +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; +import { Link } from "@/lib/navigation"; +import { resolveContentFormLayout } from "@/lib/plugin"; + +import { getContentLabels } from "../content-admin-view"; +import { ContentFormPage } from "./content-form-page"; + +/** The row shape a form opens on: the record, plus its reference labels. */ +const zodDetail = z + .object({ + id: z.number(), + labels: z.record(z.string(), z.string().nullable()), + }) + .loose(); + +const fieldOverridesOf = (entry: RegisteredFrontendContentType) => + Object.fromEntries( + Object.entries(entry.registration.fields ?? {}).map(([name, override]) => [ + name, + override.component, + ]), + ); + +/** + * The specs a form page needs, and the labels its headings use. + * + * Identical to what the list screen builds for its dialogs - page mode changes + * where the form is, not what the form is. + */ +const buildPageSpecs = async (entry: RegisteredFrontendContentType) => { + const { definition, pluginId } = entry; + const labels = await getContentLabels(entry); + const shared = { + definition, + labelEnum: labels.labelEnum, + labelField: labels.labelField, + pluginId, + }; + + return { + labels, + spec: buildContentFormSpec(shared), + translationSpec: buildContentTranslationFormSpec(shared), + } satisfies { + labels: Awaited>; + spec: ContentFormSpec; + translationSpec: ContentFormSpec | null; + }; +}; + +/** + * The generated **create page**. + * + * Reachable only with `can_view` *and* `can_create`, checked here rather than + * inferred from whether a button was rendered - a URL typed into the address bar + * has to answer the same way the button would have. The generated `POST` checks + * again, which is the check that actually stops the write. + */ +export const ContentCreatePageView = async ({ + entry, +}: { + entry: RegisteredFrontendContentType; +}) => { + const { definition, pluginId, registration } = entry; + + const [t, tPage, canView, canCreate] = await Promise.all([ + getTranslations("core.content.create"), + getTranslations("core.content.page"), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.create, + plugin: pluginId, + }), + ]); + + if (!canView || !canCreate) notFound(); + + const { spec } = await buildPageSpecs(entry); + const singular = definition.admin.label.singular; + const backHref = contentAdminHref(definition.id); + + return ( +
    + + + + + +
    + ); +}; + +/** + * The generated **edit page**. + * + * Reachable with `can_edit`, or with `can_translate` on a localized content type + * - the same pair the edit dialog opens for, because a translator who may not + * touch a shared field still needs somewhere to write the Polish copy. + * + * A record that does not exist is a 404, and so is one whose content type the + * session may not view: the read goes through the generated API, which enforces + * `can_view` itself, so a missing permission and a missing row are the same + * answer from here. + */ +export const ContentEditPageView = async ({ + entry, + itemId, +}: { + entry: RegisteredFrontendContentType; + itemId: number; +}) => { + const { definition, pluginId, registration } = entry; + const localized = definition.localization.enabled; + + const [t, tPage, canView, canEdit, canTranslate] = await Promise.all([ + getTranslations("core.content.edit"), + getTranslations("core.content.page"), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: pluginId, + }), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.translate, + plugin: pluginId, + }), + ]); + + if (!canView) notFound(); + if (!canEdit && !(localized && canTranslate)) notFound(); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${itemId}`, + pluginId, + schema: zodDetail, + }); + + if (result.status !== 200 || !result.data) notFound(); + + const { spec, translationSpec } = await buildPageSpecs(entry); + const backHref = contentAdminHref(definition.id); + const singular = definition.admin.label.singular; + const data = result.data as Record & { id: number }; + const titleField = definition.admin.titleField; + const title = + titleField && typeof data[titleField] === "string" + ? data[titleField] + : `#${data.id}`; + + return ( +
    + + + + + +
    + ); +}; 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 4adc5f130..869f6f512 100644 --- a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx @@ -8,7 +8,7 @@ import type { ContentColumnSpec, ContentFormSpec } from "@/content/admin/spec"; import { zodPaginationPageInfo } from "@/api/lib/with-pagination"; import { DataTable } from "@/components/table/data-table"; import { contentApiFetch } from "@/content/admin/fetch.server"; -import { orderableColumns } from "@/content/registry"; +import { contentEditHref, orderableColumns } from "@/content/registry"; import type { ContentRowData } from "./cells"; @@ -246,6 +246,13 @@ export const ContentTableView = async ({ ([name, override]) => [name, override.component], ), )} + // Page mode turns the pencil into a link. Nothing of the form is + // mounted, so a 25-row table stays 25 anchors. + href={ + definition.admin.edit.mode === "page" + ? contentEditHref(definition.id, row.id) + : undefined + } permissionModule={definition.permissionModule} pluginId={pluginId} publication={definition.publication.enabled} From 293c9746ba08d817fdf3bca964cde78a0402649d Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 11:12:57 +0200 Subject: [PATCH 110/123] refactor(blog): migrate the blog onto the Content Engine The blog becomes a Content Engine consumer and stops being a CRUD implementation. Two content types replace six route files, three lib files, two admin screens, two create/edit dialogs, a search indexer and a hand-written slug uniqueness check. blog.category dialog create/edit, colour field override, colour cell override, relation target blog.post page create/edit, custom layout, AutoFormEditor, category relation, author, publication, editorial, search, delivery The ids stay `blog.post` and `blog.category`, the tables stay `blog_posts` and `blog_categories`, the fields stay `categoryId` and `authorId`, and the permission modules stay `posts` and `categories` - so every existing role, every foreign key and every stored permission still addresses the right thing. "Article" is what the AdminCP calls it, because that is what people call it. The migration is additive. Nothing is dropped and no record moves: - the text moves out of `core_languages_words` into the two generated translation tables, one row per language that genuinely had one, - every existing article becomes `published` with `publishedAt = createdAt` - they were all publicly readable before, and that is the one publication fact the old schema can prove. No revision history is invented, - a record with no default-locale translation gets one built from a value it already has, rather than being left unreadable, - only rows that were actually copied are deleted from the old storage. A PostgreSQL suite seeds a pre-migration install - two categories with different colours, three articles, an author, rich bodies, existing slugs and a Polish translation - runs the committed migration over it, and reads everything back through the engine's own services. Search and events stop being duplicated. The `search` block replaces `api/lib/search.ts`, which emitted a document per *enabled language* whether or not a translation existed. The blog's own event names survive as adapters over `content.blog.*`, so one mutation still means one announcement - `blog.post.deleted` loses `categoryId`, because the row is gone by then and inventing one would be a lie in an audit trail. The legacy admin URLs redirect. The two public read routes are removed: they read `core_languages_words`, which no longer holds the data, and the article's generated public API is a better answer at the same `/blog/` prefix. Co-Authored-By: Claude Opus 5 (1M context) --- .../0035_migrate_blog_to_content_engine.sql | 192 + apps/docs/migrations/meta/0035_snapshot.json | 4456 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + .../(vitnode-blog)/blog/categories/page.tsx | 68 +- .../(vitnode-blog)/blog/posts/page.tsx | 77 +- .../@breadcrumb/blog/categories/page.tsx | 5 - .../(auth)/@breadcrumb/blog/posts/page.tsx | 5 - apps/docs/src/locales/@vitnode/blog/pl.json | 123 +- plugins/blog/package.json | 13 +- .../blog/src/api/lib/categories-language.ts | 69 - plugins/blog/src/api/lib/events.ts | 154 +- plugins/blog/src/api/lib/posts-language.ts | 129 - plugins/blog/src/api/lib/search.ts | 150 - .../src/api/modules/admin/admin.module.ts | 33 +- .../categories/categories.admin.module.ts | 12 - .../admin/categories/routes/create.route.ts | 68 - .../admin/categories/routes/delete.route.ts | 58 - .../admin/categories/routes/edit.route.ts | 84 - .../modules/admin/posts/posts.admin.module.ts | 12 - .../admin/posts/routes/create.route.ts | 133 - .../admin/posts/routes/delete.route.ts | 51 - .../modules/admin/posts/routes/edit.route.ts | 139 - .../modules/categories/categories.module.ts | 11 - .../modules/categories/routes/get.route.ts | 137 - .../src/api/modules/posts/posts.module.ts | 11 - .../src/api/modules/posts/routes/get.route.ts | 150 - plugins/blog/src/config.api.ts | 57 +- plugins/blog/src/config.test-d.ts | 51 + plugins/blog/src/config.tsx | 57 +- plugins/blog/src/content/category.ts | 72 + .../blog/src/content/content-types.test.ts | 115 + plugins/blog/src/content/post.ts | 176 + plugins/blog/src/database/categories.ts | 20 +- plugins/blog/src/database/harness.ts | 312 ++ plugins/blog/src/database/index.ts | 3 - .../src/database/migration-postgres.test.ts | 368 ++ plugins/blog/src/database/posts.ts | 29 +- plugins/blog/src/database/relations.ts | 18 - plugins/blog/src/locales/en.json | 123 +- .../src/routes/admin/blog/categories/page.tsx | 67 +- .../blog/src/routes/admin/blog/posts/page.tsx | 78 +- .../breadcrumb/admin/blog/categories/page.tsx | 5 - .../breadcrumb/admin/blog/posts/page.tsx | 5 - .../src/views/admin/article/editor-field.tsx | 45 + .../views/admin/article/form-layout.test.tsx | 120 + .../src/views/admin/article/form-layout.tsx | 67 + .../admin/categories/actions/actions.tsx | 46 - .../actions/create-edit/create-edit.tsx | 90 - .../create-edit/mutation-api.server.ts | 60 - .../table/actions/delete/delete-action.tsx | 71 - .../actions/delete/mutation-api.server.ts | 29 - .../categories/table/actions/edit-action.tsx | 81 - .../table/categories-admin-view.tsx | 102 - .../views/admin/category/color-cell.test.tsx | 42 + .../src/views/admin/category/color-cell.tsx | 40 + .../src/views/admin/category/color-field.tsx | 27 + .../src/views/admin/posts/actions/actions.tsx | 46 - .../posts/actions/create-edit/create-edit.tsx | 174 - .../actions/create-edit/multi-lang-fields.tsx | 138 - .../create-edit/mutation-api.server.ts | 60 - .../table/actions/delete/delete-action.tsx | 71 - .../actions/delete/mutation-api.server.ts | 29 - .../admin/posts/table/actions/edit-action.tsx | 81 - .../admin/posts/table/posts-admin-view.tsx | 102 - plugins/blog/tsconfig.json | 14 +- plugins/blog/vitest.config.ts | 24 + 66 files changed, 6534 insertions(+), 2928 deletions(-) create mode 100644 apps/docs/migrations/0035_migrate_blog_to_content_engine.sql create mode 100644 apps/docs/migrations/meta/0035_snapshot.json delete mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx delete mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx delete mode 100644 plugins/blog/src/api/lib/categories-language.ts delete mode 100644 plugins/blog/src/api/lib/posts-language.ts delete mode 100644 plugins/blog/src/api/lib/search.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/routes/create.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/routes/create.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts delete mode 100644 plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts delete mode 100644 plugins/blog/src/api/modules/categories/categories.module.ts delete mode 100644 plugins/blog/src/api/modules/categories/routes/get.route.ts delete mode 100644 plugins/blog/src/api/modules/posts/posts.module.ts delete mode 100644 plugins/blog/src/api/modules/posts/routes/get.route.ts create mode 100644 plugins/blog/src/config.test-d.ts create mode 100644 plugins/blog/src/content/category.ts create mode 100644 plugins/blog/src/content/content-types.test.ts create mode 100644 plugins/blog/src/content/post.ts create mode 100644 plugins/blog/src/database/harness.ts create mode 100644 plugins/blog/src/database/migration-postgres.test.ts delete mode 100644 plugins/blog/src/database/relations.ts delete mode 100644 plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx delete mode 100644 plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx create mode 100644 plugins/blog/src/views/admin/article/editor-field.tsx create mode 100644 plugins/blog/src/views/admin/article/form-layout.test.tsx create mode 100644 plugins/blog/src/views/admin/article/form-layout.tsx delete mode 100644 plugins/blog/src/views/admin/categories/actions/actions.tsx delete mode 100644 plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx delete mode 100644 plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx delete mode 100644 plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx delete mode 100644 plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx create mode 100644 plugins/blog/src/views/admin/category/color-cell.test.tsx create mode 100644 plugins/blog/src/views/admin/category/color-cell.tsx create mode 100644 plugins/blog/src/views/admin/category/color-field.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/actions.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx delete mode 100644 plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx delete mode 100644 plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts delete mode 100644 plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx delete mode 100644 plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx create mode 100644 plugins/blog/vitest.config.ts diff --git a/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql b/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql new file mode 100644 index 000000000..cd66b71e3 --- /dev/null +++ b/apps/docs/migrations/0035_migrate_blog_to_content_engine.sql @@ -0,0 +1,192 @@ +CREATE TABLE "blog_categories_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "name" varchar(100) NOT NULL, + CONSTRAINT "blog_categories_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "blog_posts_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(255) NOT NULL, + "friendlyUrl" varchar(255) NOT NULL, + "content" text NOT NULL, + CONSTRAINT "blog_posts_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "blog_posts" DROP CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk"; +--> statement-breakpoint +ALTER TABLE "blog_categories" ALTER COLUMN "updatedAt" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "blog_posts" ALTER COLUMN "updatedAt" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ADD CONSTRAINT "blog_categories_translations_itemId_blog_categories_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."blog_categories"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_categories_translations" ADD CONSTRAINT "blog_categories_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ADD CONSTRAINT "blog_posts_translations_itemId_blog_posts_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."blog_posts"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "blog_posts_translations" ADD CONSTRAINT "blog_posts_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "blog_categories_translations_language_id_idx" ON "blog_categories_translations" USING btree ("languageId");--> statement-breakpoint +CREATE INDEX "blog_posts_translations_language_id_status_idx" ON "blog_posts_translations" USING btree ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "blog_posts_translations_language_id_friendly_url_key" ON "blog_posts_translations" USING btree ("languageId","friendlyUrl");--> statement-breakpoint +ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."blog_categories"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "blog_categories_created_at_idx" ON "blog_categories" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "blog_categories_updated_at_idx" ON "blog_categories" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "blog_posts_status_created_at_idx" ON "blog_posts" USING btree ("status","createdAt");--> statement-breakpoint +CREATE INDEX "blog_posts_category_id_idx" ON "blog_posts" USING btree ("categoryId");--> statement-breakpoint +CREATE INDEX "blog_posts_author_id_idx" ON "blog_posts" USING btree ("authorId");--> statement-breakpoint +CREATE INDEX "blog_posts_created_at_idx" ON "blog_posts" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "blog_posts_updated_at_idx" ON "blog_posts" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "blog_posts_status_published_at_idx" ON "blog_posts" USING btree ("status","publishedAt");--> statement-breakpoint +-- +-- Data migration: the blog's own storage -> the Content Engine's. +-- +-- Nothing above this line dropped a table or a column, and nothing below moves a +-- record: ids, categories, authors and timestamps stay exactly where they are. +-- What moves is the *text*, out of `core_languages_words` and into the two +-- translation tables the engine reads. +-- + +-- 1. Publication. Every article that exists today is publicly readable - the old +-- public route returned every row and every search document was written +-- `isPublic: true` - so they all migrate as published. `publishedAt` is +-- `createdAt`, which is the only publication date the old schema can prove; +-- no revision history is fabricated, so `version` stays at its default of 1. +UPDATE "blog_posts" +SET "status" = 'published', "publishedAt" = "createdAt" +WHERE "status" = 'draft' AND "publishedAt" IS NULL;--> statement-breakpoint + +-- 2. Category names. One row per (category, language) that actually had a title, +-- so a language nobody translated into stays untranslated rather than being +-- invented. A stored empty title would break `name`'s minimum length, so it +-- falls back to a unique placeholder an editor can see and fix. +INSERT INTO "blog_categories_translations" + ("itemId", "languageId", "version", "createdAt", "updatedAt", "name") +SELECT + c."id", + l."id", + 1, + c."createdAt", + c."updatedAt", + LEFT(COALESCE(NULLIF(w."value", ''), 'category-' || c."id"), 100) +FROM "core_languages_words" w +JOIN "blog_categories" c ON c."id" = w."itemId" +JOIN "core_languages" l ON l."code" = w."languageCode" +WHERE w."pluginCode" = '@vitnode/blog' + AND w."tableName" = 'blog_categories' + AND w."variable" = 'title' +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 3. Article text. The three variables the plugin kept side by side become one +-- row, for each (article, language) pair that had any of them. A missing +-- friendly URL falls back to something unique rather than to an empty string, +-- which the new UNIQUE (languageId, friendlyUrl) index would reject on the +-- second article. +INSERT INTO "blog_posts_translations" ( + "itemId", "languageId", "version", "createdAt", "updatedAt", + "publishedAt", "status", "title", "friendlyUrl", "content" +) +SELECT + p."id", + l."id", + 1, + p."createdAt", + p."updatedAt", + p."createdAt", + 'published', + LEFT(COALESCE(w."title", ''), 255), + LEFT( + COALESCE(NULLIF(w."friendlyUrl", ''), 'post-' || p."id" || '-' || l."code"), + 255 + ), + COALESCE(w."content", '') +FROM ( + SELECT + "itemId", + "languageCode", + MAX("value") FILTER (WHERE "variable" = 'title') AS "title", + MAX("value") FILTER (WHERE "variable" = 'content') AS "content", + MAX("value") FILTER (WHERE "variable" = 'friendlyUrl') AS "friendlyUrl" + FROM "core_languages_words" + WHERE "pluginCode" = '@vitnode/blog' + AND "tableName" = 'blog_posts' + AND "variable" IN ('title', 'content', 'friendlyUrl') + GROUP BY "itemId", "languageCode" +) w +JOIN "blog_posts" p ON p."id" = w."itemId" +JOIN "core_languages" l ON l."code" = w."languageCode" +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 4. The default locale. A localized content type refuses to leave a record +-- without a translation in its `defaultLocale`, so a record that was only ever +-- written in another language gets an English row built from the name it +-- already has in whichever language it does have. Nothing is invented: the +-- value is one the record genuinely carries. +INSERT INTO "blog_categories_translations" + ("itemId", "languageId", "version", "createdAt", "updatedAt", "name") +SELECT + c."id", + l."id", + 1, + c."createdAt", + c."updatedAt", + LEFT( + COALESCE( + ( + SELECT NULLIF(t."name", '') + FROM "blog_categories_translations" t + WHERE t."itemId" = c."id" + ORDER BY t."languageId" + LIMIT 1 + ), + 'category-' || c."id" + ), + 100 + ) +FROM "blog_categories" c +JOIN "core_languages" l ON l."code" = 'en' +ON CONFLICT DO NOTHING;--> statement-breakpoint + +INSERT INTO "blog_posts_translations" ( + "itemId", "languageId", "version", "createdAt", "updatedAt", + "publishedAt", "status", "title", "friendlyUrl", "content" +) +SELECT + p."id", + l."id", + 1, + p."createdAt", + p."updatedAt", + p."createdAt", + 'published', + LEFT(COALESCE(NULLIF(source."title", ''), 'post-' || p."id"), 255), + LEFT('post-' || p."id" || '-en', 255), + COALESCE(source."content", '') +FROM "blog_posts" p +JOIN "core_languages" l ON l."code" = 'en' +LEFT JOIN LATERAL ( + SELECT t."title", t."content" + FROM "blog_posts_translations" t + WHERE t."itemId" = p."id" + ORDER BY t."languageId" + LIMIT 1 +) source ON TRUE +ON CONFLICT DO NOTHING;--> statement-breakpoint + +-- 5. The old storage, now that everything in it has a new home. Scoped to rows +-- that were genuinely migrated: a word in a language the install does not have +-- could not be copied, so it is left where it is rather than deleted. +DELETE FROM "core_languages_words" w +USING "core_languages" l +WHERE w."pluginCode" = '@vitnode/blog' + AND w."tableName" IN ('blog_categories', 'blog_posts') + AND l."code" = w."languageCode"; diff --git a/apps/docs/migrations/meta/0035_snapshot.json b/apps/docs/migrations/meta/0035_snapshot.json new file mode 100644 index 000000000..4b679e430 --- /dev/null +++ b/apps/docs/migrations/meta/0035_snapshot.json @@ -0,0 +1,4456 @@ +{ + "id": "42b7098a-c42b-4c70-8673-087b8ff56ce4", + "prevId": "0f660415-9144-44ed-9d96-78cd76711ebf", + "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 + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_content_slug_history": { + "name": "core_content_slug_history", + "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 + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_categories_created_at_idx": { + "name": "blog_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_categories_updated_at_idx": { + "name": "blog_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 + }, + "public.blog_categories_translations": { + "name": "blog_categories_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "blog_categories_translations_language_id_idx": { + "name": "blog_categories_translations_language_id_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_categories_translations_itemId_blog_categories_id_fk": { + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "blog_categories", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_categories_translations_languageId_core_languages_id_fk": { + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_categories_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_categories_translations_item_id_language_id_pk": { + "name": "blog_categories_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "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 + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "blog_posts_status_created_at_idx": { + "name": "blog_posts_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": {} + }, + "blog_posts_category_id_idx": { + "name": "blog_posts_category_id_idx", + "columns": [ + { + "expression": "categoryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_author_id_idx": { + "name": "blog_posts_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_created_at_idx": { + "name": "blog_posts_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_updated_at_idx": { + "name": "blog_posts_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_status_published_at_idx": { + "name": "blog_posts_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": { + "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": "restrict", + "onUpdate": "cascade" + }, + "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.blog_posts_translations": { + "name": "blog_posts_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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(255)", + "primaryKey": false, + "notNull": true + }, + "friendlyUrl": { + "name": "friendlyUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_posts_translations_language_id_status_idx": { + "name": "blog_posts_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blog_posts_translations_language_id_friendly_url_key": { + "name": "blog_posts_translations_language_id_friendly_url_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "friendlyUrl", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_translations_itemId_blog_posts_id_fk": { + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "blog_posts", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "blog_posts_translations_languageId_core_languages_id_fk": { + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "tableFrom": "blog_posts_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "blog_posts_translations_item_id_language_id_pk": { + "name": "blog_posts_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles": { + "name": "example_advanced_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 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationNoIndex": { + "name": "syndicationNoIndex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "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()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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 + }, + "noIndex": { + "name": "noIndex", + "type": "boolean", + "primaryKey": false, + "notNull": 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 + }, + "public.example_localized_articles": { + "name": "example_localized_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 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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 2ba6eff0f..72819b08a 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1786292946013, "tag": "0034_add_example_article_no_index_flag", "breakpoints": true + }, + { + "idx": 35, + "version": "7", + "when": 1786350996229, + "tag": "0035_migrate_blog_to_content_engine", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx index 3dfeea398..81d74e4c9 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx @@ -1,64 +1,8 @@ -import type { Metadata } from "next"; +import { blogCategoryContentType } from "@vitnode/blog/content/category"; +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@vitnode/blog/const"; -import { ActionsCategoriesAdmin } from "@vitnode/blog/views/admin/categories/actions/actions"; - -const CategoriesAdminView = dynamic(async () => - import("@vitnode/blog/views/admin/categories/table/categories-admin-view").then(mod => ({ - default: mod.CategoriesAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("categories"), - }; -}; - -export default async function CategoriesPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.categories"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
    - - {canCreate && } - - - }> - - -
    -
    - ); +/** The address categories used to live at. See the posts page next door. */ +export default async function LegacyCategoriesPage() { + await redirect(contentAdminHref(blogCategoryContentType.id)); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx index 99d58036d..d381bcf9f 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx @@ -1,64 +1,15 @@ -import type { Metadata } from "next"; - -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@vitnode/blog/const"; -import { ActionsPostsAdmin } from "@vitnode/blog/views/admin/posts/actions/actions"; - -const PostsAdminView = dynamic(async () => - import("@vitnode/blog/views/admin/posts/table/posts-admin-view").then(mod => ({ - default: mod.PostsAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("posts"), - }; -}; - -export default async function PostsPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.posts"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
    - - {canCreate && } - - - }> - - -
    -
    - ); +import { blogPostContentType } from "@vitnode/blog/content/post"; +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; + +/** + * The address articles used to live at. + * + * A redirect rather than a second list screen: the AdminCP linked here for + * several releases, so the URL is in bookmarks and in muscle memory - but the + * page behind it is now generated, and keeping a duplicate of it would mean two + * tables to fix every time one of them was wrong. + */ +export default async function LegacyPostsPage() { + await redirect(contentAdminHref(blogPostContentType.id)); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx deleted file mode 100644 index b4680017f..000000000 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/categories/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx deleted file mode 100644 index 6aad0fb44..000000000 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/blog/posts/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index c4dd50874..e3eb86744 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -1,92 +1,65 @@ { "@vitnode/blog": { "title": "Blog", - "admin": { - "nav": { - "posts": "Wpisy", - "categories": "Kategorie" - }, - "categories": { - "desc": "Zarządzaj kategoriami wpisów na blogu.", - "table": { + "content": { + "post": { + "title": "Artykuły", + "desc": "Pisz artykuły na blogu i zarządzaj nimi.", + "fields": { "title": "Tytuł", + "friendlyUrl": "Przyjazny adres URL", + "content": "Treść", + "categoryId": "Kategoria", + "authorId": "Autor", + "status": "Status", + "publishedAt": "Opublikowano", + "updatedAt": "Zaktualizowano" + } + }, + "category": { + "title": "Kategorie", + "desc": "Grupuj artykuły razem.", + "fields": { + "name": "Nazwa", "color": "Kolor", - "updated_at": "Zaktualizowano" - }, - "delete": { - "title": "Usuń kategorię", - "desc": "Czy na pewno chcesz usunąć kategorię ? Tej akcji nie można cofnąć.", - "confirm": "Tak, usuń tę kategorię", - "success": "Kategoria została pomyślnie usunięta." - }, - "create": { - "title": "Utwórz kategorię", - "desc": "Nowa kategoria dla wpisów na blogu.", - "form": { - "title": { - "label": "Tytuł", - "already_exists": "Kategoria o tym tytule już istnieje." - }, - "color": "Kolor" - }, - "submit": "Utwórz", - "success": "Kategoria została pomyślnie utworzona." + "updatedAt": "Zaktualizowano" + } + } + }, + "admin": { + "article": { + "content": { + "label": "Treść" }, - "edit": { - "title": "Edytuj kategorię", - "submit": "Zapisz zmiany", - "success": "Kategoria została pomyślnie zaktualizowana." + "form": { + "publish": "Publikacja", + "settings": { + "title": "Ustawienia artykułu", + "locale_desc": "Adres i metadane wersji w tym języku." + } } }, - "posts": { - "desc": "Twórz wpisy na blogu i zarządzaj nimi.", - "table": { - "title": "Tytuł", - "category": "Kategoria", - "author": "Autor", - "updated_at": "Zaktualizowano" - }, - "create": { - "title": "Utwórz wpis", - "desc": "Napisz nowy artykuł na swój blog.", - "form": { - "title": { - "label": "Tytuł", - "already_exists": "Wpis o tym tytule już istnieje." - }, - "friendly_url": { - "label": "Przyjazny adres URL", - "desc": "Używany w adresie wpisu. Wypełniany automatycznie na podstawie tytułu.", - "already_exists": "Taki przyjazny adres URL już istnieje." - }, - "content": "Treść", - "category": "Kategoria" - }, - "submit": "Utwórz wpis", - "success": "Wpis został pomyślnie utworzony." - }, - "edit": { - "title": "Edytuj wpis", - "submit": "Zapisz zmiany", - "success": "Wpis został pomyślnie zaktualizowany." - }, - "delete": { - "title": "Usuń wpis", - "desc": "Czy na pewno chcesz usunąć wpis ? Tej akcji nie można cofnąć.", - "confirm": "Tak, usuń ten wpis", - "success": "Wpis został pomyślnie usunięty." + "category": { + "color": { + "label": "Kolor", + "desc": "Wyświetlany obok kategorii na listach.", + "none": "Brak koloru" } } } }, - "@vitnode/blog:posts": "Wpisy", - "@vitnode/blog:posts:can_view": "Wyświetlanie listy wpisów", - "@vitnode/blog:posts:can_create": "Tworzenie wpisów", - "@vitnode/blog:posts:can_edit": "Edytowanie wpisów", - "@vitnode/blog:posts:can_delete": "Usuwanie wpisów", + "@vitnode/blog:posts": "Artykuły", + "@vitnode/blog:posts:can_view": "Wyświetlanie listy artykułów", + "@vitnode/blog:posts:can_create": "Tworzenie artykułów", + "@vitnode/blog:posts:can_edit": "Edytowanie artykułów", + "@vitnode/blog:posts:can_delete": "Usuwanie artykułów", + "@vitnode/blog:posts:can_publish": "Publikowanie i cofanie publikacji artykułów", + "@vitnode/blog:posts:can_restore": "Przywracanie wcześniejszej wersji artykułu", + "@vitnode/blog:posts:can_translate": "Pisanie tłumaczeń artykułów", "@vitnode/blog:categories": "Kategorie", "@vitnode/blog:categories:can_view": "Wyświetlanie listy kategorii", "@vitnode/blog:categories:can_create": "Tworzenie kategorii", "@vitnode/blog:categories:can_edit": "Edytowanie kategorii", - "@vitnode/blog:categories:can_delete": "Usuwanie kategorii" + "@vitnode/blog:categories:can_delete": "Usuwanie kategorii", + "@vitnode/blog:categories:can_translate": "Pisanie tłumaczeń kategorii" } diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 6214bcfc5..e2b13a778 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -32,7 +32,10 @@ "dev": "vitnode dev", "dev:email": "email dev --dir src/emails", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest", + "test:types": "vitest run --typecheck.only" }, "dependencies": { "@hono/zod-openapi": "^1.5.1", @@ -55,11 +58,17 @@ "@react-email/ui": "^6.9.0", "@swc/cli": "^0.8.1", "@swc/core": "^1.15.46", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", "@vitnode/config": "workspace:*", "eslint": "^10.7.0", + "jsdom": "^29.1.1", + "postgres": "^3.4.9", "tsc-alias": "^1.9.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } } diff --git a/plugins/blog/src/api/lib/categories-language.ts b/plugins/blog/src/api/lib/categories-language.ts deleted file mode 100644 index 6187d22f9..000000000 --- a/plugins/blog/src/api/lib/categories-language.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import type { Context } from "hono"; - -import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { and, eq, inArray } from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; - -export const CATEGORY_LANG_TABLE = "blog_categories"; -export const CATEGORY_LANG_VARIABLE = "title"; - -export interface CategoryTranslations { - title: MultiLangValue; -} - -// The category title lives entirely in `core_languages_words` (one row per -// language); nothing text-like remains on `blog_categories`. -export const saveCategoryTranslations = async ( - c: Context, - itemId: number, - { title }: CategoryTranslations, -): Promise => { - await saveLanguageWords(c, { - pluginCode: CONFIG_PLUGIN.pluginId, - tableName: CATEGORY_LANG_TABLE, - variable: CATEGORY_LANG_VARIABLE, - itemId, - values: title, - }); -}; - -export const loadCategoryTranslations = async ( - c: Context, - categoryIds: number[], -): Promise> => { - const result = new Map(); - if (categoryIds.length === 0) { - return result; - } - - const words = await c - .get("db") - .select({ - itemId: core_languages_words.itemId, - languageCode: core_languages_words.languageCode, - value: core_languages_words.value, - }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, CATEGORY_LANG_TABLE), - eq(core_languages_words.variable, CATEGORY_LANG_VARIABLE), - inArray(core_languages_words.itemId, categoryIds), - ), - ); - - for (const id of categoryIds) { - result.set( - id, - words - .filter(word => word.itemId === id) - .map(({ languageCode, value }) => ({ languageCode, value })), - ); - } - - return result; -}; diff --git a/plugins/blog/src/api/lib/events.ts b/plugins/blog/src/api/lib/events.ts index b9932c9e8..441ae1948 100644 --- a/plugins/blog/src/api/lib/events.ts +++ b/plugins/blog/src/api/lib/events.ts @@ -1,12 +1,47 @@ +import type { EnvVitNode } from "@vitnode/core/api/middlewares/global.middleware"; +import type { ContentEventsFor } from "@vitnode/core/content"; +import type { Context } from "hono"; + import { buildEventListener } from "@vitnode/core/api/lib/events"; +import { contentEventName } from "@vitnode/core/content"; +import { eq } from "drizzle-orm"; + +import type { blogCategoryContentType } from "@/content/category"; +import { blogPostContentType } from "@/content/post"; +import { blog_posts } from "@/database/posts"; + +/** + * The blog's own event names, kept as **adapters** over the Content Engine's. + * + * There is one mutation pipeline now - the engine's - and these listeners + * translate its events into the names the blog has always published, so a plugin + * listening for `blog.post.created` keeps working without the blog keeping a + * second way to write a row. + * + * They are a compatibility layer with a shelf life. New listeners should use + * `content.blog.post.*` and `content.blog.category.*`, which carry more: changed + * fields, revision ids, publication transitions, per-locale translation events + * and slug history - none of which the blog's own names ever had. + */ declare module "@vitnode/core/api/models/events" { - interface VitNodeEvents { + interface VitNodeEvents + extends + ContentEventsFor, + ContentEventsFor { "blog.category.created": { categoryId: number; }; "blog.category.deleted": { categoryId: number; + /** + * Always empty. + * + * It always effectively was: the foreign key from `blog_posts` refuses a + * category that still has articles, so a category deletion that succeeds + * is one that had none. The field stays so existing listeners still + * compile. + */ postIds: number[]; }; "blog.category.updated": { @@ -16,8 +51,15 @@ declare module "@vitnode/core/api/models/events" { categoryId: number; postId: number; }; + /** + * No `categoryId`, unlike the other two. + * + * The row is gone by the time this is emitted, so there is nothing left to + * read it from - and inventing one would put a wrong category id into an + * audit trail. A listener that needs it should watch + * `content.blog.post.deleted` and keep its own index. + */ "blog.post.deleted": { - categoryId: number; postId: number; }; "blog.post.updated": { @@ -27,14 +69,106 @@ declare module "@vitnode/core/api/models/events" { } } -export const cleanupCategorySearchListener = buildEventListener({ - event: "blog.category.deleted", - name: "cleanup-category-search", - description: - "Remove search index rows of posts cascade-deleted with a category", +const POST = blogPostContentType.id; +const CATEGORY = "blog.category"; + +/** The category an article is in, read back for the legacy payload. */ +const categoryOf = async ( + c: Context, + postId: number, +): Promise => { + const [post] = await c + .get("db") + .select({ categoryId: blog_posts.categoryId }) + .from(blog_posts) + .where(eq(blog_posts.id, postId)) + .limit(1); + + return post?.categoryId ?? null; +}; + +export const legacyPostCreatedListener = buildEventListener({ + event: contentEventName(POST, "created"), + name: "legacy-blog-post-created", + description: "Re-emits the blog's own blog.post.created event", + handler: async (c, payload) => { + const categoryId = await categoryOf(c, payload.contentId); + if (categoryId === null) return; + + await c.get("events").emit("blog.post.created", { + categoryId, + postId: payload.contentId, + }); + }, +}); + +export const legacyPostUpdatedListener = buildEventListener({ + event: contentEventName(POST, "updated"), + name: "legacy-blog-post-updated", + description: "Re-emits the blog's own blog.post.updated event", + handler: async (c, payload) => { + const categoryId = await categoryOf(c, payload.contentId); + if (categoryId === null) return; + + await c.get("events").emit("blog.post.updated", { + categoryId, + postId: payload.contentId, + }); + }, +}); + +export const legacyPostDeletedListener = buildEventListener({ + event: contentEventName(POST, "deleted"), + name: "legacy-blog-post-deleted", + description: "Re-emits the blog's own blog.post.deleted event", + handler: async (c, payload) => { + await c.get("events").emit("blog.post.deleted", { + postId: payload.contentId, + }); + }, +}); + +export const legacyCategoryCreatedListener = buildEventListener({ + event: contentEventName(CATEGORY, "created"), + name: "legacy-blog-category-created", + description: "Re-emits the blog's own blog.category.created event", handler: async (c, payload) => { - for (const postId of payload.postIds) { - await c.get("search").delete("blog_post", postId); - } + await c.get("events").emit("blog.category.created", { + categoryId: payload.contentId, + }); }, }); + +export const legacyCategoryUpdatedListener = buildEventListener({ + event: contentEventName(CATEGORY, "updated"), + name: "legacy-blog-category-updated", + description: "Re-emits the blog's own blog.category.updated event", + handler: async (c, payload) => { + await c.get("events").emit("blog.category.updated", { + categoryId: payload.contentId, + }); + }, +}); + +export const legacyCategoryDeletedListener = buildEventListener({ + event: contentEventName(CATEGORY, "deleted"), + name: "legacy-blog-category-deleted", + description: "Re-emits the blog's own blog.category.deleted event", + handler: async (c, payload) => { + await c.get("events").emit("blog.category.deleted", { + categoryId: payload.contentId, + // See the payload's own note: a category with articles cannot be deleted, + // so a deletion that happened had nothing to cascade. + postIds: [], + }); + }, +}); + +export const blogLegacyEventListeners = [ + legacyCategoryCreatedListener, + legacyCategoryDeletedListener, + legacyCategoryUpdatedListener, + legacyPostCreatedListener, + legacyPostDeletedListener, + legacyPostUpdatedListener, +]; diff --git a/plugins/blog/src/api/lib/posts-language.ts b/plugins/blog/src/api/lib/posts-language.ts deleted file mode 100644 index a7390e72b..000000000 --- a/plugins/blog/src/api/lib/posts-language.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import type { Context } from "hono"; - -import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words"; -import { - core_languages, - core_languages_words, -} from "@vitnode/core/database/languages"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { removeSpecialCharacters } from "@vitnode/core/lib/special-characters"; -import { and, eq, inArray } from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; - -export const POST_LANG_TABLE = "blog_posts"; -export const POST_LANG_VARIABLES = ["title", "content", "friendlyUrl"] as const; -export type PostLangVariable = (typeof POST_LANG_VARIABLES)[number]; - -export interface PostTranslations { - content: MultiLangValue; - friendlyUrl: MultiLangValue; - title: MultiLangValue; -} - -export const slugifyMultiLang = (values: MultiLangValue): MultiLangValue => - values.map(({ languageCode, value }) => ({ - languageCode, - value: removeSpecialCharacters(value), - })); - -export const getDefaultLanguageCode = async ( - c: Context, -): Promise => { - const [language] = await c - .get("db") - .select({ code: core_languages.code }) - .from(core_languages) - .where(eq(core_languages.default, true)) - .limit(1); - - return language?.code ?? null; -}; - -// Every translated field lives in `core_languages_words`; nothing text-like -// remains on the table. This picks the default-language value (falling back to -// the first available) - used to derive slugs and validate required fields. -export const pickDefaultValue = ( - values: MultiLangValue, - defaultLanguageCode: null | string, -): string => - values.find(item => item.languageCode === defaultLanguageCode)?.value ?? - values[0]?.value ?? - ""; - -// Resolve a translated field for a language: the exact translation, else the -// default-language value, else the first available. Used when rendering/indexing -// now that the flat mirror columns are gone. -export const resolveLangValue = ( - values: MultiLangValue | undefined, - languageCode: string, - defaultLanguageCode: null | string, -): string => - getLangValue(values, languageCode) || - pickDefaultValue(values ?? [], defaultLanguageCode); - -export const savePostTranslations = async ( - c: Context, - itemId: number, - translations: PostTranslations, -): Promise => { - await Promise.all( - POST_LANG_VARIABLES.map(async variable => { - await saveLanguageWords(c, { - pluginCode: CONFIG_PLUGIN.pluginId, - tableName: POST_LANG_TABLE, - variable, - itemId, - values: - variable === "friendlyUrl" - ? slugifyMultiLang(translations.friendlyUrl) - : translations[variable], - }); - }), - ); -}; - -export const loadPostTranslations = async ( - c: Context, - postIds: number[], -): Promise> => { - const result = new Map(); - if (postIds.length === 0) { - return result; - } - - const words = await c - .get("db") - .select({ - itemId: core_languages_words.itemId, - variable: core_languages_words.variable, - languageCode: core_languages_words.languageCode, - value: core_languages_words.value, - }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, POST_LANG_TABLE), - inArray(core_languages_words.variable, [...POST_LANG_VARIABLES]), - inArray(core_languages_words.itemId, postIds), - ), - ); - - for (const id of postIds) { - result.set(id, { - title: words - .filter(word => word.itemId === id && word.variable === "title") - .map(({ languageCode, value }) => ({ languageCode, value })), - content: words - .filter(word => word.itemId === id && word.variable === "content") - .map(({ languageCode, value }) => ({ languageCode, value })), - friendlyUrl: words - .filter(word => word.itemId === id && word.variable === "friendlyUrl") - .map(({ languageCode, value }) => ({ languageCode, value })), - }); - } - - return result; -}; diff --git a/plugins/blog/src/api/lib/search.ts b/plugins/blog/src/api/lib/search.ts deleted file mode 100644 index bf70575e4..000000000 --- a/plugins/blog/src/api/lib/search.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { - SearchDocument, - SearchIndexer, -} from "@vitnode/core/api/models/search"; -import type { Context } from "hono"; - -import { asc, count } from "drizzle-orm"; - -import { blog_posts } from "@/database/posts"; - -import type { PostTranslations } from "./posts-language"; - -import { - getDefaultLanguageCode, - loadPostTranslations, - resolveLangValue, -} from "./posts-language"; - -interface BlogPostForSearch { - authorId: null | number; - categoryId: number; - createdAt: Date; - id: number; - updatedAt?: Date; -} - -const getEnabledLanguageCodes = (c: Context): string[] => - c - .get("core") - .i18n.locales.filter(locale => locale.enabled !== false) - .map(locale => locale.code); - -// One search document per enabled language: each language gets its own -// translation (falling back to the default-language mirror) and its own -// friendly URL, so search and discovery can be scoped to the viewer's locale. -const buildDocumentsForPost = ( - post: BlogPostForSearch, - languageCodes: string[], - translations: PostTranslations | undefined, - defaultLanguageCode: null | string, -): SearchDocument[] => { - const codes = languageCodes.length > 0 ? languageCodes : [""]; - - return codes.map(languageCode => { - const friendlyUrl = resolveLangValue( - translations?.friendlyUrl, - languageCode, - defaultLanguageCode, - ); - - return { - itemType: "blog_post", - itemId: post.id, - languageCode, - authorId: post.authorId, - title: resolveLangValue( - translations?.title, - languageCode, - defaultLanguageCode, - ), - content: resolveLangValue( - translations?.content, - languageCode, - defaultLanguageCode, - ), - containerType: "blog_category", - containerId: post.categoryId, - url: `/blog/${post.categoryId}/${friendlyUrl}`, - isPublic: true, - createdAt: post.createdAt, - updatedAt: post.updatedAt, - }; - }); -}; - -export const reindexBlogPost = async ( - c: Context, - post: BlogPostForSearch, -): Promise => { - const languageCodes = getEnabledLanguageCodes(c); - const [defaultLanguageCode, translations] = await Promise.all([ - getDefaultLanguageCode(c), - loadPostTranslations(c, [post.id]), - ]); - - // Drop every language row first so translations removed since the last index - // don't linger. - await c.get("search").delete("blog_post", post.id); - await c - .get("search") - .bulkIndex( - buildDocumentsForPost( - post, - languageCodes, - translations.get(post.id), - defaultLanguageCode, - ), - ); -}; - -export const blogPostSearchIndexer: SearchIndexer = { - itemType: "blog_post", - count: async c => { - const [row] = await c.get("db").select({ value: count() }).from(blog_posts); - - return row?.value ?? 0; - }, - load: async (c, offset, limit) => { - const rows = await c - .get("db") - .select({ - id: blog_posts.id, - categoryId: blog_posts.categoryId, - authorId: blog_posts.authorId, - createdAt: blog_posts.createdAt, - updatedAt: blog_posts.updatedAt, - }) - .from(blog_posts) - .orderBy(asc(blog_posts.id)) - .limit(limit) - .offset(offset); - - if (rows.length === 0) { - return { documents: [], itemsRead: 0 }; - } - - const languageCodes = getEnabledLanguageCodes(c); - const [defaultLanguageCode, translations] = await Promise.all([ - getDefaultLanguageCode(c), - loadPostTranslations( - c, - rows.map(row => row.id), - ), - ]); - - // One post emits one document per enabled language, so the document count is - // never the source count - `itemsRead` is what the rebuild pages by. - return { - documents: rows.flatMap(post => - buildDocumentsForPost( - post, - languageCodes, - translations.get(post.id), - defaultLanguageCode, - ), - ), - itemsRead: rows.length, - }; - }, -}; diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index 4113aca19..0435b25c0 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -1,17 +1,32 @@ import { buildModule } from "@vitnode/core/api/lib/module"; +import { buildContentAdminModule } from "@vitnode/core/content/server"; -import { CONFIG_PLUGIN } from "../../../const"; -import { cleanupCategorySearchListener } from "../../lib/events"; -import { categoriesAdminModule } from "./categories/categories.admin.module"; -import { postsAdminModule } from "./posts/posts.admin.module"; +import { blogLegacyEventListeners } from "@/api/lib/events"; +import { CONFIG_PLUGIN } from "@/const"; +import { categoryContent } from "@/database/categories"; +import { postContent } from "@/database/posts"; +/** + * Every admin route the blog has, generated from two content types. + * + * The generated content module is nested here rather than mounted by the engine: + * Hono serves only the last sub-app mounted at a prefix, so a second top-level + * `/admin` would silently shadow this one. + * + * Routes land at `/api/@vitnode/blog/admin/content/{posts,categories}`, and the + * staff permissions they check are the modules the blog has always used. + */ export const adminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "admin", - modules: [categoriesAdminModule, postsAdminModule], routes: [], - // Event listeners are only collected from top-level modules (like cronJobs - // and queueTasks), so they are registered here rather than on the nested - // categories module. - events: [cleanupCategorySearchListener], + modules: [ + buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [categoryContent, postContent], + }), + ], + // Event listeners are only collected from top-level modules, so the + // compatibility adapters are registered here rather than on a nested one. + events: blogLegacyEventListeners, }); diff --git a/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts b/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts deleted file mode 100644 index 56c107aa0..000000000 --- a/plugins/blog/src/api/modules/admin/categories/categories.admin.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "../../../../const"; -import { createCategoryRoute } from "./routes/create.route"; -import { deleteCategoryRoute } from "./routes/delete.route"; -import { editCategoryRoute } from "./routes/edit.route"; - -export const categoriesAdminModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "categories", - routes: [createCategoryRoute, editCategoryRoute, deleteCategoryRoute], -}); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts deleted file mode 100644 index 7f452cf0b..000000000 --- a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; - -import { saveCategoryTranslations } from "../../../../lib/categories-language"; - -const zodCategoryResponseSchema = z.object({ - id: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const zodCreateCategorySchema = z.object({ - title: multiLangValueSchema({ minLength: 1, maxLength: 100 }).min(1), - color: z.string().nullish(), -}); - -export const createCategoryRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "categories", permission: "can_create" }, - route: { - method: "post", - path: "/", - request: { - body: { - content: { - "application/json": { - schema: zodCreateCategorySchema, - }, - }, - }, - }, - responses: { - 201: { - content: { - "application/json": { - schema: zodCategoryResponseSchema, - }, - }, - description: "Category created successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - }, - }, - handler: async c => { - const { title, color } = c.req.valid("json"); - const [category] = await c - .get("db") - .insert(blog_categories) - .values({ - color: color?.trim() ? color : null, - }) - .returning(); - - await saveCategoryTranslations(c, category.id, { title }); - - await c.get("events").emit("blog.category.created", { - categoryId: category.id, - }); - - return c.json(category, 201); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts deleted file mode 100644 index 8daa567e5..000000000 --- a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -export const deleteCategoryRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "categories", permission: "can_delete" }, - route: { - method: "delete", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - }, - responses: { - 204: { - description: "Category deleted successfully", - }, - 404: { - description: "Category not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - - // Capture the posts the category's `onDelete: "cascade"` is about to - // remove, so listeners (e.g. search index cleanup) know what went away. - const posts = await c - .get("db") - .select({ id: blog_posts.id }) - .from(blog_posts) - .where(eq(blog_posts.categoryId, id)); - - const result = await c - .get("db") - .delete(blog_categories) - .where(eq(blog_categories.id, id)) - .returning(); - - if (result.length === 0) { - throw new HTTPException(404); - } - - await c.get("events").emit("blog.category.deleted", { - categoryId: id, - postIds: posts.map(post => post.id), - }); - - return c.body(null, 204); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts deleted file mode 100644 index 8b1228269..000000000 --- a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; - -import { saveCategoryTranslations } from "../../../../lib/categories-language"; -import { zodCreateCategorySchema } from "./create.route"; - -const zodCategoryResponseSchema = z.object({ - id: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const editCategoryRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "categories", permission: "can_edit" }, - route: { - method: "put", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - body: { - content: { - "application/json": { - schema: zodCreateCategorySchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: zodCategoryResponseSchema, - }, - }, - description: "Category updated successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - 404: { - description: "Category not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - const { title, color } = c.req.valid("json"); - const [editData] = await c - .get("db") - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, id)) - .limit(1); - - if (!editData) { - throw new HTTPException(404); - } - - const [category] = await c - .get("db") - .update(blog_categories) - .set({ - color: color?.trim() ? color : null, - }) - .where(eq(blog_categories.id, id)) - .returning(); - - await saveCategoryTranslations(c, id, { title }); - - await c.get("events").emit("blog.category.updated", { - categoryId: id, - }); - - return c.json(category); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts b/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts deleted file mode 100644 index c39aa207c..000000000 --- a/plugins/blog/src/api/modules/admin/posts/posts.admin.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "../../../../const"; -import { createPostRoute } from "./routes/create.route"; -import { deletePostRoute } from "./routes/delete.route"; -import { editPostRoute } from "./routes/edit.route"; - -export const postsAdminModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "posts", - routes: [editPostRoute, createPostRoute, deletePostRoute], -}); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts deleted file mode 100644 index c6321ca04..000000000 --- a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; -import { and, eq, inArray } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -import { - POST_LANG_TABLE, - savePostTranslations, - slugifyMultiLang, -} from "../../../../lib/posts-language"; -import { reindexBlogPost } from "../../../../lib/search"; - -const zodPostResponseSchema = z.object({ - id: z.number(), - categoryId: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const zodCreatePostSchema = z.object({ - title: multiLangValueSchema({ minLength: 3, maxLength: 255 }).min(1), - content: multiLangValueSchema(), - friendlyUrl: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1), - categoryId: z.number(), -}); - -export const createPostRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "posts", permission: "can_create" }, - route: { - method: "post", - path: "/", - request: { - body: { - content: { - "application/json": { - schema: zodCreatePostSchema, - }, - }, - }, - }, - responses: { - 201: { - content: { - "application/json": { - schema: zodPostResponseSchema, - }, - }, - description: "Post created successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - 404: { - description: "Category not found", - }, - }, - }, - handler: async c => { - const { title, content, friendlyUrl, categoryId } = c.req.valid("json"); - const slugFriendlyUrl = slugifyMultiLang(friendlyUrl); - const friendlyUrlValues = [ - ...new Set(slugFriendlyUrl.map(item => item.value).filter(Boolean)), - ]; - - if (friendlyUrlValues.length === 0) { - throw new HTTPException(400, { - message: "Friendly URL is required.", - }); - } - - const [category] = await c - .get("db") - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, categoryId)) - .limit(1); - - if (!category) { - throw new HTTPException(404, { - message: "Category not found.", - }); - } - - // The friendly URL is the post's public slug and lives in - // `core_languages_words`; keep it globally unique so two posts can't resolve - // to the same URL. - const [duplicate] = await c - .get("db") - .select({ itemId: core_languages_words.itemId }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, POST_LANG_TABLE), - eq(core_languages_words.variable, "friendlyUrl"), - inArray(core_languages_words.value, friendlyUrlValues), - ), - ) - .limit(1); - - if (duplicate) { - throw new HTTPException(400, { - message: "Post with this title already exists.", - }); - } - - const [post] = await c - .get("db") - .insert(blog_posts) - .values({ - categoryId, - authorId: c.get("admin")?.user.id ?? c.get("user")?.id ?? null, - }) - .returning(); - - await savePostTranslations(c, post.id, { title, content, friendlyUrl }); - await reindexBlogPost(c, post); - - await c.get("events").emit("blog.post.created", { - postId: post.id, - categoryId: post.categoryId, - }); - - return c.json(post, 201); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts deleted file mode 100644 index 6337e7c73..000000000 --- a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { eq } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_posts } from "@/database/posts"; - -export const deletePostRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "posts", permission: "can_delete" }, - route: { - method: "delete", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - }, - responses: { - 204: { - description: "Post deleted successfully", - }, - 404: { - description: "Post not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - - const result = await c - .get("db") - .delete(blog_posts) - .where(eq(blog_posts.id, id)) - .returning(); - - if (result.length === 0) { - throw new HTTPException(404); - } - - await c.get("search").delete("blog_post", id); - - await c.get("events").emit("blog.post.deleted", { - postId: id, - categoryId: result[0].categoryId, - }); - - return c.body(null, 204); - }, -}); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts deleted file mode 100644 index f3d80dc6d..000000000 --- a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { and, eq, inArray, ne } from "drizzle-orm"; -import { HTTPException } from "hono/http-exception"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -import { - POST_LANG_TABLE, - savePostTranslations, - slugifyMultiLang, -} from "../../../../lib/posts-language"; -import { reindexBlogPost } from "../../../../lib/search"; -import { zodCreatePostSchema } from "./create.route"; - -const zodPostResponseSchema = z.object({ - id: z.number(), - categoryId: z.number(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const editPostRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - adminStaffPermission: { module: "posts", permission: "can_edit" }, - route: { - method: "put", - path: "/{id}", - request: { - params: z.object({ - id: z.string().transform(Number), - }), - body: { - content: { - "application/json": { - schema: zodCreatePostSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: zodPostResponseSchema, - }, - }, - description: "Post updated successfully", - }, - 400: { - description: "Bad request - Invalid input data", - }, - 404: { - description: "Post or category not found", - }, - }, - }, - handler: async c => { - const { id } = c.req.valid("param"); - const { title, content, friendlyUrl, categoryId } = c.req.valid("json"); - const slugFriendlyUrl = slugifyMultiLang(friendlyUrl); - const friendlyUrlValues = [ - ...new Set(slugFriendlyUrl.map(item => item.value).filter(Boolean)), - ]; - - if (friendlyUrlValues.length === 0) { - throw new HTTPException(400, { - message: "Friendly URL is required.", - }); - } - - const [existingPost] = await c - .get("db") - .select({ id: blog_posts.id }) - .from(blog_posts) - .where(eq(blog_posts.id, id)) - .limit(1); - - if (!existingPost) { - throw new HTTPException(404, { message: "Post not found." }); - } - - const [category] = await c - .get("db") - .select({ id: blog_categories.id }) - .from(blog_categories) - .where(eq(blog_categories.id, categoryId)) - .limit(1); - - if (!category) { - throw new HTTPException(404, { message: "Category not found." }); - } - - // Keep the friendly URL (the public slug, stored in `core_languages_words`) - // globally unique, excluding this post's own rows. - const [duplicate] = await c - .get("db") - .select({ itemId: core_languages_words.itemId }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, POST_LANG_TABLE), - eq(core_languages_words.variable, "friendlyUrl"), - inArray(core_languages_words.value, friendlyUrlValues), - ne(core_languages_words.itemId, id), - ), - ) - .limit(1); - - if (duplicate) { - throw new HTTPException(400, { - message: "Post with this title already exists.", - }); - } - - const [post] = await c - .get("db") - .update(blog_posts) - .set({ - categoryId, - }) - .where(eq(blog_posts.id, id)) - .returning(); - - await savePostTranslations(c, id, { title, content, friendlyUrl }); - await reindexBlogPost(c, post); - - await c.get("events").emit("blog.post.updated", { - postId: post.id, - categoryId: post.categoryId, - }); - - return c.json(post); - }, -}); diff --git a/plugins/blog/src/api/modules/categories/categories.module.ts b/plugins/blog/src/api/modules/categories/categories.module.ts deleted file mode 100644 index 55b7f5271..000000000 --- a/plugins/blog/src/api/modules/categories/categories.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { categoriesRoute } from "./routes/get.route"; - -export const categoriesModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "categories", - routes: [categoriesRoute], -}); diff --git a/plugins/blog/src/api/modules/categories/routes/get.route.ts b/plugins/blog/src/api/modules/categories/routes/get.route.ts deleted file mode 100644 index 189654ec6..000000000 --- a/plugins/blog/src/api/modules/categories/routes/get.route.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { - withPagination, - zodPaginationPageInfo, - zodPaginationQuery, -} from "@vitnode/core/api/lib/with-pagination"; -import { core_languages_words } from "@vitnode/core/database/languages"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; -import { - and, - eq, - getTableColumns, - ilike, - inArray, - type SQL, -} from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; - -import { - CATEGORY_LANG_TABLE, - CATEGORY_LANG_VARIABLE, - loadCategoryTranslations, -} from "../../../lib/categories-language"; - -const zodMultiLangValue = multiLangValueSchema(); - -export const zodCategorySchema = z.object({ - id: z.number(), - // The title lives in `core_languages_words`; the client resolves this array to - // the active locale (see `getLangValue`). - titleTranslations: zodMultiLangValue, - color: z.string().nullable(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export const categoriesRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - path: "/", - request: { - query: zodPaginationQuery.extend({ - order: z.enum(["asc", "desc"]).optional(), - orderBy: z.enum(["updatedAt"]).optional(), - search: z.string().optional(), - }), - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - edges: z.array(zodCategorySchema), - pageInfo: zodPaginationPageInfo, - }), - }, - }, - description: "Categories retrieved successfully", - }, - }, - }, - handler: async c => { - const query = c.req.valid("query"); - - const data = await withPagination({ - c, - params: { - query, - }, - primaryCursor: blog_categories.id, - query: async ({ cursorSelection, limit, where, orderBy }) => { - // The title lives in `core_languages_words`, so search resolves matching - // category ids from there rather than a column on `blog_categories`. - const searchCondition = query.search - ? inArray( - blog_categories.id, - c - .get("db") - .select({ id: core_languages_words.itemId }) - .from(core_languages_words) - .where( - and( - eq(core_languages_words.pluginCode, CONFIG_PLUGIN.pluginId), - eq(core_languages_words.tableName, CATEGORY_LANG_TABLE), - eq(core_languages_words.variable, CATEGORY_LANG_VARIABLE), - ilike(core_languages_words.value, `%${query.search}%`), - ), - ), - ) - : undefined; - - let combinedWhere: SQL | undefined; - if (searchCondition) { - if (where) { - combinedWhere = and(where, searchCondition); - } else { - combinedWhere = searchCondition; - } - } else { - combinedWhere = where; - } - - return await c - .get("db") - .select({ ...getTableColumns(blog_categories), ...cursorSelection }) - .from(blog_categories) - .where(combinedWhere) - .orderBy(orderBy) - .limit(limit); - }, - table: blog_categories, - orderBy: { - column: query.orderBy - ? blog_categories[query.orderBy] - : blog_categories.updatedAt, - order: query.order ?? "desc", - }, - }); - - const translations = await loadCategoryTranslations( - c, - data.edges.map(edge => edge.id), - ); - - return c.json({ - ...data, - edges: data.edges.map(edge => ({ - ...edge, - titleTranslations: translations.get(edge.id) ?? [], - })), - }); - }, -}); diff --git a/plugins/blog/src/api/modules/posts/posts.module.ts b/plugins/blog/src/api/modules/posts/posts.module.ts deleted file mode 100644 index 13e57ab11..000000000 --- a/plugins/blog/src/api/modules/posts/posts.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { postsRoute } from "./routes/get.route"; - -export const postsModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "posts", - routes: [postsRoute], -}); diff --git a/plugins/blog/src/api/modules/posts/routes/get.route.ts b/plugins/blog/src/api/modules/posts/routes/get.route.ts deleted file mode 100644 index a50564630..000000000 --- a/plugins/blog/src/api/modules/posts/routes/get.route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; -import { - withPagination, - zodPaginationPageInfo, - zodPaginationQuery, -} from "@vitnode/core/api/lib/with-pagination"; -import { core_users } from "@vitnode/core/database/users"; -import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang"; -import { eq } from "drizzle-orm"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blog_categories } from "@/database/categories"; -import { blog_posts } from "@/database/posts"; - -import { loadCategoryTranslations } from "../../../lib/categories-language"; -import { loadPostTranslations } from "../../../lib/posts-language"; - -const zodMultiLangValue = multiLangValueSchema(); - -export const zodPostSchema = z.object({ - id: z.number(), - // Every translated field lives in `core_languages_words`; the client resolves - // these arrays to the active locale (see `getLangValue` / `resolveLangValue`). - titleTranslations: zodMultiLangValue, - contentTranslations: zodMultiLangValue, - friendlyUrlTranslations: zodMultiLangValue, - categoryId: z.number(), - createdAt: z.date(), - updatedAt: z.date(), - category: z.object({ - id: z.number(), - titleTranslations: zodMultiLangValue, - }), - author: z - .object({ - id: z.number(), - name: z.string(), - nameCode: z.string(), - avatarColor: z.string(), - }) - .nullable(), -}); - -export const postsRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - path: "/", - request: { - query: zodPaginationQuery.extend({ - order: z.enum(["asc", "desc"]).optional(), - orderBy: z.enum(["updatedAt", "createdAt"]).optional(), - categoryId: z.string().transform(Number).optional(), - }), - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - edges: z.array(zodPostSchema), - pageInfo: zodPaginationPageInfo, - }), - }, - }, - description: "Posts retrieved successfully", - }, - }, - }, - handler: async c => { - const query = c.req.valid("query"); - - const data = await withPagination({ - c, - params: { - query, - }, - primaryCursor: blog_posts.id, - query: async ({ cursorSelection, limit, where, orderBy }) => - await c - .get("db") - .select({ - ...cursorSelection, - id: blog_posts.id, - categoryId: blog_posts.categoryId, - createdAt: blog_posts.createdAt, - updatedAt: blog_posts.updatedAt, - category: { - id: blog_categories.id, - }, - author: { - id: core_users.id, - name: core_users.name, - nameCode: core_users.nameCode, - avatarColor: core_users.avatarColor, - }, - }) - .from(blog_posts) - .innerJoin( - blog_categories, - eq(blog_posts.categoryId, blog_categories.id), - ) - .leftJoin(core_users, eq(core_users.id, blog_posts.authorId)) - .where( - query.categoryId - ? eq(blog_posts.categoryId, query.categoryId) - : where, - ) - .orderBy(orderBy) - .limit(limit), - table: blog_posts, - orderBy: { - column: query.orderBy - ? blog_posts[query.orderBy] - : blog_posts.updatedAt, - order: query.order ?? "desc", - }, - }); - - const [translations, categoryTranslations] = await Promise.all([ - loadPostTranslations( - c, - data.edges.map(edge => edge.id), - ), - loadCategoryTranslations( - c, - data.edges.map(edge => edge.category.id), - ), - ]); - - return c.json({ - ...data, - edges: data.edges.map(edge => { - const words = translations.get(edge.id); - - return { - ...edge, - titleTranslations: words?.title ?? [], - contentTranslations: words?.content ?? [], - friendlyUrlTranslations: words?.friendlyUrl ?? [], - category: { - ...edge.category, - titleTranslations: categoryTranslations.get(edge.category.id) ?? [], - }, - }; - }), - }); - }, -}); diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index 487f96d41..b8a76c463 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -1,33 +1,36 @@ import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; +import { buildContentPublicModule } from "@vitnode/core/content/server"; +import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; +import { categoryContent } from "@/database/categories"; +import { postContent } from "@/database/posts"; -import { blogPostSearchIndexer } from "./api/lib/search"; -import { adminModule } from "./api/modules/admin/admin.module"; -import { categoriesModule } from "./api/modules/categories/categories.module"; -import { postsModule } from "./api/modules/posts/posts.module"; - -export const blogApiPlugin = () => { - return buildApiPlugin({ +/** + * No `contentTypes` here: `buildApiPlugin` walks the module tree, so the content + * types declared in `admin.module.ts` also drive the registry and the derived + * `can_view` / `can_create` / `can_edit` / `can_delete` / `can_publish` / + * `can_restore` / `can_translate` permissions. + * + * No `searchIndexers` either. The article's `search` block is the indexer now - + * one document per published translation, written by the engine in the same + * transaction as the mutation that caused it. + * + * `permissionStaff` names the same two modules the blog always used, so an + * existing role's stored permissions still address the right thing. Only the + * generated additions - publish, restore, translate - are new, and a role that + * does not have them is denied by default. + */ +export const blogApiPlugin = () => + buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, - modules: [adminModule, categoriesModule, postsModule], - searchIndexers: [blogPostSearchIndexer], - permissionStaff: { - moderator: { - posts: ["can_edit", "can_delete"], - }, - admin: { - posts: [ - "can_view", - { - permission: "can_create", - dependsOn: ["can_view"], - }, - "can_edit", - "can_delete", - ], - categories: ["can_view", "can_create", "can_edit", "can_delete"], - }, - }, + modules: [ + adminModule, + buildContentPublicModule({ + pluginId: CONFIG_PLUGIN.pluginId, + // Skips any content type without `publicApi`, so the category + // contributes nothing - it has no public URL of its own. + contentTypes: [categoryContent, postContent], + }), + ], }); -}; diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts new file mode 100644 index 000000000..a572874f4 --- /dev/null +++ b/plugins/blog/src/config.test-d.ts @@ -0,0 +1,51 @@ +import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; +import { describe, expectTypeOf, it } from "vitest"; + +import { blogCategoryContentType } from "@/content/category"; +import { blogPostContentType } from "@/content/post"; + +/** + * What the frontend registration will and will not accept. + * + * The registration is checked against the definition's own field names, so a + * renamed field is a compile error at the override rather than an input that + * silently stops being overridden. + */ +describe("blog content admin registration", () => { + it("accepts overrides for fields the content type has", () => { + contentTypeAdmin({ + definition: blogCategoryContentType, + fields: { color: { component: () => null } }, + columns: { color: { cell: () => null } }, + }); + + contentTypeAdmin({ + definition: blogPostContentType, + fields: { content: { component: () => null } }, + forms: { layout: () => null }, + }); + }); + + it("refuses an override for a field that does not exist", () => { + contentTypeAdmin({ + definition: blogCategoryContentType, + // @ts-expect-error - the category has no `colour` + fields: { colour: { component: () => null } }, + }); + + contentTypeAdmin({ + definition: blogPostContentType, + // @ts-expect-error - the article's body is `content`, not `body` + fields: { body: { component: () => null } }, + }); + }); + + it("keeps the presentation modes literal", () => { + expectTypeOf(blogPostContentType.admin.create.mode).toEqualTypeOf< + "dialog" | "page" + >(); + expectTypeOf(blogCategoryContentType.admin.edit.mode).toEqualTypeOf< + "dialog" | "page" + >(); + }); +}); diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index 29f09e666..bed13177d 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -1,29 +1,56 @@ -import { buildPlugin } from "@vitnode/core/lib/plugin"; +import { buildPlugin, contentTypeAdmin } from "@vitnode/core/lib/plugin"; import { ListIcon, NotebookPenIcon } from "lucide-react"; import { CONFIG_PLUGIN } from "@/const"; +import { blogCategoryContentType } from "@/content/category"; +import { blogPostContentType } from "@/content/post"; +import { BlogArticleEditorField } from "@/views/admin/article/editor-field"; +import { BlogArticleFormLayout } from "@/views/admin/article/form-layout"; +import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; +import { BlogCategoryColorField } from "@/views/admin/category/color-field"; import messages from "./locales"; +/** + * The blog's entire frontend integration. + * + * Two content types, three component overrides and one layout - and that is the + * AdminCP: the nav items, the breadcrumbs, the list, the create and edit screens + * and the delete confirmation are all generated. No page under + * `src/routes/admin` renders a table any more, and no view calls a mutation. + * + * The overrides are the two escape hatches, one of each kind. `fields` replaces + * an input, `columns` replaces a table cell, and `forms.layout` replaces the + * arrangement of a whole form - never its behaviour. + */ export const blogPlugin = () => { return buildPlugin({ pluginId: CONFIG_PLUGIN.pluginId, messages, - admin: { - nav: [ - { - id: "posts", - href: "/admin/blog/posts", - icon: , - permission: { module: "posts", permission: "can_view" }, + contentTypes: [ + contentTypeAdmin({ + definition: blogPostContentType, + icon: , + fields: { + // The Tiptap editor, inside the same AutoForm as everything else. + content: { component: BlogArticleEditorField }, }, - { - id: "categories", - href: "/admin/blog/categories", - icon: , - permission: { module: "categories", permission: "can_view" }, + forms: { + // One layout for both actions - they are the same screen, and writing + // it twice is how two screens drift apart. + layout: BlogArticleFormLayout, }, - ], - }, + }), + contentTypeAdmin({ + definition: blogCategoryContentType, + icon: , + fields: { + color: { component: BlogCategoryColorField }, + }, + columns: { + color: { cell: BlogCategoryColorCell }, + }, + }), + ], }); }; diff --git a/plugins/blog/src/content/category.ts b/plugins/blog/src/content/category.ts new file mode 100644 index 000000000..6ca1c49b7 --- /dev/null +++ b/plugins/blog/src/content/category.ts @@ -0,0 +1,72 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +/** + * Blog categories, as a Content Engine content type. + * + * The **simple** reference implementation: two fields, generated CRUD, and + * dialog create/edit. It exists to show that a small record needs no page-mode + * editor - and, between them, that `admin.create.mode` really is per content + * type rather than per install. + * + * `tableName` is deliberately the table the plugin has always used. The + * Content Engine's generated schema for this shape *is* `blog_categories` plus a + * translation table, so the migration adds rather than replaces: no ids move, no + * rows are copied between tables, and an install with categories keeps them. + * + * `name` is localized because it always was - the blog stored category titles in + * `core_languages_words`, one row per language - and it moves into + * `blog_categories_translations`, which is where the engine keeps the same idea. + * `color` is shared, because a colour is a property of the category and not of + * the language somebody is reading it in. + */ +export const blogCategoryContentType = defineContentType({ + id: "blog.category", + tableName: "blog_categories", + + localization: { + enabled: true, + // The language every category is first written in. `en` is the locale + // VitNode installs seed, and the boot guard says so loudly if an install + // does not have it rather than failing on the first write. + defaultLocale: "en", + fallback: "default", + }, + + fields: { + // The existing `varchar(50)` on `blog_categories`, unchanged. Rendered by + // the AdminCP's own colour picker through a frontend field override - the + // Content Engine has no `color` kind, and does not need one. + color: field.text({ maxLength: 50, nullable: true }), + name: field.text({ + localized: true, + required: true, + minLength: 1, + maxLength: 100, + }), + }, + + admin: { + label: { plural: "Categories", singular: "Category" }, + // The module the blog's staff permissions have always been stored under, so + // every existing role keeps exactly the access it had. + permissionModule: "categories", + /** + * `null` rather than left out, and the difference matters here. + * + * Every text field on this content type is localized, so there is no shared + * column that could honestly be a title. Left undefined the engine would + * pick the first shared text field - which is `color`, and a toast reading + * "#3260c0 has been deleted" is worse than no name at all. + */ + titleField: null, + // Dialogs, deliberately: a name and a colour do not need a page, and this is + // the half of the blog that proves page mode is opt-in. + create: { mode: "dialog" }, + edit: { mode: "dialog" }, + list: { + // Shared columns only - `name` lives on the translation table, and the + // list's locale selector is what shows it. + columns: ["color", "updatedAt"], + }, + }, +}); diff --git a/plugins/blog/src/content/content-types.test.ts b/plugins/blog/src/content/content-types.test.ts new file mode 100644 index 000000000..6820f3525 --- /dev/null +++ b/plugins/blog/src/content/content-types.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { blogCategoryContentType } from "./category"; +import { blogPostContentType } from "./post"; + +/** + * What the blog's two content types promise, stated as facts rather than as a + * snapshot of the descriptor. + * + * Every assertion here is something an install would notice if it changed: a + * table name, a permission module, a public URL, a presentation mode. They are + * the compatibility contract of the migration. + */ +describe("blog content types", () => { + describe("compatibility with the pre-migration plugin", () => { + it("keeps the table names, so no data has to move", () => { + expect(blogCategoryContentType.tableName).toBe("blog_categories"); + expect(blogPostContentType.tableName).toBe("blog_posts"); + }); + + it("keeps the column names of the relation and the author", () => { + expect(Object.keys(blogPostContentType.fields)).toContain("categoryId"); + expect(Object.keys(blogPostContentType.fields)).toContain("authorId"); + }); + + it("keeps the staff permission modules every role is stored against", () => { + expect(blogCategoryContentType.permissionModule).toBe("categories"); + expect(blogPostContentType.permissionModule).toBe("posts"); + }); + + it("keeps the public URL prefix", () => { + expect(blogPostContentType.publicApi.path).toBe("blog"); + }); + }); + + describe("the category, the simple example", () => { + it("creates and edits in a dialog", () => { + expect(blogCategoryContentType.admin.create.mode).toBe("dialog"); + expect(blogCategoryContentType.admin.edit.mode).toBe("dialog"); + }); + + it("keeps the colour shared and the name per language", () => { + expect(blogCategoryContentType.fields.color.localized).toBe(false); + expect(blogCategoryContentType.fields.name.localized).toBe(true); + }); + + it("has no shared title to guess at", () => { + // Left undefined the engine would pick `color`, and "#3260c0 has been + // deleted" is not a sentence anybody wants to read. + expect(blogCategoryContentType.admin.titleField).toBeNull(); + }); + + it("shows only shared columns in the list", () => { + expect(blogCategoryContentType.admin.list.columns).toEqual([ + "color", + "updatedAt", + ]); + }); + }); + + describe("the article, the rich example", () => { + it("creates and edits on a page", () => { + expect(blogPostContentType.admin.create.mode).toBe("page"); + expect(blogPostContentType.admin.edit.mode).toBe("page"); + }); + + it("relates to the category, and refuses to orphan an article", () => { + const relation = blogPostContentType.fields.categoryId; + + expect(relation.kind).toBe("relation"); + expect(relation.required).toBe(true); + expect(relation).toMatchObject({ onDelete: "restrict" }); + }); + + it("keeps the three translated fields per language", () => { + expect(blogPostContentType.fields.title.localized).toBe(true); + expect(blogPostContentType.fields.friendlyUrl.localized).toBe(true); + expect(blogPostContentType.fields.content.localized).toBe(true); + expect(blogPostContentType.localization.enabled).toBe(true); + }); + + it("derives the friendly URL from the title, per language", () => { + expect(blogPostContentType.fields.friendlyUrl).toMatchObject({ + kind: "slug", + source: "title", + }); + }); + + it("has publication and the editorial workflow", () => { + expect(blogPostContentType.publication.enabled).toBe(true); + expect(blogPostContentType.editorial.enabled).toBe(true); + expect(blogPostContentType.editorial.preview.enabled).toBe(true); + expect(blogPostContentType.editorial.scheduling.enabled).toBe(true); + }); + + it("indexes itself through the engine rather than a plugin indexer", () => { + expect(blogPostContentType.search.enabled).toBe(true); + expect(blogPostContentType.search.titleField).toBe("title"); + expect(blogPostContentType.search.pathTemplate).toBe( + "/{locale}/blog/{slug}", + ); + }); + + it("owns its public URLs through delivery, redirects included", () => { + expect(blogPostContentType.delivery.enabled).toBe(true); + expect(blogPostContentType.delivery.redirects.enabled).toBe(true); + expect(blogPostContentType.delivery.sitemap.enabled).toBe(true); + }); + + it("never exposes the author publicly", () => { + expect(blogPostContentType.publicApi.fields).not.toContain("authorId"); + }); + }); +}); diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts new file mode 100644 index 000000000..8daa61c3d --- /dev/null +++ b/plugins/blog/src/content/post.ts @@ -0,0 +1,176 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { blogCategoryContentType } from "./category"; + +/** + * Blog articles, as a Content Engine content type. + * + * The **rich** reference implementation: page-mode create and edit, a custom + * AdminCP layout, `AutoFormEditor` for the body, a native relation to the + * category, an author, publication, editorial history, search and delivery. + * Everything a real CMS entry needs, and not one line of bespoke CRUD. + * + * The id stays `blog.post` and the table stays `blog_posts`. "Article" is what + * the AdminCP calls it, because that is what people call it - but the content + * type id is part of the event names, the permission keys and the admin URL, and + * renaming a public contract for a nicer noun is churn with no payer. + * + * The field names are the column names the plugin already had: `categoryId` and + * `authorId`, not `category` and `author`. The engine names a column after its + * field, so keeping the field names keeps the columns, the foreign keys and + * their constraint names exactly where they are. + */ +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + + localization: { + enabled: true, + defaultLocale: "en", + // A locale with no translation of its own is served the default language's, + // which is what the plugin's own `resolveLangValue` did by hand. + fallback: "default", + }, + + /** + * Draft and published, which the blog did not have and now does. + * + * Every article that exists today is publicly readable - the old public route + * returned every row and the search index marked every document + * `isPublic: true` - so the migration backfills them all as published, with + * `publishedAt` set to `createdAt`. That is the one publication fact the old + * schema can actually prove; nothing else about their history is invented. + */ + publication: { enabled: true }, + + /** + * Versions, revisions, preview links and scheduling. + * + * `editorial` is also what `delivery.redirects` is gated on: slug history has + * to be written in the same transaction as the slug change, and only the + * editorial mutation paths own such a transaction. An article's URL is the + * thing most worth not breaking, so both are on. + */ + editorial: { + enabled: true, + revisions: { retention: 20 }, + preview: { enabled: true, expiresInMinutes: 30 }, + scheduling: { enabled: true }, + }, + + fields: { + // Shared: which category an article is in, and who wrote it, are properties + // of the article rather than of a language. + categoryId: field.relation({ + required: true, + // Postgres itself refuses to delete a category that still has articles, + // which is what the plugin's own delete route was trying to be careful + // about with a `SELECT` first. + onDelete: "restrict", + target: () => blogCategoryContentType, + }), + authorId: field.user(), + + // Localized: exactly the three variables the plugin kept in + // `core_languages_words`. + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 255, + }), + // Derived from the localized title, per language - which is what + // `TitleField` did in the browser, except the engine also keeps it unique + // per language and remembers the addresses it has retired. + friendlyUrl: field.slug({ + localized: true, + maxLength: 255, + source: "title", + }), + content: field.textarea({ localized: true, required: true }), + }, + + /** + * The public read layer. + * + * `path: "blog"` keeps the public URL shape the plugin already published under + * - `/blog/...` - now as a canonical delivery address the engine owns. + * + * `authorId` is **not** exposed, and cannot be: a `user` field is not one of + * the publicly exposable kinds, because publishing a staff account's display + * name is a decision the core users table gets to make rather than a side + * effect of an article having an author. + */ + publicApi: { + enabled: true, + path: "blog", + fields: [ + // Delivery resolves localized alternates by identifier, so a localized + // delivery content type that withheld `id` would carry an empty alternate + // set. + "id", + "title", + "friendlyUrl", + "content", + "categoryId", + "publishedAt", + ], + searchableFields: ["title", "content"], + // Shared columns only: a list ordered by a localized title would reshuffle + // itself per language, and a cursor would mean two positions at once. + orderableFields: ["publishedAt"], + filterableFields: ["categoryId", "friendlyUrl"], + defaultOrderBy: "publishedAt", + defaultOrder: "desc", + }, + + /** + * One search document per published translation. + * + * Replaces `api/lib/search.ts` entirely. That file emitted one document per + * *enabled language* whether or not a translation existed, falling back to the + * default language's copy - so a Polish search could return an English article + * at a Polish URL. The engine indexes translations that actually exist, which + * is both less code and a better answer. + */ + search: { + enabled: true, + titleField: "title", + contentFields: ["title", "content"], + pathTemplate: "/{locale}/blog/{slug}", + }, + + /** + * Canonical URLs, slug history, redirects, SEO and the sitemap. + * + * `redirects` is the reason the old friendly-URL uniqueness check is gone: + * the engine reserves every address an article has ever been published at, so + * renaming one 308s the old URL instead of leaving it dead - and a second + * article cannot quietly claim an address the first one still redirects from. + */ + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { titleField: "title", descriptionField: "content" }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + hreflang: { xDefault: "defaultLocale" }, + }, + + indexes: [{ on: ["status", "createdAt"] }], + + admin: { + // "Article" in the AdminCP, `blog.post` in the database and the API. + label: { plural: "Articles", singular: "Article" }, + permissionModule: "posts", + // Same reason as the category: every text field here is localized, so there + // is no shared column that could honestly be the title. + titleField: null, + // The page-mode reference. Both actions, so a create hands straight over to + // the article's own edit page. + create: { mode: "page" }, + edit: { mode: "page" }, + list: { + columns: ["status", "categoryId", "authorId", "publishedAt", "updatedAt"], + }, + }, +}); diff --git a/plugins/blog/src/database/categories.ts b/plugins/blog/src/database/categories.ts index 00a900dbd..3cdc05b46 100644 --- a/plugins/blog/src/database/categories.ts +++ b/plugins/blog/src/database/categories.ts @@ -1,11 +1,11 @@ -import { pgTable } from "drizzle-orm/pg-core"; +import { createContentModel } from "@vitnode/core/content/server"; -export const blog_categories = pgTable("blog_categories", t => ({ - id: t.serial().primaryKey(), - color: t.varchar({ length: 50 }), - createdAt: t.timestamp().notNull().defaultNow(), - updatedAt: t - .timestamp() - .notNull() - .$onUpdate(() => new Date()), -})).enableRLS(); +import { blogCategoryContentType } from "@/content/category"; + +export const categoryContent = createContentModel(blogCategoryContentType); + +// Two exports for a localized content type, not one. Drizzle Kit discovers each +// table from the export when it globs the built `dist/src/database/*.js`, so the +// translation table needs its own or the migration would be generated without it. +export const blog_categories = categoryContent.table; +export const blog_categories_translations = categoryContent.translationTable; diff --git a/plugins/blog/src/database/harness.ts b/plugins/blog/src/database/harness.ts new file mode 100644 index 000000000..14c519c08 --- /dev/null +++ b/plugins/blog/src/database/harness.ts @@ -0,0 +1,312 @@ +import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { Context } from "hono"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import postgres from "postgres"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { categoryContent } from "./categories"; +import { postContent } from "./posts"; + +/** + * A real Postgres fixture for the blog's migration onto the Content Engine. + * + * It starts from the schema an **existing install** actually has - the blog's + * own two tables, with its text in `core_languages_words` - and then runs the + * committed migration over it. That is the only way to test the thing that + * matters here: not that a fresh install gets the right tables, but that an + * install with articles in it still has them afterwards. + */ + +export const DATABASE_TEST_URL = process.env.DATABASE_TEST_URL; + +const databaseName = (() => { + if (!DATABASE_TEST_URL) return ""; + try { + return new URL(DATABASE_TEST_URL).pathname.replace(/^\//, ""); + } catch { + return ""; + } +})(); + +const here = dirname(fileURLToPath(import.meta.url)); + +/** The migration under test, read from the app that ships it. */ +export const readMigration = (file: string): string => + readFileSync(resolve(here, "../../../../apps/docs/migrations", file), "utf8"); + +export const BLOG_MIGRATION = "0035_migrate_blog_to_content_engine.sql"; + +/** + * The core tables the blog and the engine touch, stubbed to the columns they + * use. + * + * Core's own migration history is not replayed: one of its migrations builds a + * full-text column from per-language text-search configurations a stock Postgres + * image does not ship, and none of that has anything to do with the blog. + */ +const CORE_STUBS = ` + CREATE TABLE "core_users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL + ); + CREATE TABLE "core_languages" ( + "id" serial PRIMARY KEY NOT NULL, + "code" varchar(32) NOT NULL, + "name" varchar(255) NOT NULL, + "default" boolean DEFAULT false NOT NULL, + "protected" boolean DEFAULT false NOT NULL, + CONSTRAINT "core_languages_code_unique" UNIQUE("code") + ); + CREATE TABLE "core_languages_words" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginCode" varchar(255) NOT NULL, + "tableName" varchar(255) NOT NULL, + "variable" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "languageCode" varchar(32) NOT NULL, + "value" text NOT NULL + ); + 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 + ); + CREATE TABLE "core_content_revisions" ( + "id" serial PRIMARY KEY NOT NULL, + "contentTypeId" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "languageId" integer, + "version" integer NOT NULL, + "operation" varchar(32) NOT NULL, + "snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "changedFields" jsonb DEFAULT '[]'::jsonb NOT NULL, + "actorType" varchar(32) NOT NULL, + "actorUserId" integer, + "createdAt" timestamp DEFAULT now() NOT NULL + ); + CREATE TABLE "core_content_slug_history" ( + "id" serial PRIMARY KEY NOT NULL, + "contentTypeId" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "languageId" integer, + "path" varchar(512) NOT NULL, + "slug" varchar(255) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_content_slug_history_path_key" UNIQUE("path") + ); + CREATE TABLE "core_content_schedules" ( + "id" serial PRIMARY KEY NOT NULL, + "contentTypeId" varchar(255) NOT NULL, + "itemId" integer NOT NULL, + "action" varchar(32) NOT NULL, + "scheduledFor" timestamp NOT NULL, + "status" varchar(32) DEFAULT 'pending' NOT NULL, + "actorUserId" integer, + "queueId" integer, + "lastError" text, + "effectsError" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp + ); + CREATE TABLE "core_search_index" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "itemType" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "languageCode" varchar(32) DEFAULT '' NOT NULL, + "authorId" integer, + "title" text NOT NULL, + "content" text NOT NULL, + "containerType" varchar(100), + "containerId" integer, + "url" text, + "isPublic" boolean DEFAULT true NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "createdAt" timestamp NOT NULL, + "updatedAt" timestamp, + "indexedAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_search_index_item_key" + UNIQUE("itemType", "itemId", "languageCode") + ); +`; + +/** + * The blog exactly as it shipped before this migration. + * + * Copied from `plugins/blog/src/database/{categories,posts}.ts` as they were: + * two tables with no text on them at all, because every translated value lived + * in `core_languages_words`. This is what an install upgrading today looks like. + */ +export const LEGACY_BLOG_SCHEMA = ` + CREATE TABLE "blog_categories" ( + "id" serial PRIMARY KEY NOT NULL, + "color" varchar(50), + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp NOT NULL + ); + CREATE TABLE "blog_posts" ( + "id" serial PRIMARY KEY NOT NULL, + "categoryId" integer NOT NULL, + "authorId" integer, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp NOT NULL, + CONSTRAINT "blog_posts_categoryId_blog_categories_id_fk" + FOREIGN KEY ("categoryId") REFERENCES "blog_categories"("id"), + CONSTRAINT "blog_posts_authorId_core_users_id_fk" + FOREIGN KEY ("authorId") REFERENCES "core_users"("id") + ON DELETE SET NULL ON UPDATE CASCADE + ); +`; + +export const ACTOR = { type: "staff" as const, userId: null }; + +export interface RecordedEvent { + name: string; + payload: unknown; +} + +export interface BlogTestHarness { + context: Context; + db: ReturnType; + deleted: { itemId: number; itemType: string; locale?: string }[]; + emitted: RecordedEvent[]; + end: () => Promise; + indexed: SearchDocument[]; + /** Runs a script one statement per `--> statement-breakpoint`. */ + migrate: (script: string) => Promise; + reset: () => void; + sql: ReturnType; +} + +export const createBlogTestHarness = async (): Promise => { + if (!DATABASE_TEST_URL) throw new Error("DATABASE_TEST_URL is not set."); + if (!/test/i.test(databaseName)) { + throw new Error( + `DATABASE_TEST_URL points at "${databaseName || DATABASE_TEST_URL}". This suite wipes the database it runs against, so its name must contain "test".`, + ); + } + + const sql = postgres(DATABASE_TEST_URL, { + max: 1, + onnotice: () => undefined, + }); + + await sql.unsafe(` + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + `); + await sql.unsafe(CORE_STUBS); + await sql` + INSERT INTO "core_languages" ("code", "name", "default") VALUES + ('en', 'English', true), + ('pl', 'Polski', false) + `; + + const migrate = async (script: string): Promise => { + for (const statement of script.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + }; + + const db = drizzle(sql, { casing: "camelCase" }); + const indexed: SearchDocument[] = []; + const deleted: BlogTestHarness["deleted"] = []; + const emitted: RecordedEvent[] = []; + + const context = { + get: (key: string) => { + if (key === "db") return db; + if (key === "search") { + return { + countDocuments: async () => await Promise.resolve(0), + isCanonicalStorage: () => true, + name: () => "postgres", + delete: async (itemType: string, itemId: number, locale?: string) => { + deleted.push({ itemId, itemType, locale }); + + return await Promise.resolve(); + }, + index: async (document: SearchDocument) => { + indexed.push(document); + + return await Promise.resolve(); + }, + }; + } + if (key === "events") { + return { + emit: async (name: string, payload: unknown) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ + delivered: 1, + eventId: `event-${emitted.length}`, + failures: [], + status: "delivered" as const, + }); + }, + }; + } + if (key === "log") { + return { error: async () => await Promise.resolve() }; + } + if (key === "core") { + return { + contentRevalidateOrigins: [], + cronSecret: "blog-test-secret", + hasCronAdapter: false, + contentModels: [ + { model: categoryContent, pluginId: CONFIG_PLUGIN.pluginId }, + { model: postContent, pluginId: CONFIG_PLUGIN.pluginId }, + ], + i18n: { + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + }, + searchIndexers: [], + }; + } + + return undefined; + }, + } as unknown as Context; + + return { + context, + db, + deleted, + emitted, + end: async () => { + await sql.end(); + }, + indexed, + migrate, + reset: () => { + indexed.length = 0; + deleted.length = 0; + emitted.length = 0; + }, + sql, + }; +}; diff --git a/plugins/blog/src/database/index.ts b/plugins/blog/src/database/index.ts index 4c9d269a8..79630b5aa 100644 --- a/plugins/blog/src/database/index.ts +++ b/plugins/blog/src/database/index.ts @@ -1,6 +1,3 @@ // Tables export * from "./categories"; export * from "./posts"; - -// Relations -export * from "./relations"; diff --git a/plugins/blog/src/database/migration-postgres.test.ts b/plugins/blog/src/database/migration-postgres.test.ts new file mode 100644 index 000000000..296e6fdc8 --- /dev/null +++ b/plugins/blog/src/database/migration-postgres.test.ts @@ -0,0 +1,368 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { BlogTestHarness } from "./harness"; + +import { categoryContent } from "./categories"; +import { + BLOG_MIGRATION, + createBlogTestHarness, + DATABASE_TEST_URL, + LEGACY_BLOG_SCHEMA, + readMigration, +} from "./harness"; +import { postContent } from "./posts"; + +/** + * The blog's move onto the Content Engine, run over a database that already has + * a blog in it. + * + * The seed below is a pre-migration install: two categories with different + * colours, three articles across them, an author, rich bodies, existing slugs + * and Polish translations of one article - all stored the way the plugin used to + * store them, in `core_languages_words`. Everything after `migrate` reads the + * same data back through the **Content Engine's** services, which is the only + * proof that matters: the rows did not merely survive, they arrived somewhere + * the new code can actually see. + */ + +let h: BlogTestHarness; + +const seed = async (): Promise<{ + authorId: number; + categoryIds: number[]; + postIds: number[]; +}> => { + const [author] = await h.sql<{ id: number }[]>` + INSERT INTO "core_users" ("name") VALUES ('Ada Lovelace') RETURNING "id" + `; + + const categories = await h.sql<{ id: number }[]>` + INSERT INTO "blog_categories" ("color", "createdAt", "updatedAt") VALUES + ('#3260c0', '2024-01-01 10:00:00', '2024-01-02 10:00:00'), + (NULL, '2024-01-03 10:00:00', '2024-01-04 10:00:00') + RETURNING "id" + `; + + const posts = await h.sql<{ id: number }[]>` + INSERT INTO "blog_posts" ("categoryId", "authorId", "createdAt", "updatedAt") + VALUES + (${categories[0].id}, ${author.id}, '2024-02-01 09:00:00', '2024-02-05 09:00:00'), + (${categories[0].id}, NULL, '2024-02-02 09:00:00', '2024-02-06 09:00:00'), + (${categories[1].id}, ${author.id}, '2024-02-03 09:00:00', '2024-02-07 09:00:00') + RETURNING "id" + `; + + const word = ( + tableName: string, + variable: string, + itemId: number, + languageCode: string, + value: string, + ) => ({ + itemId, + languageCode, + pluginCode: "@vitnode/blog", + tableName, + value, + variable, + }); + + await h.sql` + INSERT INTO "core_languages_words" + ${h.sql([ + word("blog_categories", "title", categories[0].id, "en", "Engineering"), + word("blog_categories", "title", categories[0].id, "pl", "Inżynieria"), + word("blog_categories", "title", categories[1].id, "en", "Culture"), + + word("blog_posts", "title", posts[0].id, "en", "Hello world"), + word( + "blog_posts", + "content", + posts[0].id, + "en", + "

    The first article.

    ", + ), + word("blog_posts", "friendlyUrl", posts[0].id, "en", "hello-world"), + word("blog_posts", "title", posts[0].id, "pl", "Witaj świecie"), + word( + "blog_posts", + "content", + posts[0].id, + "pl", + "

    Pierwszy artykuł.

    ", + ), + word("blog_posts", "friendlyUrl", posts[0].id, "pl", "witaj-swiecie"), + + word("blog_posts", "title", posts[1].id, "en", "Second article"), + word("blog_posts", "content", posts[1].id, "en", "

    Body two.

    "), + word("blog_posts", "friendlyUrl", posts[1].id, "en", "second-article"), + + // Only Polish, and no English at all - the record the default-locale + // backfill has to rescue rather than leave without a translation. + word("blog_posts", "title", posts[2].id, "pl", "Tylko po polsku"), + word("blog_posts", "content", posts[2].id, "pl", "

    Trzeci.

    "), + word("blog_posts", "friendlyUrl", posts[2].id, "pl", "tylko-po-polsku"), + ])} + `; + + return { + authorId: author.id, + categoryIds: categories.map(row => row.id), + postIds: posts.map(row => row.id), + }; +}; + +describe.skipIf(!DATABASE_TEST_URL)("blog -> Content Engine migration", () => { + let seeded: Awaited>; + + beforeAll(async () => { + h = await createBlogTestHarness(); + await h.migrate(LEGACY_BLOG_SCHEMA); + seeded = await seed(); + await h.migrate(readMigration(BLOG_MIGRATION)); + }, 60_000); + + afterAll(async () => { + await h.end(); + }); + + describe("the records themselves", () => { + it("keeps every category, its id and its colour", async () => { + const rows = await h.sql<{ color: null | string; id: number }[]>` + SELECT "id", "color" FROM "blog_categories" ORDER BY "id" + `; + + expect(rows).toEqual([ + { color: "#3260c0", id: seeded.categoryIds[0] }, + { color: null, id: seeded.categoryIds[1] }, + ]); + }); + + it("keeps every article, its id, its category and its author", async () => { + const rows = await h.sql< + { authorId: null | number; categoryId: number; id: number }[] + >` + SELECT "id", "categoryId", "authorId" FROM "blog_posts" ORDER BY "id" + `; + + expect(rows).toEqual([ + { + authorId: seeded.authorId, + categoryId: seeded.categoryIds[0], + id: seeded.postIds[0], + }, + { + authorId: null, + categoryId: seeded.categoryIds[0], + id: seeded.postIds[1], + }, + { + authorId: seeded.authorId, + categoryId: seeded.categoryIds[1], + id: seeded.postIds[2], + }, + ]); + }); + + it("keeps the timestamps rather than stamping the migration's own", async () => { + const [row] = await h.sql<{ createdAt: string; updatedAt: string }[]>` + SELECT "createdAt", "updatedAt" FROM "blog_posts" + WHERE "id" = ${seeded.postIds[0]} + `; + + expect(row.createdAt).toContain("2024-02-01"); + expect(row.updatedAt).toContain("2024-02-05"); + }); + }); + + describe("publication", () => { + it("publishes every article that was publicly readable before", async () => { + const rows = await h.sql< + { publishedAt: null | string; status: string }[] + >`SELECT "status", "publishedAt" FROM "blog_posts" ORDER BY "id"`; + + expect(rows.map(row => row.status)).toEqual([ + "published", + "published", + "published", + ]); + expect(rows.every(row => row.publishedAt !== null)).toBe(true); + }); + + it("dates the publication from the record rather than from the upgrade", async () => { + const [row] = await h.sql<{ publishedAt: string }[]>` + SELECT "publishedAt" FROM "blog_posts" WHERE "id" = ${seeded.postIds[0]} + `; + + expect(row.publishedAt).toContain("2024-02-01"); + }); + + it("starts every record at version 1, inventing no history", async () => { + const [{ versions }] = await h.sql<{ versions: number[] }[]>` + SELECT array_agg(DISTINCT "version") AS versions FROM "blog_posts" + `; + const [{ count }] = await h.sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_revisions" + `; + + expect(versions).toEqual([1]); + expect(count).toBe(0); + }); + }); + + describe("translations", () => { + it("moves every language of an article into the translation table", async () => { + const rows = await h.sql< + { + content: string; + friendlyUrl: string; + locale: string; + title: string; + }[] + >` + SELECT l."code" AS locale, t."title", t."friendlyUrl", t."content" + FROM "blog_posts_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.postIds[0]} + ORDER BY l."code" + `; + + expect(rows).toEqual([ + { + content: "

    The first article.

    ", + friendlyUrl: "hello-world", + locale: "en", + title: "Hello world", + }, + { + content: "

    Pierwszy artykuł.

    ", + friendlyUrl: "witaj-swiecie", + locale: "pl", + title: "Witaj świecie", + }, + ]); + }); + + it("does not invent a translation for a language nobody wrote", async () => { + const rows = await h.sql<{ locale: string }[]>` + SELECT l."code" AS locale + FROM "blog_categories_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.categoryIds[1]} + `; + + // Only the English title existed, and the default-locale backfill has + // nothing to add on top of it. + expect(rows).toEqual([{ locale: "en" }]); + }); + + it("gives a record with no default-locale translation one built from what it has", async () => { + const rows = await h.sql<{ locale: string; title: string }[]>` + SELECT l."code" AS locale, t."title" + FROM "blog_posts_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.postIds[2]} + ORDER BY l."code" + `; + + expect(rows).toEqual([ + { locale: "en", title: "Tylko po polsku" }, + { locale: "pl", title: "Tylko po polsku" }, + ]); + }); + + it("keeps the category names, in every language they had", async () => { + const rows = await h.sql<{ locale: string; name: string }[]>` + SELECT l."code" AS locale, t."name" + FROM "blog_categories_translations" t + JOIN "core_languages" l ON l."id" = t."languageId" + WHERE t."itemId" = ${seeded.categoryIds[0]} + ORDER BY l."code" + `; + + expect(rows).toEqual([ + { locale: "en", name: "Engineering" }, + { locale: "pl", name: "Inżynieria" }, + ]); + }); + + it("empties the storage it migrated out of", async () => { + const [{ count }] = await h.sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_languages_words" + WHERE "pluginCode" = '@vitnode/blog' + `; + + expect(count).toBe(0); + }); + }); + + describe("read back through the Content Engine", () => { + it("lists the articles the AdminCP list would show", async () => { + const { edges, pageInfo } = await postContent + .service(h.context) + .findMany(); + + expect(pageInfo.totalCount).toBe(3); + expect(edges.map(edge => edge.id).sort((a, b) => a - b)).toEqual( + seeded.postIds, + ); + }); + + it("reads one article the way the edit page does", async () => { + const row = await postContent + .service(h.context) + .findRowById(seeded.postIds[0]); + + expect(row?.categoryId).toBe(seeded.categoryIds[0]); + expect(row?.authorId).toBe(seeded.authorId); + expect(row?.status).toBe("published"); + }); + + it("reads a translation the way the locale tab does", async () => { + const translation = await postContent + .translationService?.(h.context) + .findByLocale(seeded.postIds[0], "pl"); + + expect(translation?.locale).toBe("pl"); + expect(translation?.status).toBe("published"); + expect(translation?.values).toEqual({ + content: "

    Pierwszy artykuł.

    ", + friendlyUrl: "witaj-swiecie", + title: "Witaj świecie", + }); + }); + + it("keeps the category relation usable as a relation", async () => { + const rows = await h.sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "blog_posts" + WHERE "categoryId" = ${seeded.categoryIds[0]} + `; + + expect(rows[0].count).toBe(2); + }); + + it("refuses to delete a category that still has articles", async () => { + let code: string | undefined; + try { + await h.sql` + DELETE FROM "blog_categories" WHERE "id" = ${seeded.categoryIds[0]} + `; + } catch (error) { + const cause = (error as { cause?: { code?: string } }).cause; + code = cause?.code ?? (error as { code?: string }).code; + } + + expect(code).toBe("23503"); + }); + + it("edits a migrated article through the engine and nothing else", async () => { + const service = categoryContent.service(h.context); + const updated = await service.update(seeded.categoryIds[0], { + color: "#112233", + }); + + expect(updated?.changedFields).toEqual(["color"]); + expect(updated?.row.color).toBe("#112233"); + }); + }); +}); diff --git a/plugins/blog/src/database/posts.ts b/plugins/blog/src/database/posts.ts index cf7c7e0d5..78a314fea 100644 --- a/plugins/blog/src/database/posts.ts +++ b/plugins/blog/src/database/posts.ts @@ -1,21 +1,14 @@ -import { core_users } from "@vitnode/core/database/users"; -import { pgTable } from "drizzle-orm/pg-core"; +import { createContentModel } from "@vitnode/core/content/server"; + +import { blogPostContentType } from "@/content/post"; import { blog_categories } from "./categories"; -export const blog_posts = pgTable("blog_posts", t => ({ - id: t.serial().primaryKey(), - categoryId: t - .integer() - .references(() => blog_categories.id) - .notNull(), - authorId: t.integer().references(() => core_users.id, { - onDelete: "set null", - onUpdate: "cascade", - }), - createdAt: t.timestamp().notNull().defaultNow(), - updatedAt: t - .timestamp() - .notNull() - .$onUpdate(() => new Date()), -})).enableRLS(); +export const postContent = createContentModel(blogPostContentType, { + // One thunk per `relation` field - a missing or extra key is a compile error, + // and the thunk keeps circular content type references safe. + references: { categoryId: () => blog_categories.id }, +}); + +export const blog_posts = postContent.table; +export const blog_posts_translations = postContent.translationTable; diff --git a/plugins/blog/src/database/relations.ts b/plugins/blog/src/database/relations.ts deleted file mode 100644 index 0530da3a9..000000000 --- a/plugins/blog/src/database/relations.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { relations } from "drizzle-orm"; - -import { blog_categories } from "./categories"; -import { blog_posts } from "./posts"; - -export const blog_posts_relations = relations(blog_posts, ({ one }) => ({ - category: one(blog_categories, { - fields: [blog_posts.categoryId], - references: [blog_categories.id], - }), -})); - -export const blog_categories_relations = relations( - blog_categories, - ({ many }) => ({ - posts: many(blog_posts), - }), -); diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 108930014..fac507f8a 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -1,92 +1,65 @@ { "@vitnode/blog": { "title": "Blog", - "admin": { - "nav": { - "posts": "Posts", - "categories": "Categories" - }, - "categories": { - "desc": "Manage categories for blog posts.", - "table": { + "content": { + "post": { + "title": "Articles", + "desc": "Write and manage your blog articles.", + "fields": { "title": "Title", + "friendlyUrl": "Friendly URL", + "content": "Content", + "categoryId": "Category", + "authorId": "Author", + "status": "Status", + "publishedAt": "Published", + "updatedAt": "Updated" + } + }, + "category": { + "title": "Categories", + "desc": "Group articles together.", + "fields": { + "name": "Name", "color": "Color", - "updated_at": "Updated At" - }, - "delete": { - "title": "Delete Category", - "desc": "Are you sure you want to delete category? This action cannot be undone.", - "confirm": "Yes, delete this category", - "success": "Category has been deleted successfully." - }, - "create": { - "title": "Create Category", - "desc": "A new category for your blog posts.", - "form": { - "title": { - "label": "Title", - "already_exists": "This category title already exists." - }, - "color": "Color" - }, - "submit": "Create", - "success": "Category has been created successfully." + "updatedAt": "Updated" + } + } + }, + "admin": { + "article": { + "content": { + "label": "Content" }, - "edit": { - "title": "Edit Category", - "submit": "Save Changes", - "success": "Category has been updated successfully." + "form": { + "publish": "Publish", + "settings": { + "title": "Article settings", + "locale_desc": "The address and metadata of this language's version." + } } }, - "posts": { - "desc": "Write and manage your blog posts.", - "table": { - "title": "Title", - "category": "Category", - "author": "Author", - "updated_at": "Updated At" - }, - "create": { - "title": "Create Post", - "desc": "Write a new article for your blog.", - "form": { - "title": { - "label": "Title", - "already_exists": "This post title already exists." - }, - "friendly_url": { - "label": "Friendly URL", - "desc": "Used in the post address. Auto-filled from the title.", - "already_exists": "This friendly URL already exists." - }, - "content": "Content", - "category": "Category" - }, - "submit": "Create Post", - "success": "Post has been created successfully." - }, - "edit": { - "title": "Edit Post", - "submit": "Save Changes", - "success": "Post has been updated successfully." - }, - "delete": { - "title": "Delete Post", - "desc": "Are you sure you want to delete post? This action cannot be undone.", - "confirm": "Yes, delete this post", - "success": "Post has been deleted successfully." + "category": { + "color": { + "label": "Color", + "desc": "Shown next to the category in lists.", + "none": "No color" } } } }, - "@vitnode/blog:posts": "Posts", - "@vitnode/blog:posts:can_view": "View posts list", - "@vitnode/blog:posts:can_create": "Create posts", - "@vitnode/blog:posts:can_edit": "Edit posts", - "@vitnode/blog:posts:can_delete": "Delete posts", + "@vitnode/blog:posts": "Articles", + "@vitnode/blog:posts:can_view": "View articles list", + "@vitnode/blog:posts:can_create": "Create articles", + "@vitnode/blog:posts:can_edit": "Edit articles", + "@vitnode/blog:posts:can_delete": "Delete articles", + "@vitnode/blog:posts:can_publish": "Publish and unpublish articles", + "@vitnode/blog:posts:can_restore": "Restore an earlier version of an article", + "@vitnode/blog:posts:can_translate": "Write article translations", "@vitnode/blog:categories": "Categories", "@vitnode/blog:categories:can_view": "View categories list", "@vitnode/blog:categories:can_create": "Create categories", "@vitnode/blog:categories:can_edit": "Edit categories", - "@vitnode/blog:categories:can_delete": "Delete categories" + "@vitnode/blog:categories:can_delete": "Delete categories", + "@vitnode/blog:categories:can_translate": "Write category translations" } diff --git a/plugins/blog/src/routes/admin/blog/categories/page.tsx b/plugins/blog/src/routes/admin/blog/categories/page.tsx index 36aa27716..a0786d25e 100644 --- a/plugins/blog/src/routes/admin/blog/categories/page.tsx +++ b/plugins/blog/src/routes/admin/blog/categories/page.tsx @@ -1,64 +1,9 @@ -import type { Metadata } from "next"; +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; +import { blogCategoryContentType } from "@/content/category"; -import { CONFIG_PLUGIN } from "@/const"; -import { ActionsCategoriesAdmin } from "@/views/admin/categories/actions/actions"; - -const CategoriesAdminView = dynamic(async () => - import("@/views/admin/categories/table/categories-admin-view").then(mod => ({ - default: mod.CategoriesAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("categories"), - }; -}; - -export default async function CategoriesPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.categories"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
    - - {canCreate && } - - - }> - - -
    -
    - ); +/** The address categories used to live at. See the posts page next door. */ +export default async function LegacyCategoriesPage() { + await redirect(contentAdminHref(blogCategoryContentType.id)); } diff --git a/plugins/blog/src/routes/admin/blog/posts/page.tsx b/plugins/blog/src/routes/admin/blog/posts/page.tsx index 53ee8699e..ca0430824 100644 --- a/plugins/blog/src/routes/admin/blog/posts/page.tsx +++ b/plugins/blog/src/routes/admin/blog/posts/page.tsx @@ -1,64 +1,16 @@ -import type { Metadata } from "next"; - -import { I18nProvider } from "@vitnode/core/components/i18n-provider"; -import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; -import { HeaderContent } from "@vitnode/core/components/ui/header-content"; -import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; -import { getTranslations } from "next-intl/server"; -import dynamic from "next/dynamic"; -import { notFound } from "next/navigation"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@/const"; -import { ActionsPostsAdmin } from "@/views/admin/posts/actions/actions"; - -const PostsAdminView = dynamic(async () => - import("@/views/admin/posts/table/posts-admin-view").then(mod => ({ - default: mod.PostsAdminView, - })), -); - -export const generateMetadata = async (): Promise => { - const t = await getTranslations("@vitnode/blog.admin.nav"); - - return { - title: t("posts"), - }; -}; - -export default async function PostsPage( - params: React.ComponentProps, -) { - const [t, tNav, canView, canCreate] = await Promise.all([ - getTranslations("@vitnode/blog.admin.posts"), - getTranslations("@vitnode/blog.admin.nav"), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_view", - }), - checkAdminPermissionApi({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_create", - }), - ]); - - if (!canView) { - notFound(); - } - - return ( - -
    - - {canCreate && } - - - }> - - -
    -
    - ); +import { contentAdminHref } from "@vitnode/core/content"; +import { redirect } from "@vitnode/core/lib/navigation"; + +import { blogPostContentType } from "@/content/post"; + +/** + * The address articles used to live at. + * + * A redirect rather than a second list screen: the AdminCP linked here for + * several releases, so the URL is in bookmarks and in muscle memory - but the + * page behind it is now generated, and keeping a duplicate of it would mean two + * tables to fix every time one of them was wrong. + */ +export default async function LegacyPostsPage() { + await redirect(contentAdminHref(blogPostContentType.id)); } diff --git a/plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx b/plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx deleted file mode 100644 index b4680017f..000000000 --- a/plugins/blog/src/routes/breadcrumb/admin/blog/categories/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx b/plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx deleted file mode 100644 index 6aad0fb44..000000000 --- a/plugins/blog/src/routes/breadcrumb/admin/blog/posts/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; - -export default function BreadcrumbSlot() { - return ; -} diff --git a/plugins/blog/src/views/admin/article/editor-field.tsx b/plugins/blog/src/views/admin/article/editor-field.tsx new file mode 100644 index 000000000..bf88b03b4 --- /dev/null +++ b/plugins/blog/src/views/admin/article/editor-field.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { Loader } from "@vitnode/core/components/ui/loader"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +/** + * The rich text editor, loaded on demand. + * + * `AutoFormEditor` pulls in the whole Tiptap stack, which is the single heaviest + * thing on the article screen and useless on every other one - so it arrives with + * the editor tab rather than with the page. `ssr: false` because the editor + * mounts against a real DOM; rendering it on the server and again in the browser + * is exactly the hydration mismatch that makes an editor drop its first + * keystroke. + */ +const AutoFormEditor = dynamic( + async () => + await import("@vitnode/core/components/form/fields/editor").then(mod => ({ + default: mod.AutoFormEditor, + })), + { loading: () => , ssr: false }, +); + +/** + * The article body. + * + * A field override, so the editor is one input inside the **same** form as the + * title, the slug, the category and the author: one `react-hook-form` instance, + * one schema, one submit. There is no editor-local state atom and no second save + * button - `field.value` and `field.onChange` are the whole integration, and + * dirty state and validation work because of it. + */ +export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => { + const t = useTranslations("@vitnode/blog.admin.article"); + + return ( + }> + + + ); +}; diff --git a/plugins/blog/src/views/admin/article/form-layout.test.tsx b/plugins/blog/src/views/admin/article/form-layout.test.tsx new file mode 100644 index 000000000..8500985d9 --- /dev/null +++ b/plugins/blog/src/views/admin/article/form-layout.test.tsx @@ -0,0 +1,120 @@ +import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; + +import { render, screen } from "@testing-library/react"; +import { ContentFormProvider } from "@vitnode/core/views/admin/views/content/form/context"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, it, vi } from "vitest"; + +import { BlogArticleFormLayout } from "./form-layout"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@vitnode/core/lib/navigation", () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +/** + * The layout is rendered on its own, with stand-in elements where the Content + * Engine would have put real fields. + * + * That is exactly the contract under test: the layout must place whatever it is + * handed, by name, and must not know or care what a field actually is. + */ +/** The submit button reads the surrounding form, exactly as it does for real. */ +const Harness = ({ children }: { children: React.ReactNode }) => { + const form = useForm(); + + return ( + +
    {children}
    +
    + ); +}; + +const renderLayout = ({ + fieldNames, + surface = "shared", +}: { + fieldNames: string[]; + surface?: ContentFormLayoutProps["surface"]; +}) => + render( + + [ + name, + field:{name}, + ]), + ), + mode: "edit", + publication: { enabled: true, publishedAt: null, status: "draft" }, + surface, + }} + > + + + , + ); + +describe("BlogArticleFormLayout", () => { + it("puts the writing fields in the main column and the metadata beside them", () => { + const { container } = renderLayout({ + fieldNames: ["title", "content", "friendlyUrl", "categoryId", "authorId"], + }); + + for (const name of [ + "title", + "content", + "friendlyUrl", + "categoryId", + "authorId", + ]) { + expect(screen.getByText(`field:${name}`, { exact: false })).toBeTruthy(); + } + + const sections = container.querySelectorAll("section"); + // Body, publish, article settings. + expect(sections).toHaveLength(3); + expect(sections[0].textContent).toContain("title"); + expect(sections[0].textContent).toContain("content"); + expect(sections[2].textContent).toContain("categoryId"); + }); + + it("renders the publication state as a read-only line, with no publish control", () => { + renderLayout({ fieldNames: ["title"] }); + + expect(screen.getByText("draft")).toBeTruthy(); + // One button only: save. Publishing stays where the engine put it. + expect(screen.getAllByRole("button")).toHaveLength(1); + }); + + it("places the same names on a locale tab, ignoring the ones that are not there", () => { + // A localized content type splits its fields in two. The shared surface has + // no `title`, and the layout has to cope without knowing that. + renderLayout({ fieldNames: ["categoryId", "authorId"] }); + + expect(screen.getByText("field:categoryId", { exact: false })).toBeTruthy(); + expect(screen.queryByText("field:title", { exact: false })).toBeNull(); + }); + + it("explains itself on a locale tab", () => { + renderLayout({ fieldNames: ["title"], surface: "translation" }); + + expect(screen.getByText("settings.locale_desc")).toBeTruthy(); + }); +}); diff --git a/plugins/blog/src/views/admin/article/form-layout.tsx b/plugins/blog/src/views/admin/article/form-layout.tsx new file mode 100644 index 000000000..72db811a9 --- /dev/null +++ b/plugins/blog/src/views/admin/article/form-layout.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; + +import { + ContentFormActions, + ContentFormField, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "@vitnode/core/content/admin-form"; +import { useTranslations } from "next-intl"; + +/** + * The article editor: a wide writing column and a metadata sidebar. + * + * **Presentation only.** Every field here is the one the Content Engine built, + * complete with its overrides, its validation and its error message; the submit + * button is the engine's; the mutation, the version precondition, the toast, the + * cache invalidation, the events, the search write and the delivery effects all + * happen exactly as they do in the generated dialog. This file decides where + * things are and nothing else - there is not a single API call in it. + * + * One layout for create and edit, and one for both surfaces of a localized + * content type. `ContentFormField` renders nothing for a field this surface does + * not have, so the shared tab shows the category and the author while each + * language tab shows that language's title, body and URL - from the same source. + * + * The publication state is read-only, deliberately and consistently with the + * generated dialog: `status` and `publishedAt` are not in the form schema, and + * the publish action on the list is the one thing that moves them. Two mutation + * paths in one screen is how a form ends up fighting its own state. + */ +export const BlogArticleFormLayout = ({ surface }: ContentFormLayoutProps) => { + const t = useTranslations("@vitnode/blog.admin.article.form"); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/blog/src/views/admin/categories/actions/actions.tsx b/plugins/blog/src/views/admin/categories/actions/actions.tsx deleted file mode 100644 index fdfea47ed..000000000 --- a/plugins/blog/src/views/admin/categories/actions/actions.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { PlusIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -const CreateEditActionCategoriesAdmin = dynamic(async () => - import("./create-edit/create-edit").then(mod => ({ - default: mod.CreateEditActionCategoriesAdmin, - })), -); - -export const ActionsCategoriesAdmin = () => { - const t = useTranslations("@vitnode/blog.admin.categories.create"); - - return ( - - }> - - {t("title")} - - - - - {t("title")} - {t("desc")} - - - }> - - - - - ); -}; diff --git a/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx b/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx deleted file mode 100644 index 43d2decc7..000000000 --- a/plugins/blog/src/views/admin/categories/actions/create-edit/create-edit.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - AutoForm, - type AutoFormOnSubmit, -} from "@vitnode/core/components/form/auto-form"; -import { AutoFormColor } from "@vitnode/core/components/form/fields/color"; -import { AutoFormInput } from "@vitnode/core/components/form/fields/input"; -import { useDialog } from "@vitnode/core/components/ui/dialog"; -import { - getLangValue, - multiLangValueSchema, -} from "@vitnode/core/lib/helpers/multi-lang"; -import { usePathname, useRouter } from "@vitnode/core/lib/navigation"; -import { useLocale, useTranslations } from "next-intl"; -import { toast } from "sonner"; -import { z } from "zod"; - -import type { zodCategorySchema } from "@/api/modules/categories/routes/get.route"; - -import { createMutationApi, editMutationApi } from "./mutation-api.server"; - -export const CreateEditActionCategoriesAdmin = ({ - data, -}: { - data?: z.infer & { id: number }; -}) => { - const t = useTranslations("@vitnode/blog.admin.categories"); - const tCore = useTranslations("core.global.errors"); - const { setOpen } = useDialog(); - const { push } = useRouter(); - const pathname = usePathname(); - const locale = useLocale(); - const formSchema = z.object({ - title: multiLangValueSchema({ minLength: 1, maxLength: 100 }) - .min(1) - .default(data?.titleTranslations ?? []), - color: z.string().default(data?.color ?? ""), - }); - - const onSubmit: AutoFormOnSubmit = async values => { - const mutation = data?.id - ? await editMutationApi({ id: data.id, ...values }) - : await createMutationApi(values); - - if (mutation?.error) { - toast.error(tCore("title"), { - description: tCore("internal_server_error"), - }); - - return; - } - - toast.success(t(data ? "edit.success" : "create.success"), { - description: getLangValue(values.title, locale) || values.title[0]?.value, - }); - setOpen?.(false); - push(pathname); - }; - - return ( - ( - - ), - }, - { - id: "color", - component: props => ( - - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - children: t(`${data ? "edit" : "create"}.submit`), - }} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts b/plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts deleted file mode 100644 index 6d5e68ea4..000000000 --- a/plugins/blog/src/views/admin/categories/actions/create-edit/mutation-api.server.ts +++ /dev/null @@ -1,60 +0,0 @@ -"use server"; - -import type { z } from "zod"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import type { zodCreateCategorySchema } from "../../../../../api/modules/admin/categories/routes/create.route"; - -import { categoriesAdminModule } from "../../../../../api/modules/admin/categories/categories.admin.module"; - -export const createMutationApi = async ( - body: z.infer, -) => { - const res = await fetcher(categoriesAdminModule, { - prefixPath: "/admin", - method: "post", - module: "categories", - path: "/", - args: { - body, - }, - }); - - if (res.status !== 201) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories", - "page", - ); -}; - -export const editMutationApi = async ({ - id, - ...body -}: z.infer & { id: number }) => { - const res = await fetcher(categoriesAdminModule, { - prefixPath: "/admin", - method: "put", - module: "categories", - path: "/{id}", - args: { - params: { - id, - }, - body, - }, - }); - - if (res.status !== 200) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx b/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx deleted file mode 100644 index 2394677a5..000000000 --- a/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { ConfirmActionAlertDialog } from "@vitnode/core/components/confirm-action/confirm-action-alert-dialog"; -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { Trash2Icon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { toast } from "sonner"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { mutationApi } from "./mutation-api.server"; - -export const DeleteAction = ({ title, id }: { id: number; title: string }) => { - const t = useTranslations("@vitnode/blog.admin.categories.delete"); - const tGlobal = useTranslations("core.global"); - const canDelete = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_delete", - }); - - if (!canDelete) return null; - - return ( - - - ( - {title} - ), - })} - onSubmit={async ({ onClose }) => { - const mutation = await mutationApi(id); - if (mutation?.error) { - toast.error(tGlobal("errors.title"), { - description: tGlobal("errors.internal_server_error"), - }); - - return; - } - - toast.success(t("success"), { - description: title, - }); - onClose(); - }} - textSubmit={t("confirm")} - title={t("title")} - > - - - - } - /> - - - {t("title")} - - - ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts b/plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts deleted file mode 100644 index 661b6a735..000000000 --- a/plugins/blog/src/views/admin/categories/table/actions/delete/mutation-api.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -"use server"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import { categoriesAdminModule } from "@/api/modules/admin/categories/categories.admin.module"; - -export const mutationApi = async (id: number) => { - const res = await fetcher(categoriesAdminModule, { - prefixPath: "/admin", - method: "delete", - path: "/{id}", - module: "categories", - args: { - params: { - id, - }, - }, - }); - - if (!res.ok) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx deleted file mode 100644 index 092a72665..000000000 --- a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { PencilIcon } from "lucide-react"; -import { useLocale, useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@/const"; - -const CreateEditActionCategoriesAdmin = dynamic(async () => - import("../..//actions/create-edit/create-edit").then(mod => ({ - default: mod.CreateEditActionCategoriesAdmin, - })), -); - -export const EditAction = ( - props: Required>, -) => { - const t = useTranslations("@vitnode/blog.admin.categories.edit"); - const locale = useLocale(); - const canEdit = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "categories", - permission: "can_edit", - }); - - if (!canEdit) return null; - - return ( - - - - - } - > - -
    - } - /> - {t("title")} - - - - - - {t("title")} - - {getLangValue(props.data.titleTranslations, locale) || - props.data.titleTranslations[0]?.value} - - - - }> - - - -
    - ); -}; diff --git a/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx b/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx deleted file mode 100644 index 41e58bbe6..000000000 --- a/plugins/blog/src/views/admin/categories/table/categories-admin-view.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { DateFormat } from "@vitnode/core/components/date-format"; -import { DataTable } from "@vitnode/core/components/table/data-table"; -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { getLocale, getTranslations } from "next-intl/server"; - -import { categoriesModule } from "@/api/modules/categories/categories.module"; - -import { DeleteAction } from "./actions/delete/delete-action"; -import { EditAction } from "./actions/edit-action"; - -export const CategoriesAdminView = async ({ - searchParams, -}: { - searchParams: Promise>; -}) => { - const t = await getTranslations("@vitnode/blog.admin.categories.table"); - const locale = await getLocale(); - const query = await searchParams; - const res = await fetcher(categoriesModule, { - path: "/", - method: "get", - module: "categories", - args: { - query, - }, - withPagination: true, - options: { - cache: "force-cache", - }, - }); - const data = await res.json(); - - return ( - - row.color ? ( -
    - - - {row.color} - -
    - ) : ( - - ), - }, - { - accessorKey: "updatedAt", - header: t("updated_at"), - className: "w-48", - cell: ({ row }) => , - }, - { - id: "actions", - header: "", - align: "right", - className: "w-10", - cell: ({ row }) => ( - <> - - - - ), - }, - ]} - edges={data.edges.map(edge => ({ - ...edge, - title: - getLangValue(edge.titleTranslations, locale) || - edge.titleTranslations[0]?.value || - "", - }))} - id="categories-table" - order={{ - columns: ["createdAt", "updatedAt"], - defaultOrder: { - column: "createdAt", - order: "desc", - }, - }} - pageInfo={data.pageInfo} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/category/color-cell.test.tsx b/plugins/blog/src/views/admin/category/color-cell.test.tsx new file mode 100644 index 000000000..e6b970fee --- /dev/null +++ b/plugins/blog/src/views/admin/category/color-cell.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { BlogCategoryColorCell } from "./color-cell"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +type Row = Parameters[0]["row"]; + +const renderCell = (color: null | string) => + render(); + +describe("BlogCategoryColorCell", () => { + it("says the colour in words as well as showing it", () => { + const { container } = renderCell("#3260c0"); + + // The value is real text, so a screen reader and a colour-blind reader get + // the same information the swatch carries. + expect(screen.getByText("#3260c0")).toBeTruthy(); + + const swatch = container.querySelector("span[aria-hidden]"); + expect(swatch).toBeTruthy(); + expect((swatch as HTMLElement).style.backgroundColor).toBe( + "rgb(50, 96, 192)", + ); + }); + + it("keeps the swatch out of the accessibility tree", () => { + const { container } = renderCell("#3260c0"); + + expect(container.querySelector("span[aria-hidden]")?.textContent).toBe(""); + }); + + it("names the empty state rather than rendering a blank cell", () => { + renderCell(null); + + expect(screen.getByText("color.none")).toBeTruthy(); + expect(screen.queryByText("#3260c0")).toBeNull(); + }); +}); diff --git a/plugins/blog/src/views/admin/category/color-cell.tsx b/plugins/blog/src/views/admin/category/color-cell.tsx new file mode 100644 index 000000000..8ba75a9df --- /dev/null +++ b/plugins/blog/src/views/admin/category/color-cell.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { ContentCellProps } from "@vitnode/core/lib/plugin"; + +import { useTranslations } from "next-intl"; + +import type { blogCategoryContentType } from "@/content/category"; + +/** + * The colour column, as a swatch **and** the value it stands for. + * + * The text is not decoration. A cell that communicated the colour only visually + * would be unreadable to a screen reader and ambiguous to anyone who cannot tell + * two blues apart, so the swatch is `aria-hidden` and the value next to it is + * the accessible content. + * + * A column override, so none of this reasoning lands in the generic content + * table - which knows about kinds, not about colours. + */ +export const BlogCategoryColorCell = ({ + row, +}: ContentCellProps) => { + const t = useTranslations("@vitnode/blog.admin.category"); + const color = row.color; + + if (!color) { + return {t("color.none")}; + } + + return ( +
    + + {color} +
    + ); +}; diff --git a/plugins/blog/src/views/admin/category/color-field.tsx b/plugins/blog/src/views/admin/category/color-field.tsx new file mode 100644 index 000000000..b2aebd2d4 --- /dev/null +++ b/plugins/blog/src/views/admin/category/color-field.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { AutoFormColor } from "@vitnode/core/components/form/fields/color"; +import { useTranslations } from "next-intl"; + +/** + * The category colour, as the AdminCP's own colour picker. + * + * A field override, not a new field kind: the Content Engine stores a + * `varchar(50)` and has no opinion about what is in it, and the picker VitNode + * already ships is what turns that into something anyone would want to use. The + * value, the validation and the mutation are still the engine's. + */ +export const BlogCategoryColorField = (props: ItemAutoFormComponentProps) => { + const t = useTranslations("@vitnode/blog.admin.category"); + + return ( + + ); +}; diff --git a/plugins/blog/src/views/admin/posts/actions/actions.tsx b/plugins/blog/src/views/admin/posts/actions/actions.tsx deleted file mode 100644 index 9630734e4..000000000 --- a/plugins/blog/src/views/admin/posts/actions/actions.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { PlusIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -const CreateEditActionPostsAdmin = dynamic(async () => - import("./create-edit/create-edit").then(module => ({ - default: module.CreateEditActionPostsAdmin, - })), -); - -export const ActionsPostsAdmin = () => { - const t = useTranslations("@vitnode/blog.admin.posts.create"); - - return ( - - }> - - {t("title")} - - - - - {t("title")} - {t("desc")} - - - }> - - - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx b/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx deleted file mode 100644 index bb6a25892..000000000 --- a/plugins/blog/src/views/admin/posts/actions/create-edit/create-edit.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { - AutoForm, - type AutoFormOnSubmit, -} from "@vitnode/core/components/form/auto-form"; -import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; -import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor"; -import { useDialog } from "@vitnode/core/components/ui/dialog"; -import { fetcherClient } from "@vitnode/core/lib/fetcher-client"; -import { - getLangValue, - multiLangValueSchema, -} from "@vitnode/core/lib/helpers/multi-lang"; -import { usePathname, useRouter } from "@vitnode/core/lib/navigation"; -import { useLocale, useTranslations } from "next-intl"; -import React from "react"; -import { toast } from "sonner"; -import { z } from "zod"; - -import type { zodPostSchema } from "@/api/modules/posts/routes/get.route"; - -import { categoriesModule } from "@/api/modules/categories/categories.module"; - -import { FriendlyUrlField, TitleField } from "./multi-lang-fields"; -import { createMutationApi, editMutationApi } from "./mutation-api.server"; - -export const CreateEditActionPostsAdmin = ({ - data, -}: { - data?: z.infer & { id?: number }; -}) => { - const t = useTranslations("@vitnode/blog.admin.posts"); - const tCore = useTranslations("core.global.errors"); - const locale = useLocale(); - const { setOpen } = useDialog(); - const { push } = useRouter(); - const pathname = usePathname(); - const resolveCategoryTitle = ( - translations: { languageCode: string; value: string }[], - ) => getLangValue(translations, locale) || translations[0]?.value || ""; - const friendlyUrlTouchedRef = React.useRef>( - new Set(data?.friendlyUrlTranslations?.map(item => item.languageCode)), - ); - - const formSchema = z.object({ - title: multiLangValueSchema({ minLength: 3, maxLength: 255 }) - .min(1) - .default(data?.titleTranslations ?? []), - friendlyUrl: multiLangValueSchema({ minLength: 1, maxLength: 255 }) - .min(1) - .default(data?.friendlyUrlTranslations ?? []), - content: multiLangValueSchema().default(data?.contentTranslations ?? []), - categoryId: z - .object({ value: z.string(), label: z.string() }) - .refine(value => value.value !== "", { - message: tCore("field_required"), - }) - .default( - data?.category - ? { - value: data.category.id.toString(), - label: resolveCategoryTitle(data.category.titleTranslations), - } - : { value: "", label: "" }, - ), - }); - - const onSubmit: AutoFormOnSubmit = async ( - values, - form, - ) => { - const body = { - title: values.title, - content: values.content, - friendlyUrl: values.friendlyUrl, - categoryId: parseInt(values.categoryId.value, 10), - }; - const mutation = data?.id - ? await editMutationApi({ id: data.id, ...body }) - : await createMutationApi(body); - - if (mutation?.error) { - if (mutation.error.includes("already exists")) { - form.setError("friendlyUrl", { - type: "manual", - message: t("create.form.friendly_url.already_exists"), - }); - - return; - } - - toast.error(tCore("title"), { - description: tCore("internal_server_error"), - }); - - return; - } - - toast.success(t(data ? "edit.success" : "create.success")); - setOpen?.(false); - setTimeout(() => push(pathname), 300); - }; - - return ( - ( - - ), - }, - { - id: "friendlyUrl", - component: props => ( - - ), - }, - { - id: "categoryId", - component: props => ( - { - const res = await fetcherClient(categoriesModule, { - path: "/", - method: "get", - module: "categories", - args: { - query: { - search, - }, - }, - }); - const data = await res.json(); - - return data.edges.map(category => ({ - label: resolveCategoryTitle(category.titleTranslations), - value: category.id.toString(), - })); - }} - id="categoryId" - label={t("create.form.category")} - {...props} - /> - ), - }, - { - id: "content", - component: props => ( - - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - children: t(`${data ? "edit" : "create"}.submit`), - }} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx b/plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx deleted file mode 100644 index ba2294400..000000000 --- a/plugins/blog/src/views/admin/posts/actions/create-edit/multi-lang-fields.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; -import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; - -import { AutoFormDesc } from "@vitnode/core/components/form/common/desc"; -import { AutoFormLabel } from "@vitnode/core/components/form/common/label"; -import { - MultiLangSelect, - useMultiLangField, -} from "@vitnode/core/components/form/fields/multi-lang"; -import { FormControl, FormMessage } from "@vitnode/core/components/ui/form"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from "@vitnode/core/components/ui/input-group"; -import { upsertLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { removeSpecialCharacters } from "@vitnode/core/lib/special-characters"; -import React from "react"; -import { useFormContext } from "react-hook-form"; - -const MultiLangInputGroup = ({ - currentValue, - languages, - onChange, - onBlur, - onSelect, - selected, - ...props -}: Omit< - React.ComponentProps, - "onBlur" | "onChange" | "onSelect" | "value" -> & { - currentValue: string; - languages: ReturnType["languages"]; - onBlur: () => void; - onChange: (value: string) => void; - onSelect: (code: string) => void; - selected: string; -}) => ( - - - onChange(e.target.value)} - value={currentValue} - {...props} - /> - {languages.length > 1 && ( - - - - )} - - -); - -// Title drives the friendly URL: as the user types the title for a language, the -// same language's friendly URL is filled with a slug - until that language's -// friendly URL is edited by hand (tracked in `friendlyUrlTouched`). -export const TitleField = ({ - field, - label, - description, - friendlyUrlName, - friendlyUrlTouched, -}: ItemAutoFormComponentProps & { - friendlyUrlName: string; - friendlyUrlTouched: React.RefObject>; -}) => { - const form = useFormContext(); - const { languages, selected, setSelected, currentValue, setValue } = - useMultiLangField(field); - - return ( - <> - {!!label && {label}} - { - setValue(value); - - if (friendlyUrlTouched.current?.has(selected)) return; - const current: MultiLangValue | undefined = - form.getValues(friendlyUrlName); - form.setValue( - friendlyUrlName, - upsertLangValue(current, selected, removeSpecialCharacters(value)), - { shouldValidate: true, shouldDirty: true }, - ); - }} - onSelect={setSelected} - selected={selected} - /> - {!!description && {description}} - - - ); -}; - -export const FriendlyUrlField = ({ - field, - label, - description, - friendlyUrlTouched, -}: ItemAutoFormComponentProps & { - friendlyUrlTouched: React.RefObject>; -}) => { - const { languages, selected, setSelected, currentValue, setValue } = - useMultiLangField(field); - - return ( - <> - {!!label && {label}} - { - friendlyUrlTouched.current?.add(selected); - setValue(value); - }} - onSelect={setSelected} - selected={selected} - /> - {!!description && {description}} - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts b/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts deleted file mode 100644 index 1046849d9..000000000 --- a/plugins/blog/src/views/admin/posts/actions/create-edit/mutation-api.server.ts +++ /dev/null @@ -1,60 +0,0 @@ -"use server"; - -import type { z } from "zod"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import type { zodCreatePostSchema } from "@/api/modules/admin/posts/routes/create.route"; - -import { postsAdminModule } from "@/api/modules/admin/posts/posts.admin.module"; - -export const createMutationApi = async ( - body: z.infer, -) => { - const res = await fetcher(postsAdminModule, { - prefixPath: "/admin", - method: "post", - module: "posts", - path: "/", - args: { - body, - }, - }); - - if (res.status !== 201) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts", - "page", - ); -}; - -export const editMutationApi = async ({ - id, - ...body -}: z.infer & { id: number }) => { - const res = await fetcher(postsAdminModule, { - prefixPath: "/admin", - method: "put", - module: "posts", - path: "/{id}", - args: { - params: { - id, - }, - body, - }, - }); - - if (res.status !== 200) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx b/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx deleted file mode 100644 index 4b92470a3..000000000 --- a/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { ConfirmActionAlertDialog } from "@vitnode/core/components/confirm-action/confirm-action-alert-dialog"; -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { Trash2Icon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { toast } from "sonner"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { mutationApi } from "./mutation-api.server"; - -export const DeleteAction = ({ title, id }: { id: number; title: string }) => { - const t = useTranslations("@vitnode/blog.admin.posts.delete"); - const tGlobal = useTranslations("core.global"); - const canDelete = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_delete", - }); - - if (!canDelete) return null; - - return ( - - - ( - {title} - ), - })} - onSubmit={async ({ onClose }) => { - const mutation = await mutationApi(id); - if (mutation?.error) { - toast.error(tGlobal("errors.title"), { - description: tGlobal("errors.internal_server_error"), - }); - - return; - } - - toast.success(t("success"), { - description: title, - }); - onClose(); - }} - textSubmit={t("confirm")} - title={t("title")} - > - - - - } - /> - - - {t("title")} - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts b/plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts deleted file mode 100644 index 227f71823..000000000 --- a/plugins/blog/src/views/admin/posts/table/actions/delete/mutation-api.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -"use server"; - -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { revalidatePath } from "next/cache"; - -import { postsAdminModule } from "@/api/modules/admin/posts/posts.admin.module"; - -export const mutationApi = async (id: number) => { - const res = await fetcher(postsAdminModule, { - prefixPath: "/admin", - method: "delete", - path: "/{id}", - module: "posts", - args: { - params: { - id, - }, - }, - }); - - if (!res.ok) { - return { error: await res.text() }; - } - - revalidatePath( - "/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts", - "page", - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx deleted file mode 100644 index 87343e7ab..000000000 --- a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import { useAdminStaffPermission } from "@vitnode/core/components/staff-permission/provider"; -import { Button } from "@vitnode/core/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@vitnode/core/components/ui/dialog"; -import { Loader } from "@vitnode/core/components/ui/loader"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@vitnode/core/components/ui/tooltip"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { PencilIcon } from "lucide-react"; -import { useLocale, useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -import React from "react"; - -import { CONFIG_PLUGIN } from "@/const"; - -const CreateEditActionPostsAdmin = dynamic(async () => - import("../../actions/create-edit/create-edit").then(mod => ({ - default: mod.CreateEditActionPostsAdmin, - })), -); - -export const EditAction = ( - props: Required>, -) => { - const t = useTranslations("@vitnode/blog.admin.posts.edit"); - const locale = useLocale(); - const canEdit = useAdminStaffPermission({ - plugin: CONFIG_PLUGIN.pluginId, - module: "posts", - permission: "can_edit", - }); - - if (!canEdit) return null; - - return ( - - - - - } - > - - - } - /> - {t("title")} - - - - - - {t("title")} - - {getLangValue(props.data.titleTranslations, locale) || - props.data.titleTranslations[0]?.value} - - - - }> - - - - - ); -}; diff --git a/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx b/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx deleted file mode 100644 index 914d5ef0c..000000000 --- a/plugins/blog/src/views/admin/posts/table/posts-admin-view.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Avatar } from "@vitnode/core/components/avatar"; -import { DateFormat } from "@vitnode/core/components/date-format"; -import { DataTable } from "@vitnode/core/components/table/data-table"; -import { fetcher } from "@vitnode/core/lib/fetcher"; -import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; -import { getLocale, getTranslations } from "next-intl/server"; - -import { postsModule } from "@/api/modules/posts/posts.module"; - -import { DeleteAction } from "./actions/delete/delete-action"; -import { EditAction } from "./actions/edit-action"; - -export const PostsAdminView = async ({ - searchParams, -}: { - searchParams: Promise>; -}) => { - const t = await getTranslations("@vitnode/blog.admin.posts.table"); - const locale = await getLocale(); - const query = await searchParams; - const res = await fetcher(postsModule, { - path: "/", - method: "get", - module: "posts", - args: { - query, - }, - withPagination: true, - options: { - cache: "force-cache", - }, - }); - const data = await res.json(); - - return ( - - getLangValue(row.category.titleTranslations, locale) || - row.category.titleTranslations[0]?.value || - "", - }, - { - accessorKey: "author", - header: t("author"), - className: "w-48", - cell: ({ row }) => - row.author ? ( -
    - - {row.author.name} -
    - ) : ( - - ), - }, - { - accessorKey: "updatedAt", - header: t("updated_at"), - className: "w-48", - cell: ({ row }) => , - }, - { - id: "actions", - header: "", - align: "right", - className: "w-10", - cell: ({ row }) => ( - <> - - - - ), - }, - ]} - edges={data.edges.map(edge => ({ - ...edge, - title: - getLangValue(edge.titleTranslations, locale) || - edge.titleTranslations[0]?.value || - "", - }))} - id="posts-table" - order={{ - columns: ["createdAt", "updatedAt"], - defaultOrder: { - column: "createdAt", - order: "desc", - }, - }} - pageInfo={data.pageInfo} - /> - ); -}; diff --git a/plugins/blog/tsconfig.json b/plugins/blog/tsconfig.json index 0862c86ca..573bd9b55 100644 --- a/plugins/blog/tsconfig.json +++ b/plugins/blog/tsconfig.json @@ -18,9 +18,17 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "exclude": ["node_modules"], - "include": ["src", "global.d.ts"] + "exclude": [ + "node_modules" + ], + "include": [ + "src", + "global.d.ts", + "vitest.config.ts" + ] } diff --git a/plugins/blog/vitest.config.ts b/plugins/blog/vitest.config.ts new file mode 100644 index 000000000..3191f9009 --- /dev/null +++ b/plugins/blog/vitest.config.ts @@ -0,0 +1,24 @@ +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + exclude: ["**/node_modules/**", "**/dist/**"], + // The migration suite drops and rebuilds the schema in its `beforeAll`, so + // it must not share a database with another file running at the same time. + fileParallelism: false, + typecheck: { + tsconfig: "./tsconfig.json", + include: ["**/*.test-d.ts"], + }, + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); From f5c60ac5231ad9f7d1a930ac6d46b01ec45a8c8c Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 11:12:58 +0200 Subject: [PATCH 111/123] docs(content): document admin form presentation and layouts A new page for dialog-vs-page and custom layouts, with the blog as the worked example of both ends: a category with a colour picker and a colour cell, and an article with a page-mode editor and `AutoFormEditor`. Says plainly what a layout is not: it decides where the fields are, and the Content Engine decides what happens when you press Save. The blog guide gains its new architecture and an upgrade section naming the two deliberate breaks, and the events reference now marks the blog's own event names as compatibility adapters over `content.blog.*`. Co-Authored-By: Claude Opus 5 (1M context) --- .../dev/content-engine/admin-form-layouts.mdx | 281 +++++++++++++++++ .../docs/dev/content-engine/admincp.mdx | 20 +- .../content/docs/dev/content-engine/meta.json | 1 + .../docs/dev/events/built-in-events.mdx | 56 +++- apps/docs/content/docs/guides/blog.mdx | 112 +++++++ pnpm-lock.yaml | 292 +++--------------- 6 files changed, 494 insertions(+), 268 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx diff --git a/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx new file mode 100644 index 000000000..acf8ff8f3 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx @@ -0,0 +1,281 @@ +--- +title: Dialog or page, and custom layouts +description: Choose how a content type's create and edit forms appear - and rearrange them without giving up a single line of the generated behaviour. +icon: LayoutPanelLeft +--- + +The generated create and edit forms open in a dialog. That is right for most +records and wrong for the ones people spend an hour inside, so a content type can +say which it wants - and, separately, a plugin can decide where the fields go. + +The two are independent. Page mode with no layout is a perfectly good screen; a +custom layout inside a dialog works too. + +## Dialog or page + +```ts title="src/content/article.ts" +admin: { + label: { plural: "Articles", singular: "Article" }, + + create: { mode: "page" }, + edit: { mode: "page" }, +} +``` + +`"dialog"` and `"page"`, and **`"dialog"` is the default** - a content type +written before this existed behaves exactly as it did, and nothing about it +changes until somebody adds those two lines. Each action is independent: a +content type can create on a page and edit in a dialog. + +```ts +// @ts-expect-error - only "dialog" and "page" are presentation modes +create: { mode: "drawer" } +``` + +### The URLs + +Page mode is served by the **same** catch-all route as the list. There is no +second router, and no file to add: + +```text +/admin/content/blog/post list +/admin/content/blog/post/create create page +/admin/content/blog/post/42/edit edit page +``` + +The Create button becomes a link rather than a dialog trigger - none of the +form's JavaScript is downloaded until the page it points at is requested - and +the pencil in each table row becomes a link too. Typing either URL works, which +is the point of checking permissions on the server rather than on the button. + + + The slug resolves to a content type id first, and only then as a form URL. So + an id that ends in `.create` keeps its own list screen, and the create page of + its neighbour is unreachable - a name clash its author can see, rather than a + screen that silently disappeared. + + +### Permissions + +Page mode weakens nothing. The create page checks `can_view` **and** +`can_create`; the edit page checks `can_view` and then `can_edit`, or +`can_translate` on a localized content type - the same pair the edit dialog +opens for. A missing permission is a 404, whether the button was rendered or +not, and the generated route behind the form checks again. + +### After a successful save + +| Situation | What happens | +| --- | --- | +| Create, and edit is also `page` | Goes to the new record's edit page, using the id the mutation returned | +| Create, and edit is a dialog | Goes back to the list | +| Edit | Stays on the page with fresh server data | + +Everything else is unchanged: validation errors stay in the form, structured +backend errors read as sentences, success raises a `sonner` toast with a +description, version conflicts show the banner with your typing intact, and the +submit button is disabled while the write is in flight. + + + The form shows the publication state read-only, on a page exactly as in a + dialog. `status` and `publishedAt` are not in the form schema, and the publish + action on the list is the one thing that moves them - two mutation paths in one + screen is how a form ends up fighting its own state. + + +## Custom layouts + +A layout decides **where the fields are**. It does not decide what happens when +you press Save. + +The Content Engine keeps the form schema, the validation, the default values, +the field overrides, the AutoForm integration, the mutation, the version +precondition, the structured errors, the publication state, the editorial state, +the translations, the permissions, the toast, the cache invalidation, the events, +the search write and the delivery effects. All of them. A layout that called an +API directly would be doing something the engine already did, twice. + +### Registering one + +Layouts live in `buildPlugin`, next to the field and column overrides - never on +the definition, which `src/database/*.ts` imports and Drizzle Kit executes. + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: blogPostContentType, + + fields: { + content: { component: BlogArticleEditorField }, + }, + + forms: { + layout: BlogArticleFormLayout, + }, +}); +``` + +`layout` covers both actions. Override one when they genuinely differ: + +```tsx +forms: { + layout: SharedLayout, + create: { layout: FirstDraftLayout }, +} +``` + +### Writing one + +```tsx title="src/views/admin/article/form-layout.tsx" +"use client"; + +import { + ContentFormActions, + ContentFormField, + ContentFormLayoutGrid, + ContentFormMain, + ContentFormSection, + ContentFormSidebar, + ContentFormStatus, +} from "@vitnode/core/content/admin-form"; + +export const BlogArticleFormLayout = () => ( + + + + + + + + + + + + + + + + + + + + + +); +``` + +There is one `
    `, one schema and one submit path. `ContentFormField` +renders the element the engine already built - **including its field override**, +so overrides and layouts compose - and an error stays attached to the input it +belongs to wherever that input ended up. + +### The primitives + +| Primitive | What it does | +| --- | --- | +| `ContentFormField` | One field, by name. Nothing if this surface has no such field | +| `ContentFormRemainingFields` | Everything the layout did not name | +| `ContentFormActions` | The submit row, with an optional `cancelHref` | +| `ContentFormStatus` | The read-only publication line | +| `ContentFormLayoutGrid` / `Main` / `Sidebar` / `Section` | AdminCP chrome: two columns above `lg`, one below | +| `useContentForm()` | `mode`, `surface`, `fieldNames`, `publication` | + +### Localized content types get one layout, not two + +A localized content type splits its fields across two surfaces - the shared tab +and one tab per language - and the same layout is rendered in both. +`ContentFormField` renders **nothing** for a name the current surface does not +have, which is what lets a single file place `title` and `categoryId` without +knowing which table either lives on: + +```text +Shared tab → categoryId, authorId in the sidebar +English tab → title, content in the main column; friendlyUrl in the sidebar +``` + +`useContentForm().surface` is `"shared"` or `"translation"` when a layout wants +to word something differently. + +### The server/client boundary + +`config.tsx` is a **server** module, so a layout referenced from it is a client +reference crossing an RSC boundary. That decides the shape of the whole API: + +- The layout receives only **serialisable** props: `mode`, `surface`, + `contentTypeId`, `pluginId`, `itemId`, `singular`, `publication`, `title`. +- Field elements, the form instance and the submit action arrive through + **client context** instead. A `renderField(name)` callback prop would read + well and would be a server closure, which cannot cross the boundary at all. + +So: `"use client"` at the top of the layout file, and no inline arrow in +`config.tsx`. + + + In development, a layout that never renders one of its surface's fields logs + which ones - a field silently missing from the payload is the one failure mode + this API has that the generated form does not. + + +## Field and column overrides + +Both are unchanged, and both compose with everything above - see +[Overriding the AdminCP](/docs/dev/content-engine/overriding-admincp). + +The blog is the worked example of all three. A **simple** record, with a colour +picker and a colour cell: + +```tsx title="src/config.tsx" +contentTypeAdmin({ + definition: blogCategoryContentType, + fields: { color: { component: BlogCategoryColorField } }, + columns: { color: { cell: BlogCategoryColorCell } }, +}); +``` + +```tsx title="src/views/admin/category/color-cell.tsx" +"use client"; + +export const BlogCategoryColorCell = ({ row }) => + row.color ? ( +
    + + + {row.color} + +
    + ) : ( + No color + ); +``` + +The swatch is `aria-hidden` and the value beside it is real text: a cell that +communicated the colour only visually would be unreadable to a screen reader and +ambiguous to anyone who cannot tell two blues apart. + +And a **rich** one, with the editor: + +```tsx title="src/views/admin/article/editor-field.tsx" +"use client"; + +const AutoFormEditor = dynamic( + async () => + await import("@vitnode/core/components/form/fields/editor").then(mod => ({ + default: mod.AutoFormEditor, + })), + { loading: () => , ssr: false }, +); + +export const BlogArticleEditorField = (props: ItemAutoFormComponentProps) => ( + }> + + +); +``` + +`field.value` and `field.onChange` are the whole integration. The editor is one +input in the same `react-hook-form` instance as the title and the category, so +dirty state and validation work without a single line about them - and the Tiptap +bundle arrives with the editor rather than with the page. diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index f006e0ab7..5cee7e76a 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -29,7 +29,9 @@ You get a nav item, a breadcrumb, and a screen at: - **Sorting** - `admin.list.orderableFields`, plus the system columns and the publication ones ([below](#what-is-sortable)) - **Pagination** - the standard cursor pagination, capped at 100 per page -- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open +- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open - or full pages, + with `admin.create.mode` / `admin.edit.mode` + ([below](#dialog-or-page)) - **Delete** - a confirmation dialog - **History** - with [`editorial`](#editorial): every version, a diff, and restore - **Empty, loading and error states** - out of the box @@ -55,6 +57,22 @@ chunks, so it is downloaded once: milliseconds of theatre either way. +## Dialog or page + +The forms open in a dialog by default. A content type people spend an hour +inside can ask for a page instead, and a plugin can rearrange either without +giving up any of the generated behaviour: + +```ts +admin: { + create: { mode: "page" }, + edit: { mode: "page" }, +} +``` + +`"dialog"` is the default and stays the default. See +[Dialog or page, and custom layouts](/docs/dev/content-engine/admin-form-layouts). + ## What is sortable The table header offers a sort control for every column the generated route diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 71e167fdc..209a3a733 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -49,6 +49,7 @@ "permissions", "events", "overriding-admincp", + "admin-form-layouts", "production-hardening", "concurrency", "failure-and-retries", 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 522971ecd..a41726738 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -18,10 +18,10 @@ the emitting plugin are needed, the event map is global. | `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | | `blog.post.created` | `{ postId, categoryId }` | A blog post is created | | `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited | -| `blog.post.deleted` | `{ postId, categoryId }` | A blog post is deleted | +| `blog.post.deleted` | `{ postId }` | A blog post is deleted | | `blog.category.created` | `{ categoryId }` | A blog category is created | | `blog.category.updated` | `{ categoryId }` | A blog category is edited | -| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category (and its posts, via cascade) is deleted | +| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category is deleted (`postIds` is always empty) | ## Core @@ -166,10 +166,19 @@ themselves, and core will emit it once deletion lands. ## Blog (`@vitnode/blog`) + + The blog runs on the [Content + Engine](/docs/dev/content-engine), so the events that describe what actually + happened are `content.blog.post.*` and `content.blog.category.*` - they carry + changed fields, revision ids, publication transitions, per-locale translation + events and slug history. The four names below are re-emitted from those by + listeners in the plugin, so existing consumers keep working. Prefer the + `content.*` ones for anything new. + + ### blog.category.created / blog.category.updated -Emitted after a category (and its translated titles) is created or edited in -the AdminCP. +Re-emitted after `content.blog.category.created` / `.updated`. + + + The row is gone by the time this is emitted, so there is nothing left to read + it from - and inventing one would put a wrong id into an audit trail. A + listener that needs the category should watch `content.blog.post.deleted` and + keep its own index. + + ### blog.category.deleted -Emitted after a category is deleted. Deleting a category cascade-deletes its -posts at the database level, so the payload carries the ids of the posts that -were removed with it. +Re-emitted after `content.blog.category.deleted`. -**Use cases:** the blog plugin itself ships a listener on this event -(`cleanup-category-search`) that removes the cascade-deleted posts from the -search index - a good template for cleaning up any data your plugin keys by -post id. - ## Content Engine events Every content type declared with the diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 86cbf5f65..3bbfe4b21 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -85,3 +85,115 @@ npm run dev + +## How it is built + +The blog is the Content Engine's reference implementation. Two content types, +three component overrides and one layout - and no CRUD of its own: + +```text +plugins/blog/src/ +├── content/category.ts Blog Category (blog.category) +├── content/post.ts Blog Article (blog.post) +├── database/{categories,posts}.ts createContentModel(...) +├── config.tsx contentTypeAdmin(...) x2 +└── views/admin/ + ├── category/color-field.tsx AutoFormColor override + ├── category/color-cell.tsx table cell override + ├── article/editor-field.tsx AutoFormEditor override + └── article/form-layout.tsx the editor screen +``` + +There is no `api/modules/admin/**`, no create/edit dialog, no manual validation, +no manual search sync and no hand-written slug uniqueness check. The generated +routes, forms, permissions, events, search documents and canonical URLs come +from the two definitions. + +### Categories - the simple example + +Dialog create and edit, because a name and a colour do not need a page: + +```ts title="src/content/category.ts" +export const blogCategoryContentType = defineContentType({ + id: "blog.category", + tableName: "blog_categories", + + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + + fields: { + color: field.text({ maxLength: 50, nullable: true }), + name: field.text({ localized: true, required: true, maxLength: 100 }), + }, + + admin: { + label: { plural: "Categories", singular: "Category" }, + permissionModule: "categories", + titleField: null, + create: { mode: "dialog" }, + edit: { mode: "dialog" }, + list: { columns: ["color", "updatedAt"] }, + }, +}); +``` + +The colour is the AdminCP's own picker through a +[field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), +and the colour column is a swatch **plus** the value in words. + +### Articles - the rich example + +Page create and edit, a custom layout, `AutoFormEditor` for the body, a native +relation to the category, an author, publication, editorial history, search and +delivery: + +```text +/admin/content/blog/post list +/admin/content/blog/post/create create +/admin/content/blog/post/42/edit edit +``` + +```text +┌───────────────────────────────────────────────────────┐ +│ Title │ Publish │ +│ [............................] │ Status: Draft │ +│ │ [ Save ] │ +│ Content ├──────────────────────┤ +│ ┌────────────────────────────┐ │ Article settings │ +│ │ AutoFormEditor │ │ Friendly URL │ +│ └────────────────────────────┘ │ Category │ +│ │ Author │ +└───────────────────────────────────────────────────────┘ +``` + +Below `lg` it is a single column: body first, then metadata, then the actions. + +Articles are localized, so the editor is the same layout on the shared tab and +on each language tab - `ContentFormField` renders nothing for a field the +current surface does not have, so `title` and `content` appear per language +while `categoryId` and `authorId` appear once. + +### Upgrading from an older blog + +Migration `0035_migrate_blog_to_content_engine.sql` is additive. No table is +dropped and no record moves: + +- `blog_categories` and `blog_posts` keep their names, ids, colours, categories, + authors and timestamps. +- The text moves out of `core_languages_words` and into + `blog_categories_translations` / `blog_posts_translations`, one row per + language that actually had a translation. +- Every existing article becomes `published` with `publishedAt = createdAt` - + they were all publicly readable before, and that is the one publication fact + the old schema can prove. `version` starts at 1 and no revision history is + invented. +- A record with no default-locale translation gets one built from a name it + already has, rather than being left unreadable. + +Two things do change, deliberately: + +| Was | Now | +| --- | --- | +| `GET /api/@vitnode/blog/posts` | `GET /api/@vitnode/blog/content/blog` | +| `GET /api/@vitnode/blog/categories` | removed - categories have no public URL | +| `/admin/blog/posts`, `/admin/blog/categories` | redirect to the generated screens | +| `blog.post.deleted` carried `categoryId` | it does not; see [Built-in events](/docs/dev/events/built-in-events#blogpostdeleted) | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c394030b..86cb86a73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,7 +620,7 @@ importers: version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-scan: specifier: ^0.5.7 - version: 0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) recharts: specifier: ^3.10.0 version: 3.10.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1) @@ -818,24 +818,42 @@ importers: '@swc/core': specifier: ^1.15.46 version: 1.15.46 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/react': specifier: ^19.2.17 version: 19.2.17 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.4 + version: 6.0.4(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitnode/config': specifier: workspace:* version: link:../../packages/config eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + postgres: + specifier: ^3.4.9 + version: 3.4.9 tsc-alias: specifier: ^1.9.1 version: 1.9.1 typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) plugins/example: dependencies: @@ -940,10 +958,6 @@ packages: resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} engines: {node: '>=22'} - '@alcalzone/ansi-tokenize@0.3.0': - resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} - engines: {node: '>=18'} - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -4977,10 +4991,6 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -5075,10 +5085,6 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} - auto-bind@5.0.1: - resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -5288,14 +5294,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - cli-boxes@4.0.1: - resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} - engines: {node: '>=18.20 <19 || >=20.10'} - - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -5308,10 +5306,6 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} - cli-truncate@6.1.1: - resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} - engines: {node: '>=22'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -5344,10 +5338,6 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} - code-excerpt@4.0.0: - resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -5445,10 +5435,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convert-to-spaces@2.0.1: - resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -5655,8 +5641,8 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - deslop-js@0.9.3: - resolution: {integrity: sha512-sJcR5LLnEG+w58Oy5CdZfwAfm8XiERbXp9c941Aoeb8JmBHk/56TbZQlLJseJtClg0dkn88wpmnI82+iF0jagg==} + deslop-js@0.9.11: + resolution: {integrity: sha512-0hXU8GImv3uJZY25jeXQ1JOcajpuKyzdNTK7FTyxyGR/GtjpGPmiVM+8d+5Tacb702UzVtAHhB5xMBl/pOGxKQ==} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} @@ -5872,10 +5858,6 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -5953,10 +5935,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -6710,33 +6688,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ink-spinner@5.0.0: - resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} - engines: {node: '>=14.16'} - peerDependencies: - ink: '>=4.0.0' - react: '>=18.0.0' - - ink@7.1.1: - resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} - engines: {node: '>=22'} - peerDependencies: - '@types/react': '>=19.2.0' - react: '>=19.2.0' - react-devtools-core: '>=6.1.2' - peerDependenciesMeta: - '@types/react': - optional: true - react-devtools-core: - optional: true - inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -6841,10 +6795,6 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -6856,11 +6806,6 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-in-ci@2.0.0: - resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} - engines: {node: '>=20'} - hasBin: true - is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -7866,8 +7811,8 @@ packages: oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - oxlint-plugin-react-doctor@0.9.3: - resolution: {integrity: sha512-7XDOw+zjVquh0yqX3zvGQC2zzWIp2rXyMjLDqFzzQ8t7TZXfIQ6ttQhBwckQSk4c7kD7Cy9If0F4LI4XXI4Gsg==} + oxlint-plugin-react-doctor@0.9.11: + resolution: {integrity: sha512-ZhW15wfFjQlUwAO6zG3jVZKse4/PBTCArhbibiidHmTlvOpPHPM1AjdYG13023pfsbbtVdy7Tb7KHfqbKt8rHg==} engines: {node: ^20.19.0 || >=22.13.0} oxlint@1.76.0: @@ -7946,10 +7891,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - patch-console@2.0.0: - resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -8272,8 +8213,8 @@ packages: '@types/react': optional: true - react-doctor@0.9.3: - resolution: {integrity: sha512-s8kWwfFKZA3e9AT5wwFilQNjxKFP30ceFIXOZDrmLPstFodbHOpE0Mv+fEY0/04uZbhRdVYKoaHYb+yaAIEwPw==} + react-doctor@0.9.11: + resolution: {integrity: sha512-y5DQ+ILL6mawXpKtAvJYrrqznl6nasXd4Xs9JGJsY4GWlplzs5UCozKaZgFS5AWzZ7KNrX88GZTuV4Vj47k+IQ==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -8308,12 +8249,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-reconciler@0.33.0: - resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} - engines: {node: '>=0.10.0'} - peerDependencies: - react: ^19.2.0 - react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: @@ -8385,10 +8320,6 @@ packages: react: '*' react-dom: '*' - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} - engines: {node: '>=0.10.0'} - react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -8529,10 +8460,6 @@ packages: resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==} engines: {node: '>=20'} - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -8731,10 +8658,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - slice-ansi@9.0.0: - resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} - engines: {node: '>=22'} - socket.io-adapter@2.5.8: resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} @@ -8785,10 +8708,6 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -8973,10 +8892,6 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - terminal-size@4.0.1: - resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} - engines: {node: '>=18'} - text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -9515,18 +9430,10 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@6.0.0: - resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} - engines: {node: '>=20'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} - engines: {node: '>=20'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -9667,11 +9574,6 @@ snapshots: dependencies: json-schema: 0.4.0 - '@alcalzone/ansi-tokenize@0.3.0': - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - '@alloc/quick-lru@5.2.0': {} '@apm-js-collab/code-transformer-bundler-plugins@0.7.2': @@ -12917,6 +12819,13 @@ snapshots: optionalDependencies: babel-plugin-react-compiler: 1.0.0 + '@vitejs/plugin-react@6.0.4(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + optionalDependencies: + babel-plugin-react-compiler: 1.0.0 + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -13183,10 +13092,6 @@ snapshots: ansi-colors@4.1.3: {} - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -13289,8 +13194,6 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - auto-bind@5.0.1: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -13486,12 +13389,6 @@ snapshots: dependencies: clsx: 2.1.1 - cli-boxes@4.0.1: {} - - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -13500,11 +13397,6 @@ snapshots: cli-spinners@3.4.0: {} - cli-truncate@6.1.1: - dependencies: - slice-ansi: 9.0.0 - string-width: 8.2.2 - cli-width@4.1.0: {} client-only@0.0.1: {} @@ -13535,10 +13427,6 @@ snapshots: code-block-writer@13.0.3: {} - code-excerpt@4.0.0: - dependencies: - convert-to-spaces: 2.0.1 - collapse-white-space@2.1.0: {} comma-separated-tokens@2.0.3: {} @@ -13619,8 +13507,6 @@ snapshots: convert-source-map@2.0.0: {} - convert-to-spaces@2.0.1: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -13801,7 +13687,7 @@ snapshots: dequal@2.0.3: {} - deslop-js@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + deslop-js@0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.142.0 fast-glob: 3.3.3 @@ -13938,8 +13824,6 @@ snapshots: env-paths@3.0.0: {} - environment@1.1.0: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -14174,8 +14058,6 @@ snapshots: escape-html@1.0.3: {} - escape-string-regexp@2.0.0: {} - escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -15139,50 +15021,8 @@ snapshots: imurmurhash@0.1.4: {} - indent-string@5.0.0: {} - inherits@2.0.4: {} - ink-spinner@5.0.0(ink@7.1.1(@types/react@19.2.17)(react@19.2.8))(react@19.2.5): - dependencies: - cli-spinners: 2.9.2 - ink: 7.1.1(@types/react@19.2.17)(react@19.2.5) - react: 19.2.5 - - ink@7.1.1(@types/react@19.2.17)(react@19.2.5): - dependencies: - '@alcalzone/ansi-tokenize': 0.3.0 - ansi-escapes: 7.3.0 - ansi-styles: 6.2.3 - auto-bind: 5.0.1 - chalk: 5.6.2 - cli-boxes: 4.0.1 - cli-cursor: 4.0.0 - cli-truncate: 6.1.1 - code-excerpt: 4.0.0 - es-toolkit: 1.49.0 - indent-string: 5.0.0 - is-in-ci: 2.0.0 - patch-console: 2.0.0 - react: 19.2.5 - react-reconciler: 0.33.0(react@19.2.5) - scheduler: 0.27.0 - signal-exit: 3.0.7 - slice-ansi: 9.0.0 - stack-utils: 2.0.6 - string-width: 8.2.2 - terminal-size: 4.0.1 - type-fest: 5.8.0 - widest-line: 6.0.0 - wrap-ansi: 10.0.0 - ws: 8.21.1 - yoga-layout: 3.2.1 - optionalDependencies: - '@types/react': 19.2.17 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - inline-style-parser@0.2.7: {} inline-style-prefixer@7.0.1: @@ -15292,10 +15132,6 @@ snapshots: dependencies: call-bound: 1.0.4 - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.6.0 - is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -15310,8 +15146,6 @@ snapshots: is-hexadecimal@2.0.1: {} - is-in-ci@2.0.0: {} - is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -16535,12 +16369,13 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxlint-plugin-react-doctor@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + oxlint-plugin-react-doctor@0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@shaderfrog/glsl-parser': 7.0.1 '@typescript-eslint/types': 8.65.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 + lightningcss: 1.33.0 oxc-parser: 0.142.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' @@ -16634,8 +16469,6 @@ snapshots: parseurl@1.3.3: {} - patch-console@2.0.0: {} - path-browserify@1.0.1: {} path-exists@3.0.0: {} @@ -16890,7 +16723,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-doctor@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)): + react-doctor@0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0)): dependencies: '@astrojs/compiler': 4.0.0 '@babel/code-frame': 7.29.7 @@ -16898,35 +16731,29 @@ snapshots: agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + deslop-js: 0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) eslint-plugin-react-hooks: 7.1.1(eslint@10.7.0(jiti@2.7.0)) figures: 6.1.0 - ink: 7.1.1(@types/react@19.2.17)(react@19.2.5) - ink-spinner: 5.0.0(ink@7.1.1(@types/react@19.2.17)(react@19.2.8))(react@19.2.5) jiti: 2.7.0 magicast: 0.5.3 oxc-resolver: 11.24.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) oxlint: 1.76.0 - oxlint-plugin-react-doctor: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxlint-plugin-react-doctor: 0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) prompts: 2.4.2 - react: 19.2.5 typescript: 5.9.3 vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.1.0 yaml: 2.9.0 + yoga-layout: 3.2.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' - - '@types/react' - - bufferutil - eslint - oxlint-tsgolint - - react-devtools-core - supports-color - - utf-8-validate - vite-plus react-dom@19.2.8(react@19.2.8): @@ -16978,11 +16805,6 @@ snapshots: react-is@17.0.2: {} - react-reconciler@0.33.0(react@19.2.5): - dependencies: - react: 19.2.5 - scheduler: 0.27.0 - react-redux@9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -17016,7 +16838,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-scan@0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + react-scan@0.5.7(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(esbuild@0.27.7)(eslint@10.7.0(jiti@2.7.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 '@babel/types': 7.29.7 @@ -17028,7 +16850,7 @@ snapshots: preact: 10.29.7 prompts: 2.4.2 react: 19.2.8 - react-doctor: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)) + react-doctor: 0.9.11(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0)) react-dom: 19.2.8(react@19.2.8) react-grab: 0.1.50(react@19.2.8) optionalDependencies: @@ -17041,18 +16863,14 @@ snapshots: - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' - '@rspack/core' - - '@types/react' - - bufferutil - bun-types-no-globals - eslint - oxlint-tsgolint - preact-render-to-string - - react-devtools-core - rolldown - rollup - supports-color - unloader - - utf-8-validate - vite - vite-plus - webpack @@ -17089,8 +16907,6 @@ snapshots: ts-easing: 0.2.0 tslib: 2.8.1 - react@19.2.5: {} - react@19.2.8: {} readdirp@3.6.0: @@ -17296,11 +17112,6 @@ snapshots: dependencies: lowercase-keys: 3.0.0 - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -17662,11 +17473,6 @@ snapshots: slash@3.0.0: {} - slice-ansi@9.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - socket.io-adapter@2.5.8: dependencies: debug: 4.4.3 @@ -17729,10 +17535,6 @@ snapshots: dependencies: stackframe: 1.3.4 - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} stackframe@1.3.4: {} @@ -17928,8 +17730,6 @@ snapshots: - bare-abort-controller - react-native-b4a - terminal-size@4.0.1: {} - text-decoder@1.2.7: dependencies: b4a: 1.8.1 @@ -18526,18 +18326,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@6.0.0: - dependencies: - string-width: 8.2.2 - word-wrap@1.2.5: {} - wrap-ansi@10.0.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.2 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 From 606323699f5fec123cc019b021938f24f56aadd0 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 11:21:45 +0200 Subject: [PATCH 112/123] fix(blog): name categories in the article's picker A relation picker takes its labels from the target's `admin.titleField`, which has to be a shared column - and every text field on a blog category is localized, so there is none. The generic picker fell back to identifiers, and `#3` is not a category anybody recognises. The label, and only the label, now comes from the generated admin list route with `?locale=`, which already returns each row's translation. The relation is untouched: a real foreign key, a real `onDelete: "restrict"`, validated by the generated schemas, and the combobox stores the same identifier it always did. Co-Authored-By: Claude Opus 5 (1M context) --- apps/docs/content/docs/guides/blog.mdx | 8 +++ apps/docs/src/locales/@vitnode/blog/pl.json | 3 + plugins/blog/src/config.test-d.ts | 5 +- plugins/blog/src/config.tsx | 4 ++ plugins/blog/src/locales/en.json | 3 + .../views/admin/article/category-field.tsx | 41 ++++++++++++++ .../admin/article/category-options.server.ts | 56 +++++++++++++++++++ 7 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 plugins/blog/src/views/admin/article/category-field.tsx create mode 100644 plugins/blog/src/views/admin/article/category-options.server.ts diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 3bbfe4b21..6619cd966 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -140,6 +140,14 @@ The colour is the AdminCP's own picker through a [field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), and the colour column is a swatch **plus** the value in words. + + Every text field on a category is localized, so there is no shared column that + could honestly be its name - left undefined, the engine would pick `color`, and + "#3260c0 has been deleted" is not a sentence anybody wants to read. The + article's category picker gets its labels from a small field override for the + same reason; the relation itself is entirely the engine's. + + ### Articles - the rich example Page create and edit, a custom layout, `AutoFormEditor` for the body, a native diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index e3eb86744..b20a52021 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -37,6 +37,9 @@ "title": "Ustawienia artykułu", "locale_desc": "Adres i metadane wersji w tym języku." } + }, + "category": { + "label": "Kategoria" } }, "category": { diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts index a572874f4..226db0286 100644 --- a/plugins/blog/src/config.test-d.ts +++ b/plugins/blog/src/config.test-d.ts @@ -21,7 +21,10 @@ describe("blog content admin registration", () => { contentTypeAdmin({ definition: blogPostContentType, - fields: { content: { component: () => null } }, + fields: { + categoryId: { component: () => null }, + content: { component: () => null }, + }, forms: { layout: () => null }, }); }); diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index bed13177d..0227160f8 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -4,6 +4,7 @@ import { ListIcon, NotebookPenIcon } from "lucide-react"; import { CONFIG_PLUGIN } from "@/const"; import { blogCategoryContentType } from "@/content/category"; import { blogPostContentType } from "@/content/post"; +import { BlogArticleCategoryField } from "@/views/admin/article/category-field"; import { BlogArticleEditorField } from "@/views/admin/article/editor-field"; import { BlogArticleFormLayout } from "@/views/admin/article/form-layout"; import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; @@ -34,6 +35,9 @@ export const blogPlugin = () => { fields: { // The Tiptap editor, inside the same AutoForm as everything else. content: { component: BlogArticleEditorField }, + // A label override, so the picker names categories rather than + // numbering them - the relation itself is still the engine's. + categoryId: { component: BlogArticleCategoryField }, }, forms: { // One layout for both actions - they are the same screen, and writing diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index fac507f8a..20168c9bf 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -37,6 +37,9 @@ "title": "Article settings", "locale_desc": "The address and metadata of this language's version." } + }, + "category": { + "label": "Category" } }, "category": { diff --git a/plugins/blog/src/views/admin/article/category-field.tsx b/plugins/blog/src/views/admin/article/category-field.tsx new file mode 100644 index 000000000..0012141b8 --- /dev/null +++ b/plugins/blog/src/views/admin/article/category-field.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; + +import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; +import { useLocale, useTranslations } from "next-intl"; + +import { loadBlogCategoryOptions } from "./category-options.server"; + +/** + * The category picker, labelled in the editor's own language. + * + * A **label** override and nothing more: the value it stores is the identifier + * the generated API takes, and the relation - the foreign key, the required + * check, the refusal to delete a category that still has articles - is entirely + * the Content Engine's. See `category-options.server.ts` for why the generated + * picker cannot name a localized target by itself. + */ +export const BlogArticleCategoryField = (props: ItemAutoFormComponentProps) => { + const t = useTranslations("@vitnode/blog.admin.article"); + const locale = useLocale(); + + return ( + { + const options = await loadBlogCategoryOptions(locale); + const term = search.trim().toLowerCase(); + + // Filtered here rather than by the route: the list route searches shared + // columns, and a category's name is not one - it is on the translation + // table. A blog has tens of categories, not thousands. + return term === "" + ? options + : options.filter(option => option.label.toLowerCase().includes(term)); + }} + id="categoryId" + label={t("category.label")} + {...props} + /> + ); +}; diff --git a/plugins/blog/src/views/admin/article/category-options.server.ts b/plugins/blog/src/views/admin/article/category-options.server.ts new file mode 100644 index 000000000..148109fb8 --- /dev/null +++ b/plugins/blog/src/views/admin/article/category-options.server.ts @@ -0,0 +1,56 @@ +"use server"; + +import { contentApiFetch } from "@vitnode/core/content/admin/fetch.server"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; +import { blogCategoryContentType } from "@/content/category"; + +const zodCategories = z.object({ + edges: z.array( + z + .object({ + id: z.number(), + translation: z.object({ title: z.string() }).nullable().optional(), + }) + .loose(), + ), +}); + +/** + * Categories, named in the language the editor is working in. + * + * The Content Engine resolves a relation picker's labels from the target's + * `admin.titleField`, which has to be a **shared** column - and every text field + * on a blog category is localized, so there is no shared column that could + * honestly be its name. The generic picker therefore falls back to identifiers, + * and `#3` is not a category anybody recognises. + * + * So the label - and only the label - is resolved here, from the generated admin + * list route the engine already publishes: `?locale=` makes it return each row's + * translation in that language. The relation itself is entirely the engine's: a + * real foreign key, a real `onDelete: "restrict"`, validated by the generated + * create and update schemas. What the combobox stores is the identifier the API + * takes, exactly as the generated picker would have stored it. + * + * The route is gated by the category's own `can_view`, so this exposes nothing a + * relation picker did not already show. + */ +export const loadBlogCategoryOptions = async ( + locale: string, +): Promise<{ label: string; value: string }[]> => { + const result = await contentApiFetch({ + definition: blogCategoryContentType, + method: "get", + pluginId: CONFIG_PLUGIN.pluginId, + query: { first: "100", locale }, + schema: zodCategories, + }); + + return (result.data?.edges ?? []).map(edge => ({ + // A category with no translation in this language is still selectable - + // hiding it would make an article unassignable for the wrong reason. + label: edge.translation?.title ?? `#${edge.id}`, + value: edge.id.toString(), + })); +}; From b2c8c84618f01ad10773212a27e85d54d0a276aa Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 12:49:47 +0200 Subject: [PATCH 113/123] Revert "fix(blog): name categories in the article's picker" This reverts commit 606323699. The override reached a server action, and `config.tsx` cannot: the `vitnode` CLI loads it with jiti to enumerate a plugin's routes and messages, so anything `server-only` in its static graph throws - which broke `vitnode init` outright, before the first migration ran. That is the constraint core's own overrides already respect, and the reason `ContentField` is handed a `loadOptions` callback rather than importing one. A lazy import would have hidden the trap rather than removed it. So the limitation stands and is written down instead: a relation label comes from a **shared** column on the target, and a localized content type has none, so the article's category picker labels its options `#3`. Resolving a label from the translation table is a Content Engine change, not a plugin workaround. Co-Authored-By: Claude Opus 5 (1M context) --- .../dev/content-engine/overriding-admincp.mdx | 8 +++ apps/docs/content/docs/guides/blog.mdx | 16 ++++-- .../(vitnode-blog)/blog/categories/page.tsx | 3 +- .../(vitnode-blog)/blog/posts/page.tsx | 3 +- .../@breadcrumb/content/[...slug]/page.tsx | 56 +++++++++++++++++-- apps/docs/src/locales/@vitnode/blog/pl.json | 3 - plugins/blog/src/config.test-d.ts | 5 +- plugins/blog/src/config.tsx | 4 -- plugins/blog/src/locales/en.json | 3 - .../views/admin/article/category-field.tsx | 41 -------------- .../admin/article/category-options.server.ts | 56 ------------------- 11 files changed, 74 insertions(+), 124 deletions(-) delete mode 100644 plugins/blog/src/views/admin/article/category-field.tsx delete mode 100644 plugins/blog/src/views/admin/article/category-options.server.ts diff --git a/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx index 7b78e7811..b7bd27d5a 100644 --- a/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/overriding-admincp.mdx @@ -62,6 +62,14 @@ contentTypeAdmin({ The override receives the same props the generated input would, so the field stays wired into `AutoForm`'s validation and error display. + + `config.tsx` is loaded by the `vitnode` CLI to enumerate a plugin's routes and + messages, so **anything `server-only` reachable from it breaks `vitnode init`** + - including a `"use server"` module a field override imports. Server data + reaches an override the way the generated inputs get it: through the props the + Content Engine already passes, such as `ContentField`'s `loadOptions`. + + `config.tsx` is a server module, so an inline arrow written there is a server closure and cannot be handed to the client form. Put the component in its own diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 6619cd966..4ffe49884 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -140,12 +140,16 @@ The colour is the AdminCP's own picker through a [field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), and the colour column is a swatch **plus** the value in words. - - Every text field on a category is localized, so there is no shared column that - could honestly be its name - left undefined, the engine would pick `color`, and - "#3260c0 has been deleted" is not a sentence anybody wants to read. The - article's category picker gets its labels from a small field override for the - same reason; the relation itself is entirely the engine's. + + `titleField` is `null` because every text field on a category is localized - + left undefined, the engine would pick `color`, and "#3260c0 has been deleted" + is not a sentence anybody wants to read. The consequence is that the article's + category picker labels its options `#3` rather than "Engineering": a relation + label is resolved from a **shared** column on the target, and a localized + content type has none. Resolving one from the translation table is a Content + Engine change, not something a plugin should paper over - `config.tsx` is + loaded by the `vitnode` CLI, so a field override cannot reach a server action + to look the names up itself. ### Articles - the rich example diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx index 81d74e4c9..87b011c5c 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/categories/page.tsx @@ -1,7 +1,8 @@ -import { blogCategoryContentType } from "@vitnode/blog/content/category"; import { contentAdminHref } from "@vitnode/core/content"; import { redirect } from "@vitnode/core/lib/navigation"; +import { blogCategoryContentType } from "@vitnode/blog/content/category"; + /** The address categories used to live at. See the posts page next door. */ export default async function LegacyCategoriesPage() { await redirect(contentAdminHref(blogCategoryContentType.id)); diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx index d381bcf9f..148140681 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-blog)/blog/posts/page.tsx @@ -1,7 +1,8 @@ -import { blogPostContentType } from "@vitnode/blog/content/post"; import { contentAdminHref } from "@vitnode/core/content"; import { redirect } from "@vitnode/core/lib/navigation"; +import { blogPostContentType } from "@vitnode/blog/content/post"; + /** * The address articles used to live at. * diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx index 23c72b508..ef6a13465 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx @@ -1,22 +1,68 @@ +import { getTranslations } from "next-intl/server"; + +import { + CONTENT_ADMIN_CREATE_SEGMENT, + CONTENT_ADMIN_EDIT_SEGMENT, +} from "@vitnode/core/content/const"; +import { contentAdminHref, contentTypeToPath } from "@vitnode/core/content/registry"; import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; import { getContentLabels, - resolveContentType, + resolveContentRoute, } from "@vitnode/core/views/admin/views/content/content-admin-view"; +/** + * The breadcrumb of every generated Content Engine screen. + * + * The list keeps the trail it always had. A create or an edit **page** appends + * one more crumb, labelled from `core.content` with the content type's own + * singular - so it reads "Blog / Articles / Create article" in whatever language + * the AdminCP is in, and "Articles" becomes a link back to the list. + * + * The record id is deliberately **not** a crumb of its own: `/42/` would render + * as a dead "42" between two words, and the page it would point at is the one + * being read. + */ export default async function BreadcrumbSlot({ params, }: { params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - const entry = await resolveContentType(params); - const labels = entry ? await getContentLabels(entry) : undefined; + const route = await resolveContentRoute(params); + const labels = route ? await getContentLabels(route.entry) : undefined; + + if (!route || route.action === "list") { + return ( + + ); + } + + const t = await getTranslations("core.content"); + const { definition } = route.entry; return ( ); } diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index b20a52021..e3eb86744 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -37,9 +37,6 @@ "title": "Ustawienia artykułu", "locale_desc": "Adres i metadane wersji w tym języku." } - }, - "category": { - "label": "Kategoria" } }, "category": { diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts index 226db0286..a572874f4 100644 --- a/plugins/blog/src/config.test-d.ts +++ b/plugins/blog/src/config.test-d.ts @@ -21,10 +21,7 @@ describe("blog content admin registration", () => { contentTypeAdmin({ definition: blogPostContentType, - fields: { - categoryId: { component: () => null }, - content: { component: () => null }, - }, + fields: { content: { component: () => null } }, forms: { layout: () => null }, }); }); diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index 0227160f8..bed13177d 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -4,7 +4,6 @@ import { ListIcon, NotebookPenIcon } from "lucide-react"; import { CONFIG_PLUGIN } from "@/const"; import { blogCategoryContentType } from "@/content/category"; import { blogPostContentType } from "@/content/post"; -import { BlogArticleCategoryField } from "@/views/admin/article/category-field"; import { BlogArticleEditorField } from "@/views/admin/article/editor-field"; import { BlogArticleFormLayout } from "@/views/admin/article/form-layout"; import { BlogCategoryColorCell } from "@/views/admin/category/color-cell"; @@ -35,9 +34,6 @@ export const blogPlugin = () => { fields: { // The Tiptap editor, inside the same AutoForm as everything else. content: { component: BlogArticleEditorField }, - // A label override, so the picker names categories rather than - // numbering them - the relation itself is still the engine's. - categoryId: { component: BlogArticleCategoryField }, }, forms: { // One layout for both actions - they are the same screen, and writing diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 20168c9bf..fac507f8a 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -37,9 +37,6 @@ "title": "Article settings", "locale_desc": "The address and metadata of this language's version." } - }, - "category": { - "label": "Category" } }, "category": { diff --git a/plugins/blog/src/views/admin/article/category-field.tsx b/plugins/blog/src/views/admin/article/category-field.tsx deleted file mode 100644 index 0012141b8..000000000 --- a/plugins/blog/src/views/admin/article/category-field.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import type { ItemAutoFormComponentProps } from "@vitnode/core/components/form/auto-form"; - -import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; -import { useLocale, useTranslations } from "next-intl"; - -import { loadBlogCategoryOptions } from "./category-options.server"; - -/** - * The category picker, labelled in the editor's own language. - * - * A **label** override and nothing more: the value it stores is the identifier - * the generated API takes, and the relation - the foreign key, the required - * check, the refusal to delete a category that still has articles - is entirely - * the Content Engine's. See `category-options.server.ts` for why the generated - * picker cannot name a localized target by itself. - */ -export const BlogArticleCategoryField = (props: ItemAutoFormComponentProps) => { - const t = useTranslations("@vitnode/blog.admin.article"); - const locale = useLocale(); - - return ( - { - const options = await loadBlogCategoryOptions(locale); - const term = search.trim().toLowerCase(); - - // Filtered here rather than by the route: the list route searches shared - // columns, and a category's name is not one - it is on the translation - // table. A blog has tens of categories, not thousands. - return term === "" - ? options - : options.filter(option => option.label.toLowerCase().includes(term)); - }} - id="categoryId" - label={t("category.label")} - {...props} - /> - ); -}; diff --git a/plugins/blog/src/views/admin/article/category-options.server.ts b/plugins/blog/src/views/admin/article/category-options.server.ts deleted file mode 100644 index 148109fb8..000000000 --- a/plugins/blog/src/views/admin/article/category-options.server.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use server"; - -import { contentApiFetch } from "@vitnode/core/content/admin/fetch.server"; -import { z } from "zod"; - -import { CONFIG_PLUGIN } from "@/const"; -import { blogCategoryContentType } from "@/content/category"; - -const zodCategories = z.object({ - edges: z.array( - z - .object({ - id: z.number(), - translation: z.object({ title: z.string() }).nullable().optional(), - }) - .loose(), - ), -}); - -/** - * Categories, named in the language the editor is working in. - * - * The Content Engine resolves a relation picker's labels from the target's - * `admin.titleField`, which has to be a **shared** column - and every text field - * on a blog category is localized, so there is no shared column that could - * honestly be its name. The generic picker therefore falls back to identifiers, - * and `#3` is not a category anybody recognises. - * - * So the label - and only the label - is resolved here, from the generated admin - * list route the engine already publishes: `?locale=` makes it return each row's - * translation in that language. The relation itself is entirely the engine's: a - * real foreign key, a real `onDelete: "restrict"`, validated by the generated - * create and update schemas. What the combobox stores is the identifier the API - * takes, exactly as the generated picker would have stored it. - * - * The route is gated by the category's own `can_view`, so this exposes nothing a - * relation picker did not already show. - */ -export const loadBlogCategoryOptions = async ( - locale: string, -): Promise<{ label: string; value: string }[]> => { - const result = await contentApiFetch({ - definition: blogCategoryContentType, - method: "get", - pluginId: CONFIG_PLUGIN.pluginId, - query: { first: "100", locale }, - schema: zodCategories, - }); - - return (result.data?.edges ?? []).map(edge => ({ - // A category with no translation in this language is still selectable - - // hiding it would make an article unassignable for the wrong reason. - label: edge.translation?.title ?? `#${edge.id}`, - value: edge.id.toString(), - })); -}; From ae447f390b11ef02f58f2c7fe06cef6c3a759523 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 10 Aug 2026 13:23:12 +0200 Subject: [PATCH 114/123] fix(content): keep native button semantics on the page-mode links Base UI's `Button` assumes it renders a real ` diff --git a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx index 75060ab29..61b400bc0 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx @@ -94,6 +94,7 @@ export const EditContentAction = ({ render={ ) : null} diff --git a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx index a144bb628..230cec336 100644 --- a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx +++ b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx @@ -107,7 +107,11 @@ export const ContentCreatePageView = async ({ desc={t("desc", { name: singular })} h1={t("title", { name: singular })} > - @@ -203,7 +207,11 @@ export const ContentEditPageView = async ({ return (
    - From e19dbcd2c1d216c9d18d86d5a33c2a0b505c6bda Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 12 Aug 2026 21:11:19 +0200 Subject: [PATCH 115/123] feat: Move blog plugin to content engine --- apps/api/.env.example | 10 +- apps/docs/.env.example | 10 +- .../dev/content-engine/admin-form-layouts.mdx | 36 +- .../docs/dev/content-engine/admincp.mdx | 32 ++ .../content-engine-security.mdx | 6 +- .../docs/dev/content-engine/editorial.mdx | 9 +- .../docs/dev/content-engine/limitations.mdx | 26 +- .../docs/dev/content-engine/localization.mdx | 48 +- .../dev/content-engine/localized-fields.mdx | 35 +- .../content-engine/localized-public-api.mdx | 3 +- .../dev/content-engine/localized-search.mdx | 17 +- .../docs/dev/content-engine/preview.mdx | 42 +- .../content-engine/translation-editorial.mdx | 173 ++++-- .../content-engine/translation-preview.mdx | 6 +- .../content-engine/translation-revisions.mdx | 14 +- .../content-engine/translation-service.mdx | 4 +- apps/docs/content/docs/dev/index.mdx | 22 +- apps/docs/content/docs/guides/blog.mdx | 65 ++- apps/docs/src/locales/@vitnode/blog/pl.json | 3 +- .../src/api/middlewares/global.middleware.ts | 9 +- .../admin/debug/routes/integrations.route.ts | 6 +- .../vitnode/src/components/form/auto-form.tsx | 9 + .../src/components/form/fields/input.tsx | 29 +- .../components/form/fields/textarea.test.tsx | 194 +++++++ .../src/components/form/fields/textarea.tsx | 97 +++- .../src/components/i18n-provider.test.ts | 86 +++ .../vitnode/src/components/i18n-provider.tsx | 31 +- .../vitnode/src/content/admin/spec.test.ts | 163 +++++- packages/vitnode/src/content/admin/spec.ts | 309 ++++++++-- packages/vitnode/src/content/define.ts | 59 +- packages/vitnode/src/content/index.ts | 4 +- .../src/content/localization.test-d.ts | 57 +- .../vitnode/src/content/localization.test.ts | 59 +- packages/vitnode/src/content/schemas.ts | 11 +- .../src/content/server/editorial-effects.ts | 6 +- .../src/content/server/editorial-service.ts | 89 +++ .../server/localized-admin-routes.test.ts | 525 +++++++++++++++++ .../content/server/localized-admin-routes.ts | 538 ++++++++++++++++++ .../server/localized-preview-routes.test.ts | 1 + packages/vitnode/src/content/server/model.ts | 52 ++ .../src/content/server/openapi-parity.test.ts | 1 + .../content/server/permission-matrix.test.ts | 9 + .../src/content/server/preview-config.test.ts | 142 +++-- .../src/content/server/preview-config.ts | 48 +- packages/vitnode/src/content/server/routes.ts | 18 +- .../translation-advanced-revisions.test.ts | 1 + .../translation-editorial-service.test.ts | 1 + .../server/translation-editorial-service.ts | 26 +- .../src/content/server/translation-model.ts | 27 + .../translation-publication-routes.test.ts | 1 + .../content/server/translation-routes.test.ts | 26 +- .../src/content/server/translation-routes.ts | 14 +- packages/vitnode/src/content/types.ts | 74 ++- packages/vitnode/src/lib/plugin.ts | 14 - packages/vitnode/src/locales/en.json | 12 +- .../content/actions/conflict-notice.test.tsx | 1 + .../views/content/actions/content-form.tsx | 240 +++++++- .../views/content/actions/edit-action.tsx | 50 +- .../actions/history/revision-history.test.tsx | 1 + .../content/actions/mutation-api.server.ts | 152 +++++ .../views/content/actions/page-links.test.tsx | 3 +- .../content/actions/translation-api.server.ts | 55 +- .../content/actions/translations-action.tsx | 120 ++++ .../actions/translations/locale-editor.tsx | 147 ----- .../translations/translation-manager.tsx | 258 +++++++++ .../translations/translation-panel.test.tsx | 126 ---- .../translations/translation-panel.tsx | 481 ---------------- .../views/content/content-admin-view.tsx | 22 +- .../admin/views/content/form/context.tsx | 18 +- .../admin/views/content/form/layout.test.tsx | 11 +- .../admin/views/content/form/primitives.tsx | 2 +- .../views/content/lib/field-component.tsx | 21 +- .../content/lib/localized-fields.test.tsx | 303 ++++++++++ .../views/content/page/content-form-page.tsx | 55 +- .../views/content/page/page-views.test.tsx | 28 +- .../admin/views/content/page/page-views.tsx | 84 +-- .../views/admin/views/content/table/cells.tsx | 35 +- .../content/table/content-table-view.test.tsx | 138 +++-- .../content/table/content-table-view.tsx | 207 +++---- .../views/content/table/locale-selector.tsx | 81 --- plugins/blog/src/content/category.ts | 20 +- .../blog/src/content/content-types.test.ts | 63 +- plugins/blog/src/content/post.ts | 17 +- plugins/blog/src/locales/en.json | 3 +- .../src/views/admin/article/editor-field.tsx | 5 + .../views/admin/article/form-layout.test.tsx | 52 +- .../src/views/admin/article/form-layout.tsx | 20 +- .../src/database/advanced-postgres.test.ts | 5 +- .../src/database/concurrency-postgres.test.ts | 137 +++++ .../src/database/delivery-postgres.test.ts | 106 +++- plugins/example/src/database/postgres.test.ts | 308 ++++++++++ 91 files changed, 4939 insertions(+), 1745 deletions(-) create mode 100644 packages/vitnode/src/components/form/fields/textarea.test.tsx create mode 100644 packages/vitnode/src/components/i18n-provider.test.ts create mode 100644 packages/vitnode/src/content/server/localized-admin-routes.test.ts create mode 100644 packages/vitnode/src/content/server/localized-admin-routes.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations-action.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-manager.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.test.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/localized-fields.test.tsx delete mode 100644 packages/vitnode/src/views/admin/views/content/table/locale-selector.tsx diff --git a/apps/api/.env.example b/apps/api/.env.example index af3fdeccf..9ab8297a6 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,12 +9,12 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === +# === Content Preview Secret (optional) === # 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. +# without an account. Optional: leave it unset and the API boots normally with +# preview switched off - minting a link answers 503 naming this variable, and +# opening one answers 404. Set at least 32 random bytes to switch it on, because +# the signature is the *only* access control those links have. # # openssl rand -base64 32 # diff --git a/apps/docs/.env.example b/apps/docs/.env.example index 726860ed7..7e09103d8 100644 --- a/apps/docs/.env.example +++ b/apps/docs/.env.example @@ -7,12 +7,12 @@ NEXT_PUBLIC_WEB_URL=http://localhost:3000 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key -# === Content Preview Secret === +# === Content Preview Secret (optional) === # 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. +# without an account. Optional: leave it unset and the API boots normally with +# preview switched off - minting a link answers 503 naming this variable, and +# opening one answers 404. Set at least 32 random bytes to switch it on, because +# the signature is the *only* access control those links have. # # openssl rand -base64 32 # diff --git a/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx index acf8ff8f3..19f28d053 100644 --- a/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx +++ b/apps/docs/content/docs/dev/content-engine/admin-form-layouts.mdx @@ -172,36 +172,40 @@ belongs to wherever that input ended up. | Primitive | What it does | | --- | --- | -| `ContentFormField` | One field, by name. Nothing if this surface has no such field | +| `ContentFormField` | One field, by name. Nothing if the form has no such field | | `ContentFormRemainingFields` | Everything the layout did not name | | `ContentFormActions` | The submit row, with an optional `cancelHref` | | `ContentFormStatus` | The read-only publication line | | `ContentFormLayoutGrid` / `Main` / `Sidebar` / `Section` | AdminCP chrome: two columns above `lg`, one below | -| `useContentForm()` | `mode`, `surface`, `fieldNames`, `publication` | +| `useContentForm()` | `mode`, `fieldNames`, `localizedFieldNames`, `publication` | -### Localized content types get one layout, not two +### Localized content types get one layout, and one form -A localized content type splits its fields across two surfaces - the shared tab -and one tab per language - and the same layout is rendered in both. -`ContentFormField` renders **nothing** for a name the current surface does not -have, which is what lets a single file place `title` and `categoryId` without -knowing which table either lives on: +A layout places every field of the content type in one screen, localized or not: -```text -Shared tab → categoryId, authorId in the sidebar -English tab → title, content in the main column; friendlyUrl in the sidebar +```tsx + {/* translation table */} + {/* translation table */} + {/* translation table */} + {/* base table */} + {/* base table */} ``` -`useContentForm().surface` is `"shared"` or `"translation"` when a layout wants -to word something differently. +Nothing here says which is which, and nothing needs to: **a localized field +renders its own language control automatically**, and a shared one does not. The +layout decides where a field appears; the Content Engine decides where its value +goes. + +`useContentForm().localizedFieldNames` is there for a layout that wants to group +or annotate them - it is never needed to *place* one. ### The server/client boundary `config.tsx` is a **server** module, so a layout referenced from it is a client reference crossing an RSC boundary. That decides the shape of the whole API: -- The layout receives only **serialisable** props: `mode`, `surface`, - `contentTypeId`, `pluginId`, `itemId`, `singular`, `publication`, `title`. +- The layout receives only **serialisable** props: `mode`, `contentTypeId`, + `pluginId`, `itemId`, `singular`, `publication`, `title`. - Field elements, the form instance and the submit action arrive through **client context** instead. A `renderField(name)` callback prop would read well and would be a server closure, which cannot cross the boundary at all. @@ -210,7 +214,7 @@ So: `"use client"` at the top of the layout file, and no inline arrow in `config.tsx`. - In development, a layout that never renders one of its surface's fields logs + In development, a layout that never renders one of the form's fields logs which ones - a field silently missing from the payload is the one failure mode this API has that the generated form does not. diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx index 5cee7e76a..db1719bb9 100644 --- a/apps/docs/content/docs/dev/content-engine/admincp.mdx +++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx @@ -34,6 +34,9 @@ You get a nav item, a breadcrumb, and a screen at: ([below](#dialog-or-page)) - **Delete** - a confirmation dialog - **History** - with [`editorial`](#editorial): every version, a diff, and restore +- **Languages** - with [`localization`](/docs/dev/content-engine/translation-editorial): + the list and the form open in *your* VitNode language, and each translated + field carries its own switcher ([below](#localized-content-types)) - **Empty, loading and error states** - out of the box ## What "lazy-loaded on open" actually means @@ -359,3 +362,32 @@ type's own `can_view`. The page checks `can_view` server-side and 404s without it. The create, edit and delete controls check their own permissions client-side - and the routes behind them check again, which is the check that actually matters. + +## Localized content types + +A localized content type gets **no extra screen and no extra control**. There is +no `Shared | English | Polish` strip and no locale in the URL: + +- the **list** shows each record in the language you are reading VitNode in, and + `Missing` where a translation does not exist yet; +- the **form** shows every field at once, and each localized one carries its own + small language switcher: + +```text +Title [ Tytuł artykułu ] [ PL ▾ ] +Content [ Treść… ] [ PL ▾ ] +Friendly URL [ tytul-artykulu ] [ PL ▾ ] +Category Aktualności +Author Maciej +``` + +Switching `Title` to English leaves the others in Polish - there is no +form-global language. One Save writes the base row and every changed language in +one transaction. + +Per-language status, publication, history and delete live in a separate +**Languages** row action, because the language is part of *that* decision rather +than a mode the whole screen is in. + +The whole thing is described in [Localized +editing](/docs/dev/content-engine/translation-editorial#the-admincp). diff --git a/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx index dc93da8cb..a76f3f306 100644 --- a/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx @@ -54,12 +54,14 @@ Two choices in there are worth the sentence they take: which is what makes "writes Polish and nothing else" expressible. Give a role `can_view + can_translate` and it can: -- read the record, every locale tab and every locale's history; +- read the record, every language and every language's history; - create and edit a translation in any enabled locale. It cannot: -- edit a shared field (`PUT /{id}` is `can_edit`); +- edit a shared field - `PUT /{id}` is `can_edit`, and the AdminCP's composite + `PUT /{id}/localized` re-checks `can_edit` in the handler the moment its payload + carries one, so reaching the form grants nothing; - publish or unpublish anything, record or translation (`can_publish`); - restore a shared revision *or* a locale's own (`can_restore`, which needs `can_edit`); diff --git a/apps/docs/content/docs/dev/content-engine/editorial.mdx b/apps/docs/content/docs/dev/content-engine/editorial.mdx index 5626c4fac..4c3542f55 100644 --- a/apps/docs/content/docs/dev/content-engine/editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/editorial.mdx @@ -91,10 +91,11 @@ editorial: { 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). + At least 32 random bytes - `openssl rand -base64 32`. The variable itself is + optional and the API boots without it, but the signature is the only access + control a preview link has, so until you set one preview fails closed: a + warning at boot, a 503 from the button. See + [Secure preview](/docs/dev/content-engine/preview#content_preview_secret-is-optional). ## `version` is generated, so you cannot declare it diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index da8342567..746745660 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -47,8 +47,8 @@ generated one without friction. [`localization`](/docs/dev/content-engine/localization) generates the tables, the types, the schemas, the services, the per-locale lifecycle, the per-locale history -and the AdminCP locale tabs. What it deliberately refuses is every combination -whose *reading* half is not built yet: +and the AdminCP's per-field language switchers. What it deliberately refuses is +every combination whose *reading* half is not built yet: Nothing is refused any more. [Locale-aware public reads](/docs/dev/content-engine/localized-public-api) landed @@ -75,17 +75,21 @@ positions depending on the language. Order by a column the record has one of. `filterableFields` and `searchableFields` *may* name a localized field: both are evaluated against the single translation the reader is being served. -## Localized field names cannot appear on base-table surfaces +## A localized field can be shown, but not queried A localized field has no column on the base table, so it cannot be an -`admin.list` column, an `orderableFields` or `searchableFields` entry, a -`form.fields` entry, `admin.titleField`, or part of an `indexes` declaration. All -six are compile errors and runtime errors. - -`admin.titleField` therefore falls back to `null` on a content type whose only -text fields are localized. The locale tabs show the localized title inside each -tab, and the list's language selector adds a column showing each record's title -in the language being viewed. +`orderableFields` or `searchableFields` entry, `admin.list.defaultOrderBy`, part +of an `indexes` declaration, or a key in `schemas.create`/`update`/`select`. All +of those are SQL over the base row, and every one of them is a compile error and +a runtime error. + +It *may* be an `admin.list` column, `admin.titleField` and an `admin.form.fields` +entry, because those are presentation: the AdminCP resolves the value from the +one translation it already loaded for the reader's own language. + +The practical consequence is that a localized column is **displayed but not +sortable**. Nothing about the base-table ordering guarantees changes; there is +simply no header control on that column. ## Foreign key names on a long translation table are truncated by Postgres diff --git a/apps/docs/content/docs/dev/content-engine/localization.mdx b/apps/docs/content/docs/dev/content-engine/localization.mdx index 53e99abeb..9739d4790 100644 --- a/apps/docs/content/docs/dev/content-engine/localization.mdx +++ b/apps/docs/content/docs/dev/content-engine/localization.mdx @@ -144,20 +144,27 @@ What lands where: | Every shared field | Every localized field | | | `version`, `createdAt`, `updatedAt` | -A localized field is **not** a column on the base table, which has consequences -worth knowing up front: +A localized field is **not** a column on the base table, and the engine draws a +line between *showing* one and *querying* one. -- it cannot appear in `admin.list.columns`, `orderableFields`, `searchableFields` - or `form.fields`, -- it cannot be `admin.titleField`, -- it cannot appear in `indexes`, -- it is absent from `schemas.create`, `schemas.update` and `schemas.select`. +**Showing is fine.** A localized field may appear in `admin.list.columns`, may be +`admin.titleField`, and always appears in `admin.form.fields`. The AdminCP +resolves it in the language the reader is already using VitNode in, and its form +input carries its own language switcher - see +[Localized editing](/docs/dev/content-engine/translation-editorial). -All five are compile errors *and* runtime errors: there is nowhere on the base -form or in a base-table query for them to go, and a silently-dropped title is -worse than a refused definition. Localized values have their own AdminCP surface - -the [locale tabs](/docs/dev/content-engine/translation-editorial) in the edit -dialog, and the language selector on the list. +**Querying is not.** A localized field cannot appear in: + +- `admin.list.orderableFields` or `admin.list.searchableFields`, +- `admin.list.defaultOrderBy`, +- `indexes`, +- `schemas.create`, `schemas.update` or `schemas.select`. + +Those are all SQL over the base table, and the value is not there. A list ordered +by a per-language title would reshuffle itself for every reader and make one +cursor mean two positions at once. Each of them is a compile error *and* a +runtime error, because a silently-dropped ordering is worse than a refused +definition. ## Optimistic locking per locale @@ -199,9 +206,14 @@ const { row, translation } = await localizedService.create({ ``` Either both exist or neither does. That invariant is what every later stage leans -on - a record always resolves in at least one language, so a locale tab strip -always has something to show and a public read always has something to fall back -to. +on - a record always resolves in at least one language, so the AdminCP always has +something to show and a public read always has something to fall back to. + +`localization.defaultLocale` is a **storage and fallback** rule, not a display +one. It decides which translation must exist, and which one a public reader falls +back to. It does not decide which language an editor sees first: that is their +own VitNode language. See [Localized +editing](/docs/dev/content-engine/translation-editorial#two-different-languages). Two rules protect it: @@ -313,9 +325,9 @@ answer to the same slug. | Stage | What it adds | | --- | --- | | **5A** | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations | -| **5B** | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, AdminCP locale tabs | +| **5B** | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, per-field language switchers in the AdminCP | | **5C** | Locale-aware public API, locale precedence, fallback resolution, strict-locale slugs, locale-aware cache tags, locale preview links | -| **5D** (this one) | Per-locale search documents, the localized rebuild, per-language diagnostics, the AdminCP list language selector | +| **5D** (this one) | Per-locale search documents, the localized rebuild, per-language diagnostics, the AdminCP list in the reader's own language | Explicitly outside all four: locale-specific relations, localized media, AI translation, translation memory, external TMS integration, `hreflang` and sitemap @@ -327,7 +339,7 @@ Content Engine. - [Localized fields](/docs/dev/content-engine/localized-fields) - which kinds, and why the others are refused - [Translation tables](/docs/dev/content-engine/translation-tables) - the generated schema, keys and indexes - [Translation service](/docs/dev/content-engine/translation-service) - every method, and every conflict it can raise -- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - per-locale publish, the subordination rule, permissions and the locale tabs +- [Localized editing](/docs/dev/content-engine/translation-editorial) - the AdminCP's per-field language switchers, per-locale publish, the subordination rule and permissions - [Translation revisions](/docs/dev/content-engine/translation-revisions) - one history per language, and what a restore may not cross - [Locale preview](/docs/dev/content-engine/translation-preview) - freezing one language, both halves of it - [Localization migrations](/docs/dev/content-engine/localization-migrations) - the generated migration, and how to localize an existing content type safely diff --git a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx index 39ff0d978..519859f34 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx @@ -164,28 +164,33 @@ and `ContentLocalizedValues` is `{}` - which is what makes a `translation:` key impossible to fill in by accident on a Stage 1-4 definition, and what keeps every existing type exactly as it was. -## Where a localized field may not appear +## Where a localized field may appear -Everything on this list addresses a column on the *base* table: +The engine draws one line, and it is between **showing** a value and **querying** +one. ```ts admin: { list: { - columns: ["title"], // ✗ - orderableFields: ["title"], // ✗ - searchableFields: ["title"], // ✗ + columns: ["title"], // ✓ shown in the reader's own language + orderableFields: ["title"], // ✗ ORDER BY on the base table + searchableFields: ["title"], // ✗ a predicate on the base row }, - form: { fields: ["title"] }, // ✗ - titleField: "title", // ✗ + form: { fields: ["title"] }, // ✓ one form, with its own language switcher + titleField: "title", // ✓ resolved per reader }, -indexes: [{ on: ["title"] }], // ✗ +indexes: [{ on: ["title"] }], // ✗ no such column to index ``` -All six are compile errors, and all six are runtime errors as well. The defaults -skip localized fields automatically, so a localized content type that says nothing -about `admin.list` gets a sensible shared-only list without having to opt out of -anything. +The refusals are compile errors *and* runtime errors. They are not squeamishness: +a list ordered by a per-language title would reshuffle itself for every reader, +and one cursor would mean two positions at once. -`admin.titleField` falls back to `null` when every text field is localized. A -toast whose wording depended on the reading admin's locale would be worse than no -title at all; Stage 5B gives the AdminCP a locale-aware one. +The **defaults** stay shared-only. A localized content type that says nothing +about `admin.list` gets a shared-only list of columns without opting out of +anything; naming a localized column is a decision you make. + +`admin.titleField` does fall back to a localized field when there is no shared +one, because the alternative was `#123`. The AdminCP resolves it from the +translation it already loaded for the reader's language - see [Localized +editing](/docs/dev/content-engine/translation-editorial#the-admincp). diff --git a/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx index 9831ed686..f6013d50d 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx @@ -113,7 +113,8 @@ cache for free. Fallback is deliberately narrow, and these are the places it does **not** apply: - **Slug lookup.** See below. -- **The AdminCP.** A locale tab shows that locale, or shows `Missing`. +- **The AdminCP.** A localized field shows the language its switcher is on, or + shows nothing; a list cell shows `Missing`. - **Preview.** A [locale preview](/docs/dev/content-engine/translation-preview) is bound to one language and refuses every other. - **History and mutations.** A revision belongs to a locale; a write names one. diff --git a/apps/docs/content/docs/dev/content-engine/localized-search.mdx b/apps/docs/content/docs/dev/content-engine/localized-search.mdx index fbef1d0fa..200a05617 100644 --- a/apps/docs/content/docs/dev/content-engine/localized-search.mdx +++ b/apps/docs/content/docs/dev/content-engine/localized-search.mdx @@ -162,15 +162,14 @@ Stage 5D adds is content that actually has languages to filter on. ## The AdminCP list -A localized content type's list gets a language selector. It is a **view control, -not a filter**: picking Polish adds a column showing each record's Polish title -and status - including `Missing`, which is the row most worth finding. Hiding -untranslated records would be the opposite of what somebody choosing a language is -looking for. - -The choice lives in the URL, so it survives a reload, paginates with the table and -can be sent to whoever is doing the translating. Changing it resets the cursor: -page three of one ordering is not page three of another. +A localized content type's list is shown in the language you are already reading +VitNode in. There is no selector above the table and nothing in the URL: the +AdminCP resolves your locale server-side and asks the list route for it. + +It is a **view, not a filter**. Every record is listed, and one with no +translation in your language shows `Missing` rather than being hidden - that is +the row most worth finding. Sorting and searching still address the base table, +so a localized column is displayed without a sort control. ## No migration diff --git a/apps/docs/content/docs/dev/content-engine/preview.mdx b/apps/docs/content/docs/dev/content-engine/preview.mdx index eb387bbc9..491dd8aca 100644 --- a/apps/docs/content/docs/dev/content-engine/preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/preview.mdx @@ -60,12 +60,15 @@ the honest default: linking to a page nobody has written yet would just be a you cast past it. -## `CONTENT_PREVIEW_SECRET` is required +## `CONTENT_PREVIEW_SECRET` is optional -**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. +**Nothing requires it except preview.** Leave it unset and the API boots exactly +as it would otherwise - an install that never sends anyone a draft link has no +reason to hold a signing key, so this is not a deployment prerequisite. Set one +when you want the feature, and set a real one: this single value is the entire +authorization story - there is no session to fall back on - so a 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 @@ -87,24 +90,19 @@ 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 | +| Boot, in every environment | A warning on stdout naming the content types that wanted it. 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. + + A missing secret switches preview *off*; it never switches it to unsigned. + Refusing to boot would turn one content type's opt-in feature into a + prerequisite for the entire API - and for `next build`, which imports every + route module and so runs this check on a machine that has no business holding a + runtime signing key. Honouring unsigned tokens would serve drafts to anyone who + guesses a URL. So the process starts, the warning says what is missing, and the + 503 says it again to whoever clicks the button. @@ -151,9 +149,9 @@ host. Which origin it resolves against depends on where the link actually points 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. +split deployment breaks. Both are checked at boot when preview is enabled, so a +malformed `NEXT_PUBLIC_WEB_URL` is a startup warning and a 503 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 diff --git a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx index 5881aadff..77acee752 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx @@ -1,12 +1,12 @@ --- -title: Translation lifecycle -description: Each language publishes on its own schedule - and a translation is never public before the record is. +title: Localized editing +description: One form, a language switcher inside each translated field, and a lifecycle each language runs on its own. icon: Languages --- [Localization](/docs/dev/content-engine/localization) gave a record one row per language. This page is about what those rows *do*: a status of their own, a -publish button of their own, and a rule about how the two levels relate. +history of their own, and a rule about how the two levels relate. ```ts title="src/content/article.ts" export const articleContentType = defineContentType({ @@ -51,11 +51,20 @@ That is the whole model. A translation's status is **subordinate**: publishing the Polish copy of a draft article puts nothing on the internet, and unpublishing the article takes every language down at once. - - A record going live exposes the languages that were *already* marked published, - and no others. This is the difference between "we are ready to launch" and "the - Polish copy is finished", and they are rarely the same day. It also means - nobody can ship a half-finished translation by pressing one button. + + Publishing a record moves every translation it has with it, in the record's own + transaction - each through this service, so each takes its delivery address and + records the publish in its own history. Unpublishing takes them all back down. + + Publication is a decision about the *record*, and there is one control for it. + Before this, a record's publish moved only the base row, so a localized article + read as `published` in the AdminCP while every language of it was still a draft: + no canonical URL, no search document, nothing public. Both rows were telling the + truth, about different things, and nobody could see which. + + A language added to an already-published record is published as it is created, + for the same reason - otherwise it would be a language with nothing left to + publish it. ## The states @@ -63,9 +72,14 @@ the article takes every language down at once. | | What it means | | --- | --- | | **Missing** | No translation row for this language. Nothing to publish. | -| **Draft** | A translation exists and is not public. Where every one starts. | +| **Draft** | A translation exists and is not public. Where a language of a draft record starts. | | **Published** | Public - if the base record is published too. | +The per-locale `publish` and `unpublish` below still exist, and are how a single +language is held back from a record that is otherwise live. They are an override, +not the ordinary route: the AdminCP's language dialog reports each language's +status and offers no publish button of its own. + There is deliberately no **Outdated**. Its honest definition is "the source language changed after this translation did", and comparing two `updatedAt` timestamps does not mean that: a typo fix in English would mark every language @@ -178,10 +192,15 @@ if (outcome) { | Delete a non-default translation | `can_delete` | `can_translate` depends on `can_view` and **not** on `can_edit`, which is the -whole point of having it: a translator gets every locale tab without gaining the +whole point of having it: a translator can write any language without gaining the ability to touch a shared field, move the record's global publication state or delete it. +The AdminCP's single Save button posts one composite request, and the split is +enforced on the *server*: the route needs `can_translate`, and it additionally +checks `can_edit` the moment the payload carries a shared field. Nothing is +inferred from whether the browser disabled an input. + Staff permissions are stored as JSON per role, so a new one simply is not on any existing role. Grant it in AdminCP → Staff. @@ -204,7 +223,8 @@ a revision, without it they simply do not. Both take `{ "expectedVersion": 3 }` and answer `{ "changed": true, "row": { … } }`. A stale version comes back as the same structured 409 every translation route uses, with `locale` in every arm - which -is what lets a tab strip point at the right tab rather than at the record. +is what lets the AdminCP say *which language* moved rather than just "the record +changed". Locales are canonical strings on the outside and numeric `core_languages.id` values on the inside. A client never sends an id, so it can never point one at a @@ -212,37 +232,122 @@ language it was not shown. ## The AdminCP -The edit dialog of a localized content type opens on a tab strip: +There is **one form**. No `Shared | English | Polski` strip, no locale in the URL, +and no form-global language state: + +```text +Title [ Tytuł artykułu ] [ PL ▾ ] +Content [ Treść… ] [ PL ▾ ] +Friendly URL [ tytul-artykulu ] [ PL ▾ ] +Category Aktualności +Author Maciej +``` + +Each **localized field** carries its own small language switcher - the same +`multiLang` behaviour VitNode has always used for language-aware inputs. Shared +fields sit beside them with no switcher, because there is nothing to switch. + +### Two different languages + +Two things are called "the language" and they are not the same thing: + +| | What it decides | +| --- | --- | +| **Your VitNode language** | What the AdminCP *shows you first*: the list's titles, and the language every localized input opens in | +| **`localization.defaultLocale`** | Which translation a record cannot exist without, and what a public reader falls back to | + +Reading the AdminCP in Polish opens every localized field on Polish, whatever +`defaultLocale` says. It is the language you are already in; being asked to pick +it again would be a control with one sensible answer. + +If your language is not one the install serves, the field falls back to the first +enabled one rather than writing into a language nothing renders. On a +one-language install no switcher is rendered at all. + +### Switching one field, not the screen + +Switching `Title` to English leaves the body and the URL in Polish. That is +deliberate: comparing one heading against another should not move the whole page. + +```text +Title [ Article title ] [ EN ▾ ] ← switched +Content [ Treść… ] [ PL ▾ ] ← unchanged +Friendly URL [ tytul-artykulu ] [ PL ▾ ] ← unchanged +``` + +Selecting a language whose translation does not exist shows an **empty box**, and +saving writes nothing for it. Looking at a language is not a decision to create a +translation in it. + +### One Save, one transaction + +The form holds every language at once - read in one request when it opens, not +one request per language - and one Save writes all of it: ```text -Shared | English ✓ | Polski ● | Deutsch ○ +BEGIN + update the base row with its own expectedVersion + update the EN translation with its own expectedVersion + create the PL translation +COMMIT ``` -- **Shared** holds the fields that are not per-language, plus the record's global - publication, history and scheduling. -- **Each locale tab** holds that language's fields, its status, its version, its - publish button, its history and - for anything but the default - its delete - button. +Only what actually changed is sent. A Polish-only edit sends no shared values and +no English entry, so the base version, the English version, the English revision +history and the English cache are all left exactly where they were. -The strip loads metadata only, in one request. A language's values are fetched -when its tab is opened, so opening the dialog on a record with nine languages -costs one query rather than nine. +If any part is refused - somebody saved the English copy while you were typing - +**nothing commits**, and the error names the language. -Only languages the app actually serves get a tab: they come from the app config, -already filtered to the enabled ones. And **opening a tab never creates a -translation** - a missing language shows `Missing` and an explicit create button, -because looking is not a decision to publish an empty page. +### Per-language lifecycle -### When somebody else got there first +Status, publication, history, restore and delete are genuinely per-language and +genuinely not fields, so they live in their own row action rather than around the +form: the language is a parameter of *that* decision, not a mode the whole screen +is in. -A stale save keeps the form exactly as you left it and shows a banner naming the -language that moved, with a **Reload this language** button. Nothing is retried -and nothing is merged: reloading is a decision, and so is saving over what the -reload reveals. +Only languages the app actually serves appear: they come from the app config, +already filtered to the enabled ones. The default-locale translation has no +delete button. + +### Field-local languages are not JSON storage + +Worth stating plainly, because the form makes it look otherwise: a field-local +language switcher is a **UI** decision. Nothing about the storage model changed. + +```text +Admin form Storage + +Title [PL ▾] blog_posts +Content [EN ▾] id, categoryId, authorId, status, version +Friendly URL[PL ▾] ──▶ +Category (shared) blog_posts_translations +Author (shared) itemId, languageId, title, friendlyUrl, + content, status, version +``` + +One base row, one translation row per `(itemId, languageId)`, each with its own +`version`, its own `status`, its own `publishedAt` and its own revision history. +The form holds `[{ languageCode, value }]` per field only while you are editing; +the save takes it apart again and writes rows. + +There is no JSON column, and the old `MultiLangValue` persistence model has not +come back. + +### The list + +A localized list shows the record in the language you are reading, with nothing +above the table to choose: + +```text +Name Color +Aktualności ● #3260c0 +Poradniki ● #23a06b +``` -English and Polish edits are two different rows with two different version -counters, so they never conflict with each other - only with another edit of the -*same* language. +A record with no translation in your language shows `Missing` rather than a +blank - that is the row worth spotting. Sorting and searching still address the +base table, so a localized column is displayed but not sortable. ## Stage 5B boundaries @@ -261,5 +366,5 @@ both frozen revisions - see route that mints one landed with Stage 5C. Locale-specific *scheduling* stays outside Stage 5 entirely. A scheduled global -publish exposes the languages already marked published and publishes no drafts; a +publish moves every language the record has with it; a scheduled global unpublish hides every language at once. diff --git a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx index 5cfa3741c..f0dbaca82 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx @@ -119,9 +119,9 @@ AdminCP is already allowed to see it. The link is the credential from there on. It freezes the record's newest **shared** revision and that locale's newest **translation** revision, and returns both ids alongside the link. A locale with -no translation is a 404 rather than a link to the fallback - the button is on a -language tab, and a link that quietly previewed a different language would be -worse than no link. +no translation is a 404 rather than a link to the fallback - the link names one +language, and one that quietly previewed a different one would be worse than no +link. `?locale=` is a query parameter rather than a second placeholder in `editorial.preview.pathTemplate`, and that is deliberate: a new placeholder would diff --git a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx index 0e5ec8a85..f5121974d 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx @@ -196,14 +196,16 @@ demand, one at a time. ## In the AdminCP -Each locale tab has its own **Show this language's history** section, loaded when -it is opened rather than with the tab - a language's history can be long, and -nobody who only wanted to fix a typo should pay for it. Restore is offered on -every version but the current one, and only with `can_restore`. +History is per-language, so it lives in the row's **Languages** action rather than +in the form: each language gets its own **Show this language's history** section, +loaded when it is opened rather than with the dialog - a language's history can be +long, and nobody who only wanted to fix a typo should pay for it. Restore is +offered on every version but the current one, and only with `can_restore`. ## Related -- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - the - per-locale publish/unpublish these revisions record +- [Localized editing](/docs/dev/content-engine/translation-editorial) - the + per-locale publish/unpublish these revisions record, and the form they are + reached from - [Revisions](/docs/dev/content-engine/revisions) - the shared history the base row keeps, and the retention rules both share diff --git a/apps/docs/content/docs/dev/content-engine/translation-service.mdx b/apps/docs/content/docs/dev/content-engine/translation-service.mdx index 24bf54446..34669fc42 100644 --- a/apps/docs/content/docs/dev/content-engine/translation-service.mdx +++ b/apps/docs/content/docs/dev/content-engine/translation-service.mdx @@ -214,8 +214,8 @@ Five distinct outcomes, because a client that cannot tell them apart can only sh | Deleting the default translation | `409` | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | | A localized slug is taken **in this language** | `409` | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | -The version conflict names the locale, which is the one thing a locale tab strip -has to know to reload the right tab: +The version conflict names the locale, which is the one thing the AdminCP needs to +say *which language* somebody else saved: ```json { diff --git a/apps/docs/content/docs/dev/index.mdx b/apps/docs/content/docs/dev/index.mdx index f04aff5ce..96cdad078 100644 --- a/apps/docs/content/docs/dev/index.mdx +++ b/apps/docs/content/docs/dev/index.mdx @@ -8,9 +8,29 @@ icon: Power We're working hard to bring you the best documentation experience. +## Support + +- [Postgres 18-19](https://www.postgresql.org/) (min: v18, recommended: v19) - database support. + +### Supported Package Managers + +- [bun](https://bun.com/) (min: v1.1, recommended: v1.3) +- [pnpm](https://pnpm.io/) (min: v10, recommended: v11) +- [node.js](https://nodejs.org/) (min: v22, recommended: v24) + +### Optional Support + +- [Redis](https://redis.io/) (min: v7, recommended: v8) - caching and session management. +- [Docker](https://www.docker.com/) (min: v24, recommended: v25) - containerization and deployment. +- [ElasticSearch](https://www.elastic.co/elasticsearch/) (min: v8, recommended: v9) - advanced search capabilities. +- [NodeMailer](https://nodemailer.com/about/) - email sending capabilities. +- [Resend](https://resend.com/) - email sending capabilities. +- [S3](https://aws.amazon.com/s3/) - file storage. +- [Supabase](https://supabase.com/) - database management and file storage. + ## Get started -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; diff --git a/apps/docs/content/docs/guides/blog.mdx b/apps/docs/content/docs/guides/blog.mdx index 4ffe49884..c1e79d1ea 100644 --- a/apps/docs/content/docs/guides/blog.mdx +++ b/apps/docs/content/docs/guides/blog.mdx @@ -128,10 +128,10 @@ export const blogCategoryContentType = defineContentType({ admin: { label: { plural: "Categories", singular: "Category" }, permissionModule: "categories", - titleField: null, + titleField: "name", create: { mode: "dialog" }, edit: { mode: "dialog" }, - list: { columns: ["color", "updatedAt"] }, + list: { columns: ["name", "color", "updatedAt"] }, }, }); ``` @@ -140,16 +140,35 @@ The colour is the AdminCP's own picker through a [field override](/docs/dev/content-engine/admin-form-layouts#field-and-column-overrides), and the colour column is a swatch **plus** the value in words. - - `titleField` is `null` because every text field on a category is localized - - left undefined, the engine would pick `color`, and "#3260c0 has been deleted" - is not a sentence anybody wants to read. The consequence is that the article's - category picker labels its options `#3` rather than "Engineering": a relation - label is resolved from a **shared** column on the target, and a localized - content type has none. Resolving one from the translation table is a Content - Engine change, not something a plugin should paper over - `config.tsx` is - loaded by the `vitnode` CLI, so a field override cannot reach a server action - to look the names up itself. +`name` is localized and is still the list's first column and the content type's +`titleField`. That is the split the engine draws: showing a localized value is a +projection the AdminCP resolves in *your* language, while ordering and filtering +stay on the base table. The list reads: + +```text +Name Color Updated +Aktualności ● #3260c0 2 days ago +Poradniki ● #23a06b a week ago +``` + +and the dialog is one form, with the switcher inside the field that needs one: + +```text +Name [ Aktualności ] [ PL ▾ ] + +Color [ ● #3260c0 ] + + Cancel Save +``` + + + `titleField: "name"` fixes the list, the toasts and the page headings, because + the AdminCP resolves a localized title from the translation it already loaded. + The **relation picker** is a different query: a relation label is resolved from + a shared column on the target with a SQL join, and a localized content type has + none - so the article's category picker labels its options `#3` rather than + "Aktualności". Resolving one from the translation table is a Content Engine + change, not something a plugin should paper over. ### Articles - the rich example @@ -179,10 +198,24 @@ delivery: Below `lg` it is a single column: body first, then metadata, then the actions. -Articles are localized, so the editor is the same layout on the shared tab and -on each language tab - `ContentFormField` renders nothing for a field the -current surface does not have, so `title` and `content` appear per language -while `categoryId` and `authorId` appear once. +Articles are localized, and the layout does not know it. `title`, `content` and +`friendlyUrl` are stored per language and `categoryId` and `authorId` are not - +so the first three render their own small language switchers and the last two do +not, from one `ContentFormField` call each: + +```text +Title [ Tytuł artykułu ] [ PL ▾ ] +Content [ AutoFormEditor ] [ PL ▾ ] +Friendly URL [ tytul-artykulu ] [ PL ▾ ] +Category Aktualności +Author Maciej +``` + +Everything opens in the language you are reading VitNode in - not in +`defaultLocale` - and switching `Title` to English leaves the editor in Polish. +One Save writes the base row and every changed language in one transaction. +Per-language publish, history and delete live in the list's **Languages** row +action. ### Upgrading from an older blog diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json index e3eb86744..15a622106 100644 --- a/apps/docs/src/locales/@vitnode/blog/pl.json +++ b/apps/docs/src/locales/@vitnode/blog/pl.json @@ -34,8 +34,7 @@ "form": { "publish": "Publikacja", "settings": { - "title": "Ustawienia artykułu", - "locale_desc": "Adres i metadane wersji w tym języku." + "title": "Ustawienia artykułu" } } }, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index e46235cad..cdd278db0 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -27,7 +27,7 @@ import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; import { validateContentTypes } from "@/content/registry"; import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver"; -import { assertContentPreviewConfig } from "@/content/server/preview-config"; +import { warnAboutContentPreviewConfig } from "@/content/server/preview-config"; import { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; @@ -260,9 +260,10 @@ 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({ + // after every plugin's content types are in. A warning, never a boot failure: + // `CONTENT_PREVIEW_SECRET` is optional and preview is what fails closed + // without it. + warnAboutContentPreviewConfig({ contentTypes: contentTypesMetadata, secret: process.env.CONTENT_PREVIEW_SECRET, }); 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 bb760c958..f896b0d37 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 @@ -48,9 +48,9 @@ export const integrationsDebugAdminRoute = buildRoute({ // 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. + // well-known default, or too short to be a signing key. The + // variable is optional and the API boots without it, but preview + // does not merely warn in that state - it refuses to serve. secure: z.boolean(), }), cron: z.object({ diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 24677827a..aca27e168 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -65,6 +65,15 @@ export interface ItemAutoFormComponentProps { itemParams?: InputParams; label?: React.ReactNode; labelRight?: React.ReactNode; + /** + * Whether this field holds one value per language. + * + * Set by whoever builds the field list - the Content Engine reads it off + * `localized: true` - so a custom component can pass it straight through to + * `AutoFormInput`, `AutoFormTextarea` or `AutoFormEditor` and get the language + * switcher without knowing why the field has one. + */ + multiLang?: boolean; otherProps: { ["aria-invalid"]?: boolean; enum?: string[]; diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index 5185c6e55..1ac88c27e 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -43,8 +43,11 @@ const MultiLangInput = ({ )} - - + {/* `FormControl` on the input itself, not on the group: it is what hands + the field its id, and a label pointing at the wrapping div labels + nothing a screen reader can use. */} + + - {languages.length > 1 && ( - - - - )} - - + + {languages.length > 1 && ( + + + + )} + {!!description && {description}} diff --git a/packages/vitnode/src/components/form/fields/textarea.test.tsx b/packages/vitnode/src/components/form/fields/textarea.test.tsx new file mode 100644 index 000000000..f08be6feb --- /dev/null +++ b/packages/vitnode/src/components/form/fields/textarea.test.tsx @@ -0,0 +1,194 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { InputParams } from "@/lib/helpers/auto-form"; + +import { LanguagesProvider } from "@/components/languages-provider"; +import { Form, FormField } from "@/components/ui/form"; + +import { AutoFormTextarea } from "./textarea"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +const LANGUAGES = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const Harness = ({ + onSubmit = vi.fn(), + defaultValue, + languages = LANGUAGES, + itemParams, + multiLang = true, +}: { + defaultValue?: unknown; + itemParams?: InputParams; + languages?: { code: string; enabled?: boolean; name: string }[]; + multiLang?: boolean; + onSubmit?: (values: FieldValues) => void; +}) => { + const form = useForm({ + defaultValues: { body: defaultValue } as FieldValues, + }); + + return ( + + + ( + + )} + /> + + + + ); +}; + +describe("AutoFormTextarea multiLang", () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the language select when more than one language is enabled", () => { + render(); + + expect(screen.getByRole("combobox")).toBeDefined(); + }); + + it("shows no selector on a one-language install", () => { + // A switcher with one option is a control that cannot do anything. + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("shows none for a shared field either", () => { + render(); + + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("starts on the reader's own language", () => { + render( + , + ); + + // `useLocale()` is `en`, and `en` is second in the stored array - so this is + // the reader's language rather than whatever happened to be written first. + expect(screen.getByRole("textbox").value).toBe( + "Hello", + ); + }); + + it("writes the typed value as a { languageCode, value }[] array", async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Hello" }, + }); + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { body: [{ languageCode: "en", value: "Hello" }] }, + expect.anything(), + ); + }); + }); + + it("keeps a value per language, and restores it on the way back", async () => { + const onSubmit = vi.fn(); + render( + , + ); + + const switchTo = async (name: string) => { + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name }); + fireEvent.pointerDown(option); + fireEvent.click(option); + }; + + await switchTo("Polski"); + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe( + "Cześć", + ); + }); + + await switchTo("English"); + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe( + "Hello", + ); + }); + }); + + it("shows an empty box for a language with no translation, and writes nothing", async () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("combobox")); + const option = await screen.findByRole("option", { name: "Polski" }); + fireEvent.pointerDown(option); + fireEvent.click(option); + + await waitFor(() => { + expect(screen.getByRole("textbox").value).toBe(""); + }); + + fireEvent.click(screen.getByRole("button", { name: "submit" })); + + // Looking at a language is not a decision to create a translation in it. + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { body: [{ languageCode: "en", value: "Hello" }] }, + expect.anything(), + ); + }); + }); + + it("applies the value maxLength from itemParams to the textarea", () => { + render(); + + expect(screen.getByRole("textbox").getAttribute("maxLength")).toBe("12"); + }); +}); diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index 4f0026834..fcfa27552 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -3,27 +3,110 @@ import type React from "react"; import { FormControl, FormMessage } from "@/components/ui/form"; import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"; import { Textarea } from "@/components/ui/textarea"; +import { getMultiLangConstraints } from "@/lib/helpers/multi-lang"; import type { ItemAutoFormComponentProps } from "../auto-form"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; +import { MultiLangSelect, useMultiLangField } from "./multi-lang"; + +type AutoFormTextareaProps = ItemAutoFormComponentProps & + Omit, "value"> & { + description?: React.ReactNode; + label?: React.ReactNode; + multiLang?: boolean; + }; + +/** + * The same textarea, holding one value per language. + * + * The switcher sits beside the label rather than inside the box, which is where + * `AutoFormEditor` puts it too: a textarea is resizable and multi-line, so an + * inline addon would end up floating in the middle of the control. + */ +const MultiLangTextarea = ({ + label, + labelRight, + description, + isOptional, + field, + itemParams, + ...props +}: Omit & { + isOptional?: boolean; +}) => { + const { languages, selected, setSelected, currentValue, setValue } = + useMultiLangField(field); + const { maxLength, minLength } = getMultiLangConstraints(itemParams); + + return ( + <> +
    + {!!label && ( + + {label} + + )} + {languages.length > 1 && ( + + )} +
    + + +