From 9fd84d19019bb4057424b30df729a7f75cd55ade Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 2 Aug 2026 22:57:07 +0200 Subject: [PATCH 1/4] 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