diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml
index a9503bc88..ab5f34237 100644
--- a/.github/workflows/build-lint-test.yml
+++ b/.github/workflows/build-lint-test.yml
@@ -1,18 +1,39 @@
name: Build, Lint & Test
on:
+ # No `branches` filter: every pull request runs, whatever it targets. The old
+ # `"*"` glob matched a single path segment, so a PR into `perf/improve_errors`
+ # silently ran nothing at all.
pull_request:
- branches: "*"
types:
- opened
- edited
- synchronize
+ - reopened
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
+ # A throwaway database for the Content Engine's Postgres smoke test. The
+ # suite skips itself when `DATABASE_TEST_URL` is unset, and refuses to run
+ # against a database whose name does not contain "test".
+ services:
+ postgres:
+ image: postgres:18
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: vitnode_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -47,3 +68,8 @@ jobs:
- name: Run tests
run: pnpm test
+ env:
+ DATABASE_TEST_URL: postgres://postgres:postgres@localhost:5432/vitnode_test
+
+ - name: Run type tests
+ run: pnpm test:types
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/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts
index 8667b7cc2..b9ed1119f 100644
--- a/apps/api/src/vitnode.api.config.ts
+++ b/apps/api/src/vitnode.api.config.ts
@@ -2,6 +2,7 @@ import { google } from "@ai-sdk/google";
import { blogApiPlugin } from "@vitnode/blog/config.api";
// import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local";
import { buildApiConfig } from "@vitnode/core/vitnode.config";
+import { exampleApiPlugin } from "@vitnode/example/config.api";
import { NodeCronAdapter } from "@vitnode/node-cron";
import { NodemailerEmailAdapter } from "@vitnode/nodemailer";
// import { S3StorageAdapter } from "@vitnode/s3";
@@ -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..01fded491
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx
@@ -0,0 +1,135 @@
+---
+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
+
+## What "lazy-loaded on open" actually means
+
+The dialog body is only in the tree while that dialog is open. A 25-row table
+renders 25 edit buttons and, at most, **one** form - Base UI's portal mounts the
+body on open and unmounts it on close, so no other row's form, and none of its
+`react-hook-form` state, exists.
+
+The form itself is one `next/dynamic` boundary shared by the create and the edit
+dialog, because they render the same component. That is worth about 465 KB of
+chunks, so it is downloaded once:
+
+- the **first** dialog you open shows the `` while those chunks arrive,
+- every dialog after that opens instantly, with no loader.
+
+
+ No spinner on the second dialog means the code was already there. Splitting
+ create and edit into separate lazy modules would either download the same form
+ twice or land back on a shared chunk anyway - so the loader would be a few
+ milliseconds of theatre either way.
+
+
+## Mutation feedback
+
+Every mutation closes its dialog, refreshes the list and raises a `sonner` toast
+with a description. On create there is no row to name yet, so the description is
+the title that was just typed.
+
+Failures get an error toast, and the description depends on what actually went
+wrong - a delete blocked by a foreign key does not read like a crashed server:
+
+| Status | The person is told |
+| --- | --- |
+| 400 | some of these values are not valid |
+| 403 | you do not have permission to do this |
+| 404 | this record no longer exists |
+| 409 | this record is still referenced by other content |
+| anything else | the generic server error |
+
+
+ The generated routes translate Postgres error codes into a status and a generic
+ sentence. Constraint names, column names and values stay on the server; the
+ detail goes to `core_logs`.
+
+
+## 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..7ec44b46d
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx
@@ -0,0 +1,251 @@
+---
+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, the indexes described [below](#indexes), 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,
+ "code" varchar(100) 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_created_at_idx"
+ ON "example_articles" USING btree ("status","createdAt");
+CREATE UNIQUE INDEX "example_articles_code_key"
+ ON "example_articles" USING btree ("code");
+CREATE INDEX "example_articles_author_idx" ON "example_articles" USING btree ("author");
+CREATE INDEX "example_articles_category_idx" ON "example_articles" USING btree ("category");
+CREATE INDEX "example_articles_created_at_idx" ON "example_articles" USING btree ("createdAt");
+CREATE INDEX "example_articles_updated_at_idx" ON "example_articles" USING btree ("updatedAt");
+```
+
+Ordinary SQL. Nothing about it says "generated", which is the point.
+
+## Indexes
+
+Four things put an index on a content table, and they are listed here in
+precedence order:
+
+1. anything you declared in `indexes`,
+2. `field.text({ unique: true })`,
+3. every foreign key - `relation` and `user` fields,
+4. `createdAt` and `updatedAt`, which back the default ordering.
+
+```ts
+indexes: [
+ { on: ["status", "createdAt"] },
+ { on: ["category", "code"], unique: true },
+ { name: "example_articles_hot_idx", on: ["featured"] },
+],
+```
+
+### Naming
+
+Generated names are `
__idx`, or `_key` when the index is
+unique - the suffix Postgres uses for its own unique constraints. Column names
+are converted to snake_case, so `createdAt` becomes `created_at` and the name
+matches the SQL you read in the migration.
+
+Names are capped at Postgres' 63-character limit. When a name would overflow it
+is shortened and a short fingerprint of the *full* name is appended, so two long
+tables that differ only in their last few characters never collide:
+
+```text
+a_very_long_content_table_name_with_a_long_col_idx // fits, used as is
+a_very_long_content_table_name_wit..._some_column_1x9k4m2 // shortened + hashed
+```
+
+The result is deterministic: the same definition always produces the same
+migration.
+
+
+ Postgres keeps index names in the schema, so *every* content type installed in
+ one app has to have distinct ones - even across two plugins that know nothing
+ about each other. The registry checks that when it validates the installed set,
+ which means a clash fails at plugin build and at app boot rather than halfway
+ through a migration:
+
+```text
+[Content Engine] blog.post: Index name "shared_code_idx" is used by both
+@vitnode/example -> example.article (table "example_articles", columns [code])
+and @vitnode/blog -> blog.post (table "blog_posts", columns [slug]).
+Postgres index names are unique per schema, so rename one of them.
+```
+
+ Generated names carry the table name, so they cannot collide on their own -
+ this only bites when two content types pick the same explicit `name`.
+
+
+### Deduplication
+
+Two index definitions covering **the same columns in the same order** collapse
+into one. The first name wins, and the index is unique if any of them asked for
+uniqueness. In practice:
+
+| You wrote | You get |
+| --- | --- |
+| `field.text({ unique: true })` on `code` | one unique index, `
_code_key` |
+| that, plus `{ on: ["code"], unique: true }` | still one index |
+| that, plus `{ name: "my_code_idx", on: ["code"] }` | one index named `my_code_idx`, **and still unique** |
+| `{ on: ["category"] }` on a relation field | one index, yours - not a second copy of the automatic one |
+
+Column order is part of the identity: `(status, createdAt)` and
+`(createdAt, status)` are two different indexes, because they are.
+
+### What is rejected
+
+`defineContentType` throws a `ContentEngineError` for an index that could not
+work, rather than letting Postgres find out later:
+
+- an empty column list, or a column the content type does not declare,
+- the same column listed twice inside one index,
+- two declared indexes on the same columns,
+- a duplicated explicit `name`,
+- an explicit `name` that is not snake_case, or is over 63 characters,
+- two indexes that resolve to the same name.
+
+The registry adds one more, because it is the only thing that sees every content
+type at once:
+
+- two **different content types** resolving to the same index name.
+
+## 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.
+
+## Checking it against a real database
+
+The unit tests assert on Drizzle's table metadata, which is fast but cannot prove
+that Postgres accepted the DDL. So the example plugin also carries a smoke test
+that applies the committed migration to a throwaway database and drives the
+service through create, read, list, update and delete - including the unique
+index, the foreign keys and a restricted delete.
+
+It skips itself unless `DATABASE_TEST_URL` is set, and it refuses to run against
+a database whose name does not contain `test`, because it drops the schema first:
+
+
+
+```bash tab="bun"
+DATABASE_TEST_URL=postgres://root:root@localhost:5432/vitnode_test \
+ bun run --filter @vitnode/example test
+```
+
+```bash tab="pnpm"
+DATABASE_TEST_URL=postgres://root:root@localhost:5432/vitnode_test \
+ pnpm --filter @vitnode/example test
+```
+
+```bash tab="npm"
+DATABASE_TEST_URL=postgres://root:root@localhost:5432/vitnode_test \
+ npm test --workspace @vitnode/example
+```
+
+
+
+CI provides that database as a service container, so every pull request runs it.
+
+## 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..7d2f19663
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/defining-a-content-type.mdx
@@ -0,0 +1,230 @@
+---
+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 }),
+ code: field.text({ required: true, maxLength: 100, unique: true }),
+ excerpt: field.textarea({ maxLength: 500, nullable: true }),
+ views: field.number({ integer: true, min: 0, defaultValue: 0 }),
+ featured: field.boolean({ defaultValue: false }),
+ status: field.enum({
+ values: ["draft", "published", "archived"],
+ defaultValue: "draft",
+ }),
+ publishedAt: field.dateTime({ nullable: true }),
+ author: field.user(),
+ category: field.relation({
+ required: true,
+ onDelete: "restrict",
+ target: () => categoryContentType,
+ }),
+ },
+
+ indexes: [{ on: ["status", "createdAt"] }],
+
+ admin: {
+ label: { plural: "Articles", singular: "Article" },
+ titleField: "title",
+ list: {
+ columns: ["title", "code", "status", "category", "author", "updatedAt"],
+ searchableFields: ["title", "code", "excerpt"],
+ orderableFields: ["title", "code", "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, and the engine
+[checks it against the descriptor's `target`](/docs/dev/content-engine/fields#the-target-is-checked-against-the-real-foreign-key)
+so the two cannot drift apart.
+
+
+ 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..265bf9e15
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/fields.mdx
@@ -0,0 +1,203 @@
+---
+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)`, optionally unique | `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 }),
+code: field.text({ required: true, maxLength: 100, unique: true }),
+excerpt: field.textarea({ maxLength: 500, nullable: true }),
+```
+
+### unique
+
+`unique: true` on a `text` field adds a real unique index to the table, named
+`
__key` - the suffix Postgres itself uses:
+
+```sql
+CREATE UNIQUE INDEX "example_articles_code_key"
+ ON "example_articles" USING btree ("code");
+```
+
+Postgres, not the descriptor, enforces it. A duplicate comes back from the
+generated routes as **409** with a generic message, [documented in
+OpenAPI](/docs/dev/content-engine/permissions#what-each-route-documents); the
+driver's text never reaches the client.
+
+Declaring the same column in `indexes` does not produce a second index - see
+[index deduplication](/docs/dev/content-engine/database-and-migrations#indexes).
+For a multi-column unique constraint, declare it there instead:
+
+```ts
+indexes: [{ on: ["category", "code"], unique: 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(),
+// same as: field.user({ nullable: true, onDelete: "set null" })
+```
+
+
+ Every other `field.*` defaults to `nullable: false`. `field.user()` does not,
+ because accounts get deleted and their content should outlive them - exactly
+ how `blog_posts.authorId` has always been written by hand. Pass
+ `nullable: false` and the `onDelete` default moves to `"restrict"`.
+
+
+## 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", // the default
+ 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.
+
+### The target is checked against the real foreign key
+
+A relation says where it points twice: `target` in the descriptor, and
+`references` in the database module. The engine compares them - reading
+Drizzle's table metadata, not parsing SQL - and refuses to boot if they disagree:
+
+```text
+[Content Engine] example.article: Relation field "category" targets
+"example_categories", but `references.category` points at "example_tags".
+```
+
+The check runs lazily, from inside the foreign-key thunk, so two content types
+that reference each other still load fine.
+
+### onDelete rules
+
+| `onDelete` | Requires | What a delete does |
+| --- | --- | --- |
+| `"restrict"` | – | Refuses; the route answers 409 |
+| `"cascade"` | – | Deletes the referencing rows too |
+| `"set null"` | `nullable: true` | Clears the reference |
+
+
+ Postgres accepts `ON DELETE SET NULL` on a `NOT NULL` column at `CREATE TABLE`
+ time and only fails years later, the first time someone deletes a referenced
+ row. `defineContentType` rejects the combination up front instead.
+
+
+## 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..4ad99ef7b
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/index.mdx
@@ -0,0 +1,103 @@
+---
+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 }),
+ code: field.text({ required: true, maxLength: 100, unique: true }),
+ status: field.enum({ values: ["draft", "published"], defaultValue: "draft" }),
+ publishedAt: field.dateTime({ nullable: true }),
+ author: field.user(),
+ },
+ admin: {
+ label: { plural: "Articles", singular: "Article" },
+ },
+});
+```
+
+That gives you:
+
+- a dedicated `example_articles` table with a `serial` primary key, timestamps,
+ indexes and RLS, migrated by the normal `drizzle-kit` flow
+- Zod schemas for create, update, select, filters, ordering and the form
+- a typed, self-validating 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..7bd157e05
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx
@@ -0,0 +1,112 @@
+---
+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 |
+| Unique constraints on more than one column | Declare them in `indexes`, not on the field |
+| Uniqueness on non-`text` fields | Declare the index in `indexes` |
+| Range, `IN` or custom partial-match filters | `filters` is equality only; `search` covers `searchableFields`, anything else is a custom route |
+| Partial and expression indexes | Add them in a hand-written migration |
+
+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.
+
+## Filters are equality, and only equality
+
+`filters` compares a column to one value. That is the whole feature.
+
+- **Partial matching** is `search`, which runs `ilike` across
+ `admin.list.searchableFields` - wildcards escaped, so a literal `%` matches a
+ literal `%`.
+- **Ranges, `IN`, and any other operator** need a custom route. The service and
+ the column map are public, so `gte(articles.table.views, 100)` is a one-liner
+ next to a generated route.
+- **`null`** works on a nullable field in a direct service call and becomes
+ `IS NULL`. On a `NOT NULL` field it throws rather than quietly returning
+ nothing.
+- **Over HTTP there is no null.** The generated route defines no textual null
+ syntax, because `author=null` would be ambiguous with a legitimate value.
+- **Unrelated query parameters are ignored, not rejected.** Both query schemas
+ read only the keys they own, so a typo in a filter name is a silent no-op.
+ Nothing unsupported reaches the query builder either way - it just does not
+ survive the parse. A direct service call is the strict path.
+
+## Ordering is typed one step wider than it runs
+
+`orderBy.column` accepts any field of the content type at compile time, but only
+the fields in `admin.list.orderableFields` at runtime. The resolved admin config
+stores that array as `string[]`, so the exact configured list is not recoverable
+as a type. The runtime allowlist is the strict one, and it throws a
+`ContentEngineError` naming what *is* allowed.
+
+## 403 is not in the OpenAPI document
+
+Every generated operation is gated by `adminStaffPermission`, and every one of
+them can answer 403 - but none of them declares it. Authorization is the shared
+VitNode staff-permission middleware that `buildRoute` composes, not a branch in
+the handler, and no other VitNode route documents it locally either. A generated
+client will see a 403 it was not told about; that is consistent across the whole
+API, not a Content Engine quirk. Everything a handler *itself* produces
+[is documented](/docs/dev/content-engine/permissions#what-each-route-documents),
+including the unique-conflict 409.
+
+## Index names are checked across Content Types, not across the schema
+
+The registry sees every registered content type at once, so it catches two of
+them resolving to the same index name - across plugins included. It cannot see
+anything else in your database:
+
+- a collision with a **hand-written table's** index is invisible to it,
+- so is a collision with an index created directly in a migration.
+
+Generated names carry the table name, which makes an accidental clash unlikely,
+and a shortened name carries a deterministic fingerprint of the full one rather
+than a bare truncation. Whatever slips through is caught the honest way - by
+Postgres, when the migration runs.
+
+## 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..813996d78
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/permissions.mdx
@@ -0,0 +1,106 @@
+---
+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.
+
+## What each route documents
+
+Every status a generated handler can produce is in the OpenAPI document, so a
+client generated from `/api/swagger/doc` knows about all of them - including the
+unique-constraint `409`:
+
+| Route | Statuses |
+| --- | --- |
+| `GET /` | `200`, `400` |
+| `GET /options/{field}` | `200`, `400` |
+| `GET /{id}` | `200`, `400`, `404` |
+| `POST /` | `201`, `400`, `409` |
+| `PUT /{id}` | `200`, `400`, `404`, `409` |
+| `DELETE /{id}` | `200`, `400`, `404`, `409` |
+
+A `409` on create or update is a duplicate value; on delete it is a row something
+else still references. `403` is the one status the routes do not declare
+themselves - it comes from the staff-permission middleware `buildRoute` composes,
+the same way it does for every other VitNode route.
+
+## 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..6b197dd17
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx
@@ -0,0 +1,134 @@
+---
+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.
+
+
+ `service.create()` parses against this very schema before it writes, so the
+ same rules hold whether the call came over HTTP or from your own route. See
+ [the service API](/docs/dev/content-engine/service#it-is-safe-to-call-directly).
+
+
+## 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
+```
+
+`service.update()` parses against it before it even reads the row, so an invalid
+patch costs no query at all.
+
+## 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 and coerced from their query-string form. The
+list of kinds lives in one place, `CONTENT_FILTERABLE_FIELD_KINDS`, which the
+schema, the query builder and the [service filter type](/docs/dev/content-engine/service#filters-are-typed-per-content-type)
+all derive from - and which the query builder re-checks at runtime.
+
+The list route parses the *whole* query string through both this schema and the
+pagination one, each reading only the keys it owns. Neither is strict, so a key
+that belongs to neither is **ignored** rather than rejected:
+
+```text
+?status=published&nope=1&excerpt=prose
+→ filters: { status: "published" }
+```
+
+`nope` is not a field; `excerpt` is a `textarea` and has no filter entry. Both
+disappear during the parse, which is what keeps an unsupported field from ever
+reaching the query builder over HTTP.
+
+
+ A typo in a filter name is silently a no-op rather than a 400. That is the
+ Stage 1 trade: the same query string carries pagination, ordering, search and
+ filters, and strict parsing would reject perfectly ordinary requests. A direct
+ service call is the strict path - there, an unknown key throws.
+
+
+There is no query-string syntax for `null` either: a filterable value that
+arrives as text is always a value. Filtering for `IS NULL` is a direct service
+call.
+
+`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..239bd5dbb
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/service.mdx
@@ -0,0 +1,260 @@
+---
+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 validation, column allowlisting, pagination and
+relation label joins, and hands everything else to Drizzle.
+
+```ts
+const articles = articleContent.service(c);
+```
+
+## It is safe to call directly
+
+The service is a public API, not an internal helper for the generated routes. So
+`create` and `update` validate against the content type's own schemas *before*
+they touch Drizzle - the same `schemas.create` and `schemas.update` the routes
+declare. Unknown keys, system columns, a `""` where an ISO date belongs, a
+relation id of `0`: all rejected, whether the call arrived over HTTP or from your
+own route.
+
+```ts
+await articles.create({ title: "no", category: 1 });
+// ZodError: title must contain at least 3 characters
+```
+
+A failure throws a `ZodError` and issues no query at all. Inside a generated
+route that becomes a **400** with a generic message - the issue tree stays on the
+server, because it names internal field paths.
+
+
+ Drizzle never sees the object you passed in, only what came back out of the
+ schema. That is also where declared defaults are applied, so a create writes
+ the same values the column defaults would have.
+
+
+## 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" }
+```
+
+### Filters are typed per content type
+
+`filters` only accepts the fields the generated filter schema actually
+understands - `text`, `number`, `boolean`, `enum`, `relation` and `user` - with
+each value typed from its descriptor:
+
+```ts
+await articles.findMany({
+ filters: {
+ status: "published", // "draft" | "published" | "archived"
+ featured: true,
+ category: 2, // an id, not a label
+ },
+});
+
+await articles.findMany({ filters: { excerpt: "prose" } });
+// ^ a textarea is not filterable
+await articles.findMany({ filters: { status: "sideways" } });
+// ^ not one of the declared values
+```
+
+Filters are **equality only**. Ranges, `IN` and partial matches are a custom
+route - `search` already covers partial matches across
+`admin.list.searchableFields`.
+
+| Field kind | Typed service filter | Generated HTTP filter |
+| --- | :-: | :-: |
+| `text` | ✓ | ✓ |
+| `textarea` | ✗ | ✗ |
+| `number` | ✓ | ✓ |
+| `boolean` | ✓ | ✓ |
+| `enum` | ✓ | ✓ |
+| `dateTime` | ✗ | ✗ |
+| `relation` | ✓ | ✓ |
+| `user` | ✓ | ✓ |
+
+
+ The same allowlist is enforced at runtime, so a cast, a plain-JavaScript caller
+ or a filter object assembled at runtime gets a `ContentEngineError` rather than
+ a query:
+
+```ts
+articles.findMany({ filters: raw as ContentFilterInput });
+// ContentEngineError: Field "excerpt" of kind "textarea" cannot be used as a
+// generated equality filter. Filterable kinds: boolean, enum, number, ...
+```
+
+
+
+The two columns behave differently on a bad key, and it is worth knowing which
+you are using. A **direct service call** throws. The **generated list route**
+parses the query string through the filter schema, which is not strict, so a
+parameter it does not recognise is simply ignored:
+
+```text
+GET ?status=published&nope=1&excerpt=prose
+→ filters: { status: "published" }
+```
+
+Which means an unsupported field cannot reach the query builder over HTTP - it
+never survives the parse. See
+[filters and order](/docs/dev/content-engine/schemas#filters-and-order).
+
+### Filtering for nothing
+
+A nullable field can be filtered by `null`, and that becomes `IS NULL` - not a
+comparison against a null parameter, which is never true:
+
+```ts
+await articles.findMany({ filters: { author: null } });
+// where "example_articles"."author" is null
+```
+
+The field has to actually be nullable. `null` on a `NOT NULL` column is a
+`ContentEngineError`, because silently generating `IS NULL` there would return an
+empty page and look like missing data:
+
+```ts
+await articles.findMany({ filters: { category: null } });
+// ContentEngineError: Field "category" is not nullable, so it can never hold
+// null. Drop the filter, or declare the field `nullable: true`.
+```
+
+
+ This is a direct-service feature. The generated list route keeps its
+ query-string contract exactly as it is - there is no `?author=null`, because
+ inventing a textual null would make `author=null` ambiguous with a legitimate
+ value. Need it over HTTP? Write a route.
+
+
+### Ordering
+
+`orderBy.column` accepts the content type's own field names plus the three system
+columns, and nothing else.
+
+
+ `admin.list.orderableFields` is stored on the resolved admin config, where it
+ is a `string[]` - the configured array is not recoverable as a type. So the
+ compile-time check rejects anything outside the content type, and the runtime
+ allowlist rejects the fields you did not configure:
+
+```ts
+await articles.findMany({ orderBy: { column: "views" } });
+// ContentEngineError: Cannot order by "views". Allowed: title, status, id, ...
+```
+
+
+
+Both are backed by the model's column map, so 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` validates the patch, loads the row, diffs it, 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.
+
+`changedFields` is typed as the content type's field names, never `string[]`, and
+it is built from the definition's own fields - so it can only ever contain keys
+the content type declares.
+
+An empty patch is a validation error, and updates never re-apply create defaults:
+`{ title: "Updated" }` leaves `status` and `views` exactly as they were.
+
+## 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. It only accepts
+fields that actually have a picker - `relation` and `user`:
+
+```ts
+await articles.options("category", "ne");
+// → [{ label: "News", value: 3 }]
+
+await articles.options("author");
+
+await articles.options("title");
+// ^ a text field has nothing to enumerate
+```
+
+## 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..2ea6ea1bb
--- /dev/null
+++ b/apps/docs/migrations/0022_add_example_content.sql
@@ -0,0 +1,34 @@
+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,
+ "code" varchar(100) 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_status_created_at_idx" ON "example_articles" USING btree ("status","createdAt");--> statement-breakpoint
+CREATE UNIQUE INDEX "example_articles_code_key" ON "example_articles" USING btree ("code");--> 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_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_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..d3d9f71d9
--- /dev/null
+++ b/apps/docs/migrations/meta/0022_snapshot.json
@@ -0,0 +1,2493 @@
+{
+ "id": "5bd0b1de-db52-41b3-86a7-4f4a9a7ca0b7",
+ "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
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "excerpt": {
+ "name": "excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "views": {
+ "name": "views",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "featured": {
+ "name": "featured",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "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_status_created_at_idx": {
+ "name": "example_articles_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_code_key": {
+ "name": "example_articles_code_key",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_author_idx": {
+ "name": "example_articles_author_idx",
+ "columns": [
+ {
+ "expression": "author",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_category_idx": {
+ "name": "example_articles_category_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_created_at_idx": {
+ "name": "example_articles_created_at_idx",
+ "columns": [
+ {
+ "expression": "createdAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "example_articles_updated_at_idx": {
+ "name": "example_articles_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updatedAt",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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..b6f14046d 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": 1785706178516,
+ "tag": "0022_add_example_content",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
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..0b63906c9 100644
--- a/apps/docs/src/vitnode.api.config.ts
+++ b/apps/docs/src/vitnode.api.config.ts
@@ -4,6 +4,7 @@ import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord";
import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook";
import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google";
import { buildApiConfig } from "@vitnode/core/vitnode.config";
+import { exampleApiPlugin } from "@vitnode/example/config.api";
import { NodeCronAdapter } from "@vitnode/node-cron";
import { NodemailerEmailAdapter } from "@vitnode/nodemailer";
// import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local";
@@ -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..0c360d790 100644
--- a/apps/docs/src/vitnode.config.ts
+++ b/apps/docs/src/vitnode.config.ts
@@ -1,5 +1,6 @@
import { blogPlugin } from "@vitnode/blog/config";
import { buildConfig, handleRequestConfig } from "@vitnode/core/vitnode.config";
+import { examplePlugin } from "@vitnode/example/config";
import { getRequestConfig } from "next-intl/server";
import { i18n } from "./i18n";
@@ -9,7 +10,7 @@ export const vitNodeConfig = buildConfig({
title: "VitNode",
shortTitle: "VitNode",
},
- plugins: [blogPlugin()],
+ plugins: [blogPlugin(), examplePlugin()],
debug: false,
i18n,
theme: {
diff --git a/package.json b/package.json
index 81e733438..d38312371 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"lint": "turbo lint",
"lint:fix": "turbo lint:fix",
"test": "turbo test",
+ "test:types": "turbo test:types",
"test:e2e": "turbo test:e2e",
"i18n:create": "turbo i18n:create",
"i18n:check": "turbo i18n:check",
diff --git a/packages/vitnode/.swcrc b/packages/vitnode/.swcrc
index 8f099dc7a..91fc9a4b0 100644
--- a/packages/vitnode/.swcrc
+++ b/packages/vitnode/.swcrc
@@ -1,6 +1,6 @@
{
"$schema": "https://swc.rs/schema.json",
- "exclude": ["\\.test\\.tsx?$"],
+ "exclude": ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"],
"minify": true,
"jsc": {
"baseUrl": "./",
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