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

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

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

({ }); }); + const registered: RegisteredContentType[] = validateContentTypes( + contentTypes.map(definition => ({ definition, pluginId })), + ); + return { pluginId, messages, hono, + contentTypes: registered.map(entry => entry.definition), cronJobs, events, queueTasks, searchIndexers, webSockets, - permissionStaff, + // Every content type contributes can_view/can_create/can_edit/can_delete + // unless the plugin declared that module itself. + permissionStaff: withContentPermissions(permissionStaff, registered), }; } + +/** + * Walks the whole module tree. Content types are collected recursively - unlike + * `events`, `cronJobs` and friends, which only come from top-level modules - so + * a generated content module can be nested inside the plugin's `admin` module + * and still register its permissions. + */ +function collectContentTypes( + module: BaseBuildModuleReturn, +): AnyContentTypeDefinition[] { + return [ + ...(module.contentTypes ?? []), + ...(module.modules ?? []).flatMap(collectContentTypes), + ]; +} diff --git a/packages/vitnode/src/api/lib/route.test.ts b/packages/vitnode/src/api/lib/route.test.ts new file mode 100644 index 000000000..e9272f91e --- /dev/null +++ b/packages/vitnode/src/api/lib/route.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment node +import type { MiddlewareHandler } from "hono"; + +import { OpenAPIHono, z } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import { buildRoute } from "./route"; + +const okResponse = { + 200: { + content: { + "application/json": { schema: z.object({ plugin: z.string() }) }, + }, + description: "ok", + }, +} as const; + +const mount = (route: ReturnType) => { + const app = new OpenAPIHono(); + app.openapi(route.route, route.handler); + + return app; +}; + +describe("buildRoute", () => { + it("keeps pluginMiddleware when the route brings its own middleware", async () => { + const marks: string[] = []; + const custom: MiddlewareHandler = async (_c, next) => { + marks.push("custom"); + await next(); + }; + + const route = buildRoute({ + pluginId: "@vitnode/test", + route: { + method: "get", + path: "/", + middleware: [custom], + responses: okResponse, + }, + handler: c => c.json({ plugin: c.get("plugin").id }, 200), + }); + + const res = await mount(route).request("/"); + + expect(res.status).toBe(200); + // Without the fix the `...route` spread replaced the composed array and + // `c.get("plugin")` was undefined. + expect(await res.json()).toEqual({ plugin: "@vitnode/test" }); + expect(marks).toEqual(["custom"]); + }); + + it("composes pluginMiddleware, the permission guard and route middleware in order", () => { + const custom: MiddlewareHandler = async (_c, next) => next(); + + const { route } = buildRoute({ + pluginId: "@vitnode/test", + adminStaffPermission: { module: "articles", permission: "can_view" }, + route: { + method: "get", + path: "/", + middleware: [custom], + responses: okResponse, + }, + handler: c => c.json({ plugin: c.get("plugin").id }, 200), + }); + + const middleware = route.middleware; + + expect(middleware).toHaveLength(3); + expect(middleware.at(-1)).toBe(custom); + }); + + it("prepends the plugin tag and keeps route tags", () => { + const { route } = buildRoute({ + pluginId: "@vitnode/test_plugin", + route: { + method: "get", + path: "/", + tags: ["Articles"], + responses: okResponse, + }, + handler: c => c.json({ plugin: c.get("plugin").id }, 200), + }); + + expect(route.tags).toEqual(["@vitnode/test Plugin", "Articles"]); + }); +}); diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts index e4e4f7830..c40ddb664 100644 --- a/packages/vitnode/src/api/lib/route.ts +++ b/packages/vitnode/src/api/lib/route.ts @@ -67,9 +67,12 @@ export const buildRoute = < return { route: createRouteHono({ + // `route` is spread first on purpose: `tags` and `middleware` already + // merge the route's own values, so letting the spread win would drop + // `pluginMiddleware` and the staff-permission guard. + ...route, tags, middleware, - ...route, }), handler: handler as Route["handler"], pluginId, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 0a6e738e3..47282cff0 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -3,6 +3,7 @@ import type { Redis } from "ioredis"; import { HTTPException } from "hono/http-exception"; +import type { RegisteredContentType } from "@/content/registry"; import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types"; import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config"; import type { VitNodeRealtime } from "@/ws/registry"; @@ -19,6 +20,7 @@ import { SearchModel } from "@/api/models/search"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; +import { validateContentTypes } from "@/content/registry"; import { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; @@ -83,6 +85,7 @@ export interface EnvVariablesVitNode { ssoAdapters: SSOApiPlugin[]; }; captcha?: Pick["captcha"]; + contentTypes: RegisteredContentType[]; cron: (BuildCronReturn & { module: string; pluginId: string })[]; cronSecret?: string; email?: VitNodeApiConfig["email"]; @@ -220,6 +223,17 @@ export const globalMiddleware = ({ })), ); + // Validated once more across *all* plugins: `buildApiPlugin` can only catch + // collisions inside a single plugin. + const contentTypesMetadata: RegisteredContentType[] = validateContentTypes( + plugins.flatMap(plugin => + (plugin.contentTypes ?? []).map(definition => ({ + definition, + pluginId: plugin.pluginId, + })), + ), + ); + const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map( plugin => ({ pluginId: plugin.pluginId, @@ -307,6 +321,7 @@ export const globalMiddleware = ({ queue: queueMetadata, webSockets: webSocketsMetadata, permissionStaff: permissionStaffMetadata, + contentTypes: contentTypesMetadata, }); const user = await new SessionModel(c).getUser(); diff --git a/packages/vitnode/src/components/form/fields/date-time.tsx b/packages/vitnode/src/components/form/fields/date-time.tsx new file mode 100644 index 000000000..c6cdb4023 --- /dev/null +++ b/packages/vitnode/src/components/form/fields/date-time.tsx @@ -0,0 +1,71 @@ +import type React from "react"; + +import { FormControl, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import type { ItemAutoFormComponentProps } from "../auto-form"; + +import { AutoFormDesc } from "../common/desc"; +import { AutoFormLabel } from "../common/label"; + +/** `2026-08-02T10:00:00.000Z` -> `2026-08-02T10:00`, what the input expects. */ +const toInputValue = (value: unknown): string => { + if (typeof value !== "string" || value === "") return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + + const pad = (part: number) => String(part).padStart(2, "0"); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +/** + * A date and time field backed by the platform's `datetime-local` input. + * + * The form value is an ISO 8601 string (or `null` for a nullable field), which + * is exactly what the generated API accepts - Zod v4 cannot turn `z.date()` + * into JSON Schema, and `AutoForm` runs `z.toJSONSchema` on every schema. + */ +export const AutoFormDateTime = ({ + label, + labelRight, + description, + field, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + itemParams, + otherProps: { isOptional }, + ...props +}: ItemAutoFormComponentProps & + Omit, "type" | "value">) => { + return ( + <> + {!!label && ( + + {label} + + )} + + + { + field.onBlur(); + props.onBlur?.(event); + }} + onChange={event => { + const { value } = event.target; + // An emptied input means "no value" - `null` when the field allows + // it, otherwise an empty string so validation reports it. + field.onChange(value === "" ? null : new Date(value).toISOString()); + props.onChange?.(event); + }} + type="datetime-local" + value={toInputValue(field.value)} + {...props} + /> + + + {!!description && {description}} + + + ); +}; diff --git a/packages/vitnode/src/components/ui/dialog.test.tsx b/packages/vitnode/src/components/ui/dialog.test.tsx new file mode 100644 index 000000000..dbd49231a --- /dev/null +++ b/packages/vitnode/src/components/ui/dialog.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./dialog"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +/** + * Counts how many times a dialog's body was actually mounted. + * + * This is the property every lazy `React.Suspense` dialog in the AdminCP relies + * on: a table of 50 rows renders 50 triggers, and the body of a dialog - the + * lazily loaded form and all of its `react-hook-form` state - must only exist + * while that one dialog is open. + */ +const Body = ({ onMount }: { onMount: () => void }) => { + React.useEffect(onMount, [onMount]); + + return

dialog body

; +}; + +const Harness = ({ onMount }: { onMount: () => void }) => ( + + open + + + Title + + + + +); + +describe("Dialog", () => { + it("does not mount its body until it is opened", () => { + const onMount = vi.fn(); + + render(); + + expect(screen.queryByText("dialog body")).toBeNull(); + expect(onMount).not.toHaveBeenCalled(); + }); + + it("mounts the body on open and unmounts it again on close", async () => { + const onMount = vi.fn(); + + render(); + + fireEvent.click(screen.getByText("open")); + await waitFor(() => { + expect(screen.queryByText("dialog body")).not.toBeNull(); + }); + expect(onMount).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.body, { key: "Escape" }); + await waitFor(() => { + expect(screen.queryByText("dialog body")).toBeNull(); + }); + }); + + it("keeps every other dialog's body out of the tree", async () => { + const first = vi.fn(); + const second = vi.fn(); + + render( + <> + + + , + ); + + fireEvent.click(screen.getAllByText("open")[0]); + await waitFor(() => { + expect(screen.queryByText("dialog body")).not.toBeNull(); + }); + + // One body in the document, and the sibling dialog never built its own. + expect(screen.getAllByText("dialog body")).toHaveLength(1); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/content/admin/config.ts b/packages/vitnode/src/content/admin/config.ts new file mode 100644 index 000000000..ea3a5eca0 --- /dev/null +++ b/packages/vitnode/src/content/admin/config.ts @@ -0,0 +1,47 @@ +import type { ContentTypeFrontendRegistration } from "../../lib/plugin"; +import type { VitNodeConfig } from "../../vitnode.config"; +import type { AnyContentTypeDefinition } from "../types"; + +import { getVitNodeConfig } from "../../vitnode.config"; +import { validateContentTypes } from "../registry"; + +export interface RegisteredFrontendContentType { + definition: AnyContentTypeDefinition; + pluginId: string; + registration: ContentTypeFrontendRegistration; +} + +/** + * Every content type registered with the AdminCP, in a deterministic order. + * + * Reads the app config rather than a mutable singleton, so hot reload simply + * re-derives it. The same validation the API side runs applies here, which is + * what catches a content type registered on only one of the two sides. + */ +export const getFrontendContentTypes = ( + vitNodeConfig: VitNodeConfig = getVitNodeConfig(), +): RegisteredFrontendContentType[] => { + const entries = vitNodeConfig.plugins.flatMap(plugin => + (plugin.contentTypes ?? []).map(registration => ({ + definition: registration.definition, + pluginId: plugin.pluginId, + registration, + })), + ); + + validateContentTypes( + entries.map(({ definition, pluginId }) => ({ definition, pluginId })), + ); + + return [...entries].sort((a, b) => + a.definition.id.localeCompare(b.definition.id), + ); +}; + +export const findFrontendContentType = ( + contentTypeId: string, + vitNodeConfig?: VitNodeConfig, +): RegisteredFrontendContentType | undefined => + getFrontendContentTypes(vitNodeConfig).find( + entry => entry.definition.id === contentTypeId, + ); diff --git a/packages/vitnode/src/content/admin/fetch.server.ts b/packages/vitnode/src/content/admin/fetch.server.ts new file mode 100644 index 000000000..f7900154d --- /dev/null +++ b/packages/vitnode/src/content/admin/fetch.server.ts @@ -0,0 +1,76 @@ +import "server-only"; +import type { z } from "zod"; + +import { cookies, headers } from "next/headers"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { rawApiFetch } from "../../lib/fetcher/raw"; + +export interface ContentFetchResult { + data?: TData; + error?: string; + status: number; +} + +/** + * Calls a generated content route from a server component or server action. + * + * The generic AdminCP page does not know which plugin module it is talking to + * at compile time, so route-literal inference buys nothing here - the response + * is typed (and validated) by the content type's own Zod schema instead, which + * is stricter. Everything else - URL shape, cookie and header forwarding, error + * logging - is the same `rawApiFetch` the typed `fetcher` uses. + */ +export const contentApiFetch = async ({ + body, + definition, + method, + path = "/", + pluginId, + query, + schema, +}: { + body?: unknown; + definition: AnyContentTypeDefinition; + method: "delete" | "get" | "post" | "put"; + path?: string; + pluginId: string; + query?: Record; + schema?: TSchema; +}): Promise>> => { + const [nextHeaders, cookieStore] = await Promise.all([headers(), cookies()]); + + const response = await rawApiFetch({ + additionalHeaders: { + Cookie: cookieStore.toString(), + ["user-agent"]: nextHeaders.get("user-agent") ?? "node", + ["x-forwarded-for"]: nextHeaders.get("x-forwarded-for") ?? "0.0.0.0", + }, + body, + method, + module: `content/${definition.permissionModule}`, + path, + pluginId, + prefixPath: "/admin", + query, + }); + + if (!response.ok) { + return { error: await response.text(), status: response.status }; + } + + const payload: unknown = await response.json(); + if (!schema) + return { data: payload as z.infer, status: response.status }; + + const parsed = schema.safeParse(payload); + if (!parsed.success) { + return { + error: "The API returned a response this content type does not describe.", + status: response.status, + }; + } + + return { data: parsed.data, status: response.status }; +}; diff --git a/packages/vitnode/src/content/admin/labels.ts b/packages/vitnode/src/content/admin/labels.ts new file mode 100644 index 000000000..ed975a01a --- /dev/null +++ b/packages/vitnode/src/content/admin/labels.ts @@ -0,0 +1,44 @@ +import type { AnyContentTypeDefinition } from "../types"; + +/** + * Turns `publishedAt` into "Published at" - the fallback whenever a plugin has + * not translated a field name. + */ +export const humanizeFieldName = (name: string): string => { + // Sentence case, not title case: "Published at" reads better as a form label + // than "Published At". + const spaced = name + .replace( + /([a-z0-9])([A-Z])/g, + (_match, before: string, upper: string) => + `${before} ${upper.toLowerCase()}`, + ) + .replace(/[_-]+/g, " ") + .trim(); + + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +}; + +/** `example.article` -> `article`; `example.kb.article` -> `kb_article`. */ +export const contentEntityKey = (contentTypeId: string): string => + contentTypeId.split(".").slice(1).join("_"); + +/** + * The i18n keys the generated AdminCP looks up, all under the owning plugin's + * namespace. Every one is optional - `t.has(key)` decides, and the definition's + * own labels are the fallback. + */ +export const contentI18nKeys = ( + definition: AnyContentTypeDefinition, + pluginId: string, +) => { + const base = `${pluginId}.content.${contentEntityKey(definition.id)}`; + + return { + desc: `${base}.desc`, + enumValue: (field: string, value: string) => + `${base}.enums.${field}.${value}`, + field: (field: string) => `${base}.fields.${field}`, + title: `${base}.title`, + }; +}; diff --git a/packages/vitnode/src/content/admin/spec.test.ts b/packages/vitnode/src/content/admin/spec.test.ts new file mode 100644 index 000000000..b8937c35f --- /dev/null +++ b/packages/vitnode/src/content/admin/spec.test.ts @@ -0,0 +1,285 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import { humanizeFieldName } from "./labels"; +import { + buildContentColumnSpec, + buildContentFormSpec, + buildFormSchemaFromSpec, + contentFormValuesToPayload, + contentTitleFromValues, +} from "./spec"; + +const labelField = (name: string) => humanizeFieldName(name); +const labelEnum = (_field: string, value: string) => value.toUpperCase(); + +const formSpec = buildContentFormSpec({ + definition: testArticleContentType, + labelEnum, + labelField, + pluginId: "@vitnode/example", +}); + +const columnSpecs = buildContentColumnSpec({ + definition: testArticleContentType, + labelEnum, + labelField, +}); + +const REF = { label: "News", value: "1" }; + +const specFor = (name: string) => { + const found = formSpec.fields.find(item => item.name === name); + if (!found) throw new Error(`no spec for ${name}`); + + return found; +}; + +describe("contentTitleFromValues", () => { + it("reads the content type's title field", () => { + expect(contentTitleFromValues(formSpec, { title: "Hello" })).toBe("Hello"); + }); + + it("gives up on a blank or missing title", () => { + expect(contentTitleFromValues(formSpec, { title: " " })).toBeUndefined(); + expect(contentTitleFromValues(formSpec, {})).toBeUndefined(); + }); + + it("gives up when the content type has no title field", () => { + expect( + contentTitleFromValues( + { ...formSpec, titleField: null }, + { title: "Hello" }, + ), + ).toBeUndefined(); + }); +}); + +describe("buildContentFormSpec", () => { + it("is plain JSON, so it can cross the server/client boundary", () => { + expect(JSON.parse(JSON.stringify(formSpec))).toEqual(formSpec); + }); + + it("carries the title field the toasts describe a new row by", () => { + expect(formSpec.titleField).toBe("title"); + }); + + it("covers exactly the declared form fields", () => { + expect(formSpec.fields.map(item => item.name)).toEqual( + testArticleContentType.admin.form.fields, + ); + }); + + it("humanises a field name when the plugin has no translation", () => { + expect(specFor("publishedAt").label).toBe("Published at"); + }); + + it("carries enum options with translated labels", () => { + expect(specFor("status").options).toEqual([ + { label: "DRAFT", value: "draft" }, + { label: "PUBLISHED", value: "published" }, + { label: "ARCHIVED", value: "archived" }, + ]); + }); + + it("carries the validation bounds the form needs", () => { + expect(specFor("title")).toMatchObject({ + maxLength: 200, + minLength: 3, + required: true, + }); + expect(specFor("views")).toMatchObject({ integer: true, min: 0 }); + }); + + it("keeps nullability", () => { + expect(specFor("excerpt").nullable).toBe(true); + expect(specFor("title").nullable).toBe(false); + }); +}); + +describe("buildContentColumnSpec", () => { + it("marks the system columns", () => { + expect(columnSpecs.find(item => item.name === "updatedAt")?.kind).toBe( + "system", + ); + }); + + it("keeps the declared column order", () => { + expect(columnSpecs.map(item => item.name)).toEqual([ + "title", + "status", + "author", + "updatedAt", + ]); + }); + + it("carries an enum lookup for badge cells", () => { + expect(columnSpecs.find(item => item.name === "status")?.options).toEqual({ + archived: "ARCHIVED", + draft: "DRAFT", + published: "PUBLISHED", + }); + }); +}); + +describe("buildFormSchemaFromSpec", () => { + const schema = buildFormSchemaFromSpec(formSpec); + + it("survives z.toJSONSchema, which AutoForm runs on every schema", () => { + expect(() => z.toJSONSchema(schema)).not.toThrow(); + }); + + it("prefills AutoForm from the declared defaults", () => { + const json = z.toJSONSchema(schema); + + expect(json.properties?.status).toMatchObject({ default: "draft" }); + expect(json.properties?.featured).toMatchObject({ default: false }); + expect(json.properties?.views).toMatchObject({ default: 0 }); + }); + + it("prefills from an existing row when editing", () => { + const json = z.toJSONSchema( + buildFormSchemaFromSpec(formSpec, { + status: "published", + title: "Existing", + }), + ); + + expect(json.properties?.title).toMatchObject({ default: "Existing" }); + expect(json.properties?.status).toMatchObject({ default: "published" }); + }); + + it("enforces the same bounds as the API", () => { + expect(schema.safeParse({ category: REF, title: "ab" }).success).toBe( + false, + ); + expect(schema.safeParse({ category: REF, title: "Hello" }).success).toBe( + true, + ); + }); + + it("rejects a value outside the enum", () => { + expect( + schema.safeParse({ category: REF, status: "nope", title: "Hello" }) + .success, + ).toBe(false); + }); + + it("takes dateTime as an ISO string, never a Date", () => { + expect( + schema.safeParse({ + category: REF, + publishedAt: "2026-08-02T10:00:00.000Z", + title: "Hello", + }).success, + ).toBe(true); + expect( + schema.safeParse({ + category: REF, + publishedAt: new Date(), + title: "Hello", + }).success, + ).toBe(false); + }); + + it("models a relation as the option object AutoFormCombobox stores", () => { + const option = { label: "News", value: "3" }; + + expect(schema.safeParse({ category: option, title: "Hello" }).success).toBe( + true, + ); + // A bare identifier is what the API takes, not what the form holds. + expect(schema.safeParse({ category: 3, title: "Hello" }).success).toBe( + false, + ); + }); + + it("rejects a required relation left unselected", () => { + expect( + schema.safeParse({ category: { label: "", value: "" }, title: "Hello" }) + .success, + ).toBe(false); + }); + + it("prefills a relation with the label the list already resolved", () => { + const json = z.toJSONSchema( + buildFormSchemaFromSpec(formSpec, { + category: 3, + labels: { category: "News" }, + title: "Existing", + }), + { io: "input" }, + ); + + expect(json.properties?.category).toMatchObject({ + default: { label: "News", value: "3" }, + }); + }); + + it("accepts what a number input actually produces - a string", () => { + expect( + schema.parse({ category: REF, title: "Hello", views: "7" }).views, + ).toBe(7); + }); + + it("treats an empty date input as no value rather than an invalid date", () => { + const parsed = schema.parse({ + category: REF, + publishedAt: "", + title: "Hello", + }); + + expect(parsed.publishedAt).toBeNull(); + }); + + it("converts form values into the API payload", () => { + expect( + contentFormValuesToPayload(formSpec, { + author: null, + category: { label: "News", value: "3" }, + title: "Hello", + }), + ).toEqual({ author: null, category: 3, title: "Hello" }); + }); + + it("stays valid with every value exactly as the DOM reports it", () => { + // This is the combination that previously left the submit button disabled + // forever with no visible error. + const result = schema.safeParse({ + author: null, + category: { label: "News", value: "1" }, + excerpt: "", + featured: false, + publishedAt: "", + status: "draft", + title: "QA Article", + views: "0", + }); + + expect(result.success).toBe(true); + }); + + it("accepts null only for nullable fields", () => { + expect( + schema.safeParse({ category: REF, excerpt: null, title: "Hello" }) + .success, + ).toBe(true); + expect(schema.safeParse({ category: REF, title: null }).success).toBe( + false, + ); + }); +}); + +describe("humanizeFieldName", () => { + it.each([ + ["publishedAt", "Published at"], + ["title", "Title"], + ["author_id", "Author id"], + ["viewsCount", "Views count"], + ])("turns %s into %s", (input, expected) => { + expect(humanizeFieldName(input)).toBe(expected); + }); +}); diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts new file mode 100644 index 000000000..b86fa1a89 --- /dev/null +++ b/packages/vitnode/src/content/admin/spec.ts @@ -0,0 +1,318 @@ +import { z } from "zod"; + +import type { + AnyContentTypeDefinition, + ContentFieldDescriptor, + ContentFieldKind, +} from "../types"; + +/** + * A single form field, reduced to plain JSON. + * + * The AdminCP page is a server component but the form is a client one, and a + * content type definition cannot cross that boundary - `field.relation` holds a + * `target` thunk, and Zod schemas are not serialisable either. So the server + * projects the definition into this spec, and the client rebuilds the form + * schema from it with {@link buildFormSchemaFromSpec}. + */ +export interface ContentFormFieldSpec { + defaultValue?: boolean | null | number | string; + description?: string; + display?: "radio" | "select"; + integer?: boolean; + kind: ContentFieldKind; + label: string; + max?: number; + maxLength?: number; + min?: number; + minLength?: number; + name: string; + nullable: boolean; + /** Enum choices, already translated. */ + options?: { label: string; value: string }[]; + required: boolean; +} + +export interface ContentFormSpec { + contentTypeId: string; + fields: ContentFormFieldSpec[]; + pluginId: string; + /** Field the toast describes a newly created row by, if there is one. */ + titleField: null | string; +} + +export interface ContentColumnSpec { + kind: "system" | ContentFieldKind; + label: string; + name: string; + /** Enum value -> translated label, for badge cells. */ + options?: Record; +} + +export type ContentFieldLabeller = ( + name: string, + fieldValue?: ContentFieldDescriptor, +) => string; + +export type ContentEnumLabeller = (name: string, value: string) => string; + +const systemKinds: Record = { + createdAt: "system", + id: "system", + updatedAt: "system", +}; + +/** Projects a definition's form fields into the serialisable spec. */ +export const buildContentFormSpec = ({ + definition, + labelEnum, + labelField, + pluginId, +}: { + definition: AnyContentTypeDefinition; + labelEnum: ContentEnumLabeller; + labelField: ContentFieldLabeller; + pluginId: string; +}): ContentFormSpec => { + const fields = definition.fields; + + return { + contentTypeId: definition.id, + pluginId, + titleField: definition.admin.titleField, + fields: definition.admin.form.fields.map(name => { + const fieldValue = fields[name]; + const base: ContentFormFieldSpec = { + kind: fieldValue.kind, + label: labelField(name, fieldValue), + name, + nullable: fieldValue.nullable, + required: fieldValue.required, + ...(fieldValue.description === undefined + ? {} + : { description: fieldValue.description }), + }; + + switch (fieldValue.kind) { + case "boolean": + return { ...base, defaultValue: fieldValue.defaultValue }; + case "enum": + return { + ...base, + defaultValue: fieldValue.defaultValue, + display: fieldValue.display, + options: fieldValue.values.map(value => ({ + label: labelEnum(name, value), + value, + })), + }; + case "number": + return { + ...base, + defaultValue: fieldValue.defaultValue, + integer: fieldValue.integer, + max: fieldValue.max, + min: fieldValue.min, + }; + case "text": + case "textarea": + return { + ...base, + defaultValue: fieldValue.defaultValue, + maxLength: fieldValue.maxLength, + minLength: fieldValue.minLength, + }; + default: + return base; + } + }), + }; +}; + +/** Projects the list columns into the serialisable spec. */ +export const buildContentColumnSpec = ({ + definition, + labelEnum, + labelField, +}: { + definition: AnyContentTypeDefinition; + labelEnum: ContentEnumLabeller; + labelField: ContentFieldLabeller; +}): ContentColumnSpec[] => { + const fields = definition.fields; + + return definition.admin.list.columns.map(name => { + const fieldValue = fields[name] as ContentFieldDescriptor | undefined; + + return { + kind: systemKinds[name] ?? fieldValue?.kind ?? "system", + label: labelField(name, fieldValue), + name, + ...(fieldValue?.kind === "enum" + ? { + options: Object.fromEntries( + fieldValue.values.map(value => [value, labelEnum(name, value)]), + ), + } + : {}), + }; + }); +}; + +/** What `AutoFormCombobox` stores for a selected option. */ +export const referenceOptionSchema = z.object({ + label: z.string(), + value: z.string(), +}); + +export type ContentReferenceOption = z.infer; + +export const isReferenceKind = (kind: ContentFieldKind): boolean => + kind === "relation" || kind === "user"; + +const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { + switch (spec.kind) { + case "boolean": + return z.boolean(); + case "dateTime": + // ISO strings all the way through the form - `z.toJSONSchema`, which + // AutoForm runs on every schema, throws on `z.date()`. + return z.iso.datetime(); + case "enum": { + const values = (spec.options ?? []).map(option => option.value); + + return values.length > 0 + ? z.enum(values as [string, ...string[]]) + : z.string(); + } + case "number": { + // A number input hands react-hook-form a string, so the form schema + // coerces - `z.number()` would reject "0" and disable submit. + let schema = spec.integer ? z.coerce.number().int() : z.coerce.number(); + if (spec.min !== undefined) schema = schema.min(spec.min); + if (spec.max !== undefined) schema = schema.max(spec.max); + + return schema; + } + case "relation": + case "user": + // `AutoFormCombobox` holds the whole option, not the id - the same shape + // the blog plugin models by hand. `contentFormValuesToPayload` turns it + // back into an identifier on submit. + return referenceOptionSchema; + default: { + let schema = z.string(); + if (spec.minLength !== undefined) schema = schema.min(spec.minLength); + if (spec.maxLength !== undefined) schema = schema.max(spec.maxLength); + + return schema; + } + } +}; + +/** + * Field kinds whose input renders an empty string when it holds no value. Left + * as-is, `""` fails ISO-date and identifier validation and the form can never + * become valid. + */ +const EMPTY_MEANS_UNSET: ReadonlySet = new Set(["dateTime"]); + +/** + * The combobox needs the whole option to show a label, so an existing + * identifier is paired with the label the list query already resolved. + */ +const toInitialValue = ( + fieldSpec: ContentFormFieldSpec, + current: unknown, + labels: Record, +): unknown => { + if (!isReferenceKind(fieldSpec.kind)) return current; + if (current === null || current === undefined) return undefined; + + const id = typeof current === "number" ? current.toString() : ""; + + return { label: labels[fieldSpec.name] ?? id, value: id }; +}; + +/** + * The row's own title, for a toast that says what was just written. Falls back + * to nothing when the content type declares no title field. + */ +export const contentTitleFromValues = ( + spec: ContentFormSpec, + values: Record, +): string | undefined => { + if (spec.titleField === null) return undefined; + + const value = values[spec.titleField]; + + return typeof value === "string" && value.trim() !== "" ? value : undefined; +}; + +/** Turns validated form values into the payload the generated API accepts. */ +export const contentFormValuesToPayload = ( + spec: ContentFormSpec, + values: Record, +): Record => + Object.fromEntries( + Object.entries(values).map(([name, value]) => { + const fieldSpec = spec.fields.find(item => item.name === name); + if (!fieldSpec || !isReferenceKind(fieldSpec.kind)) return [name, value]; + + const option = value as ContentReferenceOption | null | undefined; + if (!option?.value) return [name, null]; + + return [name, Number(option.value)]; + }), + ); + +/** + * Rebuilds the AutoForm schema on the client. + * + * Mirrors the server's create schema, with two differences: existing values are + * folded in as Zod defaults so `AutoForm`'s `getDefaults` prefills the edit + * form, and every rule is written against what the DOM actually produces - + * strings from number inputs, `""` from a cleared picker. + */ +export const buildFormSchemaFromSpec = ( + spec: ContentFormSpec, + values?: Record, +): z.ZodObject => + z.object( + Object.fromEntries( + spec.fields.map(fieldSpec => { + const base = + isReferenceKind(fieldSpec.kind) && fieldSpec.required + ? baseFieldSchema(fieldSpec).refine( + option => (option as ContentReferenceOption).value !== "", + ) + : baseFieldSchema(fieldSpec); + const nullable = fieldSpec.nullable ? base.nullable() : base; + const labels = (values?.labels ?? {}) as Record; + const current = toInitialValue( + fieldSpec, + values?.[fieldSpec.name], + labels, + ); + const initial = + current === undefined ? fieldSpec.defaultValue : current; + + let schema: z.ZodType; + if (initial !== undefined) { + schema = nullable.default(initial); + } else { + schema = fieldSpec.required ? nullable : nullable.optional(); + } + + if (EMPTY_MEANS_UNSET.has(fieldSpec.kind)) { + const unset = fieldSpec.nullable ? null : undefined; + schema = z.preprocess( + value => (value === "" ? unset : value), + schema, + ); + } + + return [fieldSpec.name, schema]; + }), + ), + ); diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts new file mode 100644 index 000000000..05f196bf0 --- /dev/null +++ b/packages/vitnode/src/content/const.ts @@ -0,0 +1,78 @@ +/** + * Columns the Content Engine always adds. They can never be declared as + * content fields - `defineContentType` rejects them. + */ +export const CONTENT_SYSTEM_FIELDS = ["id", "createdAt", "updatedAt"] as const; + +/** + * Field kinds a generated equality filter understands. + * + * `textarea` and `dateTime` are absent on purpose: equality against a body of + * prose, or against one exact timestamp, is never what anyone means. + * + * One list, three consumers - the filter schema, the query builder and the + * public service types all derive from it, so they cannot drift apart. + */ +export const CONTENT_FILTERABLE_FIELD_KINDS = [ + "boolean", + "enum", + "number", + "relation", + "text", + "user", +] as const; + +const filterableFieldKinds: ReadonlySet = new Set( + CONTENT_FILTERABLE_FIELD_KINDS, +); + +/** + * Whether a field of this kind may back a generated equality filter. + * + * Takes a plain `string` rather than `ContentFieldKind` so this module stays + * free of type imports from `types.ts`, which imports from here. + */ +export const isFilterableFieldKind = (kind: string): boolean => + filterableFieldKinds.has(kind); + +/** + * Query-string keys owned by pagination and ordering. A filter may not use one + * of these names or it would silently shadow the pagination contract. + */ +export const RESERVED_FILTER_KEYS = [ + "cursor", + "first", + "last", + "order", + "orderBy", + "search", +] as const; + +/** `plugin.entity`, e.g. `example.article`. */ +export const CONTENT_ID_PATTERN = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/; + +/** Postgres identifier: snake_case, starts with a letter. */ +export const CONTENT_TABLE_NAME_PATTERN = /^[a-z][a-z0-9_]*$/; + +/** camelCase, matching `casing: "camelCase"` on the Drizzle client. */ +export const CONTENT_FIELD_NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/; + +/** Explicit index names follow the same snake_case rule as table names. */ +export const CONTENT_INDEX_NAME_PATTERN = CONTENT_TABLE_NAME_PATTERN; + +/** Postgres silently truncates identifiers past this length. */ +export const CONTENT_IDENTIFIER_MAX_LENGTH = 63; + +export const CONTENT_TEXT_DEFAULT_LENGTH = 255; +export const CONTENT_ENUM_DEFAULT_LENGTH = 64; + +export const CONTENT_DEFAULT_PAGE_SIZE = 25; +export const CONTENT_OPTIONS_LIMIT = 25; + +/** Every content type gets these four staff permissions. */ +export const CONTENT_PERMISSIONS = { + create: "can_create", + delete: "can_delete", + edit: "can_edit", + view: "can_view", +} as const; diff --git a/packages/vitnode/src/content/define.test-d.ts b/packages/vitnode/src/content/define.test-d.ts new file mode 100644 index 000000000..659bec45e --- /dev/null +++ b/packages/vitnode/src/content/define.test-d.ts @@ -0,0 +1,181 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { + ContentCreateInput, + ContentSelect, + ContentUpdateInput, + HasColumnDefault, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Article = typeof testArticleContentType; +type Select = ContentSelect
; +type Create = ContentCreateInput
; +type Update = ContentUpdateInput
; + +describe("content type inference", () => { + it("keeps the content type id literal", () => { + expectTypeOf(testArticleContentType.id).toEqualTypeOf<"test.article">(); + expectTypeOf(testCategoryContentType.id).toEqualTypeOf<"test.category">(); + }); + + describe("select output", () => { + it("adds the system columns", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("narrows enums to their literal union", () => { + expectTypeOf().toEqualTypeOf< + "archived" | "draft" | "published" + >(); + }); + + it("distinguishes nullable from non-nullable", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("types relation and user values as row identifiers", () => { + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe("create input", () => { + it("excludes the generated system fields", () => { + expectTypeOf().not.toHaveProperty("id"); + expectTypeOf().not.toHaveProperty("createdAt"); + expectTypeOf().not.toHaveProperty("updatedAt"); + }); + + it("requires only the fields marked required", () => { + expectTypeOf().toEqualTypeOf< + | "author" + | "category" + | "excerpt" + | "featured" + | "publishedAt" + | "status" + | "title" + | "views" + >(); + + assertType({ title: "Hello", category: 1 }); + // @ts-expect-error - `title` is required + assertType({ category: 1 }); + // @ts-expect-error - `category` is required + assertType({ title: "Hello" }); + }); + + it("serializes dateTime as an ISO string on the way in", () => { + expectTypeOf().toEqualTypeOf< + null | string | undefined + >(); + }); + + it("rejects a value outside the enum", () => { + // @ts-expect-error - "nope" is not a declared status + assertType({ title: "Hello", category: 1, status: "nope" }); + }); + + it("rejects a wrong primitive type", () => { + // @ts-expect-error - `views` is a number + assertType({ title: "Hello", category: 1, views: "many" }); + }); + + it("rejects null for a non-nullable field", () => { + // @ts-expect-error - `title` is not nullable + assertType({ title: null, category: 1 }); + }); + }); + + describe("update input", () => { + it("makes every editable field optional", () => { + assertType({}); + assertType({ title: "Only the title" }); + }); + + it("still rejects unknown and wrong-typed fields", () => { + // @ts-expect-error - `slug` is not a field + assertType({ slug: "nope" }); + // @ts-expect-error - `featured` is a boolean + assertType({ featured: "yes" }); + }); + }); + + describe("reserved fields", () => { + it("cannot be declared", () => { + defineContentType({ + id: "test.reserved", + tableName: "test_reserved", + fields: { + title: field.text({ required: true }), + // @ts-expect-error - `id` is a reserved system column + id: field.number({ integer: true, required: true }), + }, + admin: { label: { plural: "Reserved", singular: "Reserved" } }, + }); + }); + }); + + describe("field builders", () => { + it("defaults required and nullable to false", () => { + const plain = field.text({ defaultValue: "" }); + expectTypeOf(plain.required).toEqualTypeOf(); + expectTypeOf(plain.nullable).toEqualTypeOf(); + }); + + it("keeps required and nullable literal when set", () => { + const both = field.text({ required: true, nullable: true }); + expectTypeOf(both.required).toEqualTypeOf(); + expectTypeOf(both.nullable).toEqualTypeOf(); + }); + + it("keeps enum values as a readonly literal tuple", () => { + const status = field.enum({ values: ["draft", "published"] }); + expectTypeOf(status.values).toEqualTypeOf< + readonly ["draft", "published"] + >(); + }); + + it("keeps the declared default literal, so `hasDefault` is knowable", () => { + expectTypeOf( + field.enum({ values: ["draft", "published"], defaultValue: "draft" }) + .defaultValue, + ).toEqualTypeOf<"draft">(); + expectTypeOf( + field.enum({ values: ["draft", "published"] }).defaultValue, + ).toEqualTypeOf(); + }); + }); + + describe("column defaults", () => { + type Fields = (typeof testArticleContentType)["fields"]; + + it("marks declared defaults", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + HasColumnDefault + >().toEqualTypeOf(); + }); + + it("leaves undefaulted fields alone", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + HasColumnDefault + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + }); +}); diff --git a/packages/vitnode/src/content/define.test.ts b/packages/vitnode/src/content/define.test.ts new file mode 100644 index 000000000..864761e48 --- /dev/null +++ b/packages/vitnode/src/content/define.test.ts @@ -0,0 +1,415 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { ContentUserField } from "./types"; + +import { defineContentType } from "./define"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; + +const label = { plural: "Widgets", singular: "Widget" }; + +const define = ( + overrides: Partial[0]> = {}, +) => + defineContentType({ + id: "test.widget", + tableName: "test_widgets", + fields: { title: field.text({ required: true }) }, + admin: { label }, + ...overrides, + }); + +describe("defineContentType", () => { + describe("identifiers", () => { + it.each([ + ["Article", "not dotted or lowercase"], + ["example.", "trailing dot"], + ["example.Article", "uppercase segment"], + ["example_article", "underscore instead of dot"], + ])("rejects the id %s (%s)", id => { + expect(() => define({ id })).toThrow(ContentEngineError); + }); + + it("accepts a dotted lowercase id", () => { + expect(define({ id: "example.knowledge-article" }).id).toBe( + "example.knowledge-article", + ); + }); + + it.each(["Test_Widgets", "1widgets", "test-widgets"])( + "rejects the table name %s", + tableName => { + expect(() => define({ tableName })).toThrow(ContentEngineError); + }, + ); + + it("rejects a table name past the Postgres identifier limit", () => { + expect(() => define({ tableName: "a".repeat(64) })).toThrow( + /identifier limit/, + ); + }); + }); + + describe("fields", () => { + it.each(["id", "createdAt", "updatedAt"])( + "rejects the reserved field name %s", + name => { + expect(() => + define({ fields: { [name]: field.text({ required: true }) } }), + ).toThrow(/reserved system column/); + }, + ); + + it("rejects a field name that is not camelCase", () => { + expect(() => + define({ fields: { Title: field.text({ required: true }) } }), + ).toThrow(/camelCase/); + }); + + it("rejects a content type with no fields", () => { + expect(() => define({ fields: {} })).toThrow(/at least one field/); + }); + + it("rejects a field that is neither required, nullable, nor defaulted", () => { + expect(() => define({ fields: { title: field.text() } })).toThrow( + /needs a default value/, + ); + }); + + it("accepts a defaulted field that is neither required nor nullable", () => { + expect(() => + define({ + fields: { views: field.number({ integer: true, defaultValue: 0 }) }, + }), + ).not.toThrow(); + }); + + it("accepts a dateTime with defaultNow instead of a default value", () => { + expect(() => + define({ fields: { seenAt: field.dateTime({ defaultNow: true }) } }), + ).not.toThrow(); + }); + + it("rejects minLength greater than maxLength", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true, minLength: 10, maxLength: 5 }), + }, + }), + ).toThrow(/minLength 10 greater than maxLength 5/); + }); + + it("rejects min greater than max", () => { + expect(() => + define({ + fields: { + views: field.number({ + required: true, + integer: true, + min: 10, + max: 1, + }), + }, + }), + ).toThrow(/min 10 greater than max 1/); + }); + + it("rejects duplicate enum values", () => { + expect(() => + define({ + fields: { + status: field.enum({ required: true, values: ["a", "b", "a"] }), + }, + }), + ).toThrow(/duplicate enum values/); + }); + + it("rejects an enum default that is not one of its values", () => { + expect(() => + define({ + fields: { + // The type already rules this out; the runtime guard covers plain + // JS consumers and `as` escapes. + // @ts-expect-error - "nope" is not in `values` + status: field.enum({ values: ["draft"], defaultValue: "nope" }), + }, + }), + ).toThrow(/not one of its values/); + }); + + it("rejects an enum value longer than the column length", () => { + expect(() => + define({ + fields: { + status: field.enum({ + required: true, + length: 4, + values: ["draft", "ok"], + }), + }, + }), + ).toThrow(/longer than the column length 4/); + }); + }); + + // `ON DELETE SET NULL` on a NOT NULL column is accepted by Postgres at + // CREATE TABLE time and only blows up years later, when someone finally + // deletes a referenced row. + describe("reference onDelete", () => { + const withField = (fieldValue: ContentUserField) => + define({ fields: { owner: fieldValue } }); + + it("rejects a non-nullable user field with `set null`", () => { + expect(() => + withField( + field.user({ nullable: false, onDelete: "set null", required: true }), + ), + ).toThrow(/not nullable/); + }); + + it("rejects a non-nullable relation with `set null`", () => { + expect(() => + define({ + fields: { + category: field.relation({ + nullable: false, + onDelete: "set null", + required: true, + target: () => testCategoryContentType, + }), + }, + }), + ).toThrow(/not nullable/); + }); + + it("names the field and the content type in the message", () => { + expect(() => + withField( + field.user({ nullable: false, onDelete: "set null", required: true }), + ), + ).toThrow(/test\.widget: Field "owner"/); + }); + + it("accepts a nullable user field with `set null`", () => { + expect(() => + withField(field.user({ nullable: true, onDelete: "set null" })), + ).not.toThrow(); + }); + + it("accepts a nullable relation with `set null`", () => { + expect(() => + define({ + fields: { + category: field.relation({ + nullable: true, + onDelete: "set null", + target: () => testCategoryContentType, + }), + }, + }), + ).not.toThrow(); + }); + + it.each(["cascade", "restrict"] as const)( + "accepts a non-nullable relation with %s", + onDelete => { + expect(() => + define({ + fields: { + category: field.relation({ + onDelete, + required: true, + target: () => testCategoryContentType, + }), + }, + }), + ).not.toThrow(); + }, + ); + + describe("defaults", () => { + it("makes a bare user field nullable with `set null`", () => { + const owner = withField(field.user()).fields.owner; + + expect(owner).toMatchObject({ nullable: true, onDelete: "set null" }); + }); + + it("falls back to `restrict` when the user field is not nullable", () => { + const owner = withField(field.user({ nullable: false, required: true })) + .fields.owner; + + expect(owner).toMatchObject({ nullable: false, onDelete: "restrict" }); + }); + + it("defaults a relation to `restrict`", () => { + const definition = define({ + fields: { + category: field.relation({ + required: true, + target: () => testCategoryContentType, + }), + }, + }); + + expect(definition.fields.category).toMatchObject({ + nullable: false, + onDelete: "restrict", + }); + }); + }); + }); + + describe("indexes", () => { + it("expands the automatic indexes onto the definition", () => { + expect( + define({ + fields: { title: field.text({ required: true, unique: true }) }, + }).indexes, + ).toEqual([ + { name: "test_widgets_title_key", on: ["title"], unique: true }, + { + name: "test_widgets_created_at_idx", + on: ["createdAt"], + unique: false, + }, + { + name: "test_widgets_updated_at_idx", + on: ["updatedAt"], + unique: false, + }, + ]); + }); + + it("rejects an index on an unknown column", () => { + expect(() => define({ indexes: [{ on: ["nope"] }] })).toThrow( + /unknown field "nope"/, + ); + }); + }); + + describe("admin defaults", () => { + const definition = define({ + fields: { + title: field.text({ required: true }), + body: field.textarea({ nullable: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + }); + + it("defaults navigation to enabled", () => { + expect(definition.admin.navigation.enabled).toBe(true); + }); + + it("defaults ordering to updatedAt desc", () => { + expect(definition.admin.list.defaultOrderBy).toBe("updatedAt"); + expect(definition.admin.list.defaultOrder).toBe("desc"); + }); + + it("defaults searchable fields to every text and textarea field", () => { + expect(definition.admin.list.searchableFields).toEqual(["title", "body"]); + }); + + it("defaults the title field to the first text field", () => { + expect(definition.admin.titleField).toBe("title"); + }); + + it("defaults the form to every field in declaration order", () => { + expect(definition.admin.form.fields).toEqual(["title", "body", "views"]); + }); + + it("derives the permission module from the plural label", () => { + expect(define({ admin: { label } }).permissionModule).toBe("widgets"); + expect( + define({ + admin: { label: { plural: "Knowledge Articles", singular: "x" } }, + }).permissionModule, + ).toBe("knowledge_articles"); + }); + + it("prefers an explicit permission module", () => { + expect( + define({ admin: { label, permissionModule: "kb_articles" } }) + .permissionModule, + ).toBe("kb_articles"); + }); + }); + + describe("admin validation", () => { + it("rejects a searchable field that is not text-like", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + admin: { label, list: { searchableFields: ["views"] } }, + }), + ).toThrow(/not a text or textarea field/); + }); + + it.each([ + ["list.columns", { list: { columns: ["nope"] } }], + ["list.orderableFields", { list: { orderableFields: ["nope"] } }], + ["form.fields", { form: { fields: ["nope"] } }], + ["titleField", { titleField: "nope" }], + ])("rejects an unknown field in admin.%s", (_name, adminOverrides) => { + expect(() => define({ admin: { label, ...adminOverrides } })).toThrow( + /unknown field "nope"/, + ); + }); + + it("rejects a defaultOrderBy that is not allowlisted", () => { + expect(() => + define({ + fields: { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + admin: { label, list: { defaultOrderBy: "views" } }, + }), + ).toThrow(/not in admin.list.orderableFields/); + }); + + it("allows a system column as defaultOrderBy without allowlisting it", () => { + expect(() => + define({ admin: { label, list: { defaultOrderBy: "createdAt" } } }), + ).not.toThrow(); + }); + + it("rejects an index over an unknown column", () => { + expect(() => define({ indexes: [{ on: ["nope"] }] })).toThrow( + /indexes references unknown field "nope"/, + ); + }); + + it("allows an index over a system column", () => { + expect(() => + define({ indexes: [{ on: ["title", "createdAt"] }] }), + ).not.toThrow(); + }); + }); + + describe("fixtures", () => { + it("resolves the article fixture", () => { + expect(testArticleContentType.permissionModule).toBe("test_articles"); + expect(testArticleContentType.admin.list.searchableFields).toEqual([ + "title", + "excerpt", + ]); + expect(testArticleContentType.admin.titleField).toBe("title"); + }); + + it("resolves relation targets lazily", () => { + const { category } = testArticleContentType.fields; + expect(category.kind).toBe("relation"); + expect(category.target().id).toBe(testCategoryContentType.id); + }); + }); +}); diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts new file mode 100644 index 000000000..ed35a9cbf --- /dev/null +++ b/packages/vitnode/src/content/define.ts @@ -0,0 +1,372 @@ +import type { + ContentAdminConfig, + ContentFieldDescriptor, + ContentFieldMap, + ContentFieldsConstraint, + ContentIndexInput, + ContentTypeDefinition, + ResolvedContentAdminConfig, +} from "./types"; + +import { + CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_FIELD_NAME_PATTERN, + CONTENT_ID_PATTERN, + CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_SYSTEM_FIELDS, + CONTENT_TABLE_NAME_PATTERN, +} from "./const"; +import { ContentEngineError } from "./errors"; +import { resolveContentIndexes } from "./indexes"; +import { buildContentSchemas } from "./schemas"; + +const SEARCHABLE_KINDS = new Set([ + "text", + "textarea", +]); + +const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS; + +const slugifyModule = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +/** A field with no default that is neither required nor nullable is unwritable. */ +const hasWritableFallback = (fieldValue: ContentFieldDescriptor): boolean => { + if (fieldValue.kind === "dateTime") return fieldValue.defaultNow; + if (fieldValue.kind === "relation" || fieldValue.kind === "user") { + return false; + } + + return fieldValue.defaultValue !== undefined; +}; + +const assertFieldName = (id: string, name: string): void => { + if (systemFields.includes(name)) { + throw new ContentEngineError( + `"${name}" is a reserved system column and cannot be declared as a field.`, + { contentTypeId: id }, + ); + } + + if (!CONTENT_FIELD_NAME_PATTERN.test(name)) { + throw new ContentEngineError( + `Field "${name}" must be camelCase and start with a lowercase letter.`, + { contentTypeId: id }, + ); + } +}; + +const FIELD_KINDS = new Set([ + "boolean", + "dateTime", + "enum", + "number", + "relation", + "text", + "textarea", + "user", +]); + +/** Guards the widening of `ContentFieldsConstraint` to `ContentFieldMap`. */ +const assertFieldKind = ( + id: string, + name: string, + fieldValue: ContentFieldDescriptor, +): void => { + if (!FIELD_KINDS.has(fieldValue?.kind)) { + throw new ContentEngineError( + `Field "${name}" is not a field descriptor. Build it with \`field.text()\`, \`field.enum()\`, and so on.`, + { contentTypeId: id }, + ); + } +}; + +const assertField = ( + id: string, + name: string, + fieldValue: ContentFieldDescriptor, +): void => { + if (!fieldValue.required && !fieldValue.nullable) { + if (!hasWritableFallback(fieldValue)) { + throw new ContentEngineError( + `Field "${name}" is neither required nor nullable, so it needs a default value - otherwise a row could never be inserted.`, + { contentTypeId: id }, + ); + } + } + + if (fieldValue.kind === "relation" || fieldValue.kind === "user") { + // Postgres would accept the definition and then fail at delete time, when + // it tries to write NULL into a NOT NULL column. + if (fieldValue.onDelete === "set null" && !fieldValue.nullable) { + throw new ContentEngineError( + `Field "${name}" is \`onDelete: "set null"\` but not nullable, so deleting the referenced row would violate NOT NULL. Add \`nullable: true\`, or switch to \`onDelete: "restrict"\` or \`"cascade"\`.`, + { contentTypeId: id }, + ); + } + } + + if (fieldValue.kind === "text" || fieldValue.kind === "textarea") { + const { maxLength, minLength } = fieldValue; + if (maxLength !== undefined && maxLength <= 0) { + throw new ContentEngineError( + `Field "${name}" has a maxLength of ${maxLength}; it must be positive.`, + { contentTypeId: id }, + ); + } + if ( + minLength !== undefined && + maxLength !== undefined && + minLength > maxLength + ) { + throw new ContentEngineError( + `Field "${name}" has minLength ${minLength} greater than maxLength ${maxLength}.`, + { contentTypeId: id }, + ); + } + } + + if (fieldValue.kind === "number") { + const { max, min } = fieldValue; + if (min !== undefined && max !== undefined && min > max) { + throw new ContentEngineError( + `Field "${name}" has min ${min} greater than max ${max}.`, + { contentTypeId: id }, + ); + } + } + + if (fieldValue.kind === "enum") { + const { defaultValue, length = CONTENT_ENUM_DEFAULT_LENGTH } = fieldValue; + const values: readonly string[] = fieldValue.values; + + if (values.length === 0) { + throw new ContentEngineError( + `Field "${name}" needs at least one value.`, + { + contentTypeId: id, + }, + ); + } + if (new Set(values).size !== values.length) { + throw new ContentEngineError( + `Field "${name}" has duplicate enum values.`, + { contentTypeId: id }, + ); + } + const tooLong = values.find(value => value.length > length); + if (tooLong !== undefined) { + throw new ContentEngineError( + `Field "${name}" value "${tooLong}" is longer than the column length ${length}. Raise \`length\` on the field.`, + { contentTypeId: id }, + ); + } + if (defaultValue !== undefined && !values.includes(defaultValue)) { + throw new ContentEngineError( + `Field "${name}" has default "${defaultValue}", which is not one of its values.`, + { contentTypeId: id }, + ); + } + } +}; + +const assertKnownColumns = ( + id: string, + label: string, + names: readonly string[], + known: ReadonlySet, +): void => { + const unknown = names.find(name => !known.has(name)); + if (unknown !== undefined) { + throw new ContentEngineError( + `${label} references unknown field "${unknown}".`, + { contentTypeId: id }, + ); + } +}; + +const resolveAdmin = ( + id: string, + fields: ContentFieldMap, + admin: ContentAdminConfig, +): ResolvedContentAdminConfig => { + const fieldNames = Object.keys(fields); + const knownColumns = new Set([...fieldNames, ...systemFields]); + + const searchableFields = ( + admin.list?.searchableFields?.map(String) ?? + fieldNames.filter(name => SEARCHABLE_KINDS.has(fields[name].kind)) + ).map(String); + assertKnownColumns( + id, + "admin.list.searchableFields", + searchableFields, + new Set(fieldNames), + ); + const notSearchable = searchableFields.find( + name => !SEARCHABLE_KINDS.has(fields[name].kind), + ); + if (notSearchable !== undefined) { + throw new ContentEngineError( + `admin.list.searchableFields includes "${notSearchable}", which is not a text or textarea field.`, + { contentTypeId: id }, + ); + } + + const orderableFields = (admin.list?.orderableFields ?? []).map(String); + assertKnownColumns( + id, + "admin.list.orderableFields", + orderableFields, + new Set(fieldNames), + ); + + const columns = ( + admin.list?.columns?.map(String) ?? [...fieldNames, "updatedAt"] + ).map(String); + assertKnownColumns(id, "admin.list.columns", columns, knownColumns); + + const formFields = (admin.form?.fields?.map(String) ?? fieldNames).map( + String, + ); + assertKnownColumns(id, "admin.form.fields", formFields, new Set(fieldNames)); + + const defaultOrderBy = String(admin.list?.defaultOrderBy ?? "updatedAt"); + if ( + !systemFields.includes(defaultOrderBy) && + !orderableFields.includes(defaultOrderBy) + ) { + throw new ContentEngineError( + `admin.list.defaultOrderBy is "${defaultOrderBy}", which is not in admin.list.orderableFields.`, + { contentTypeId: id }, + ); + } + + const titleField = + admin.titleField === undefined + ? (fieldNames.find(name => SEARCHABLE_KINDS.has(fields[name].kind)) ?? + null) + : String(admin.titleField); + if (titleField !== null && !fieldNames.includes(titleField)) { + throw new ContentEngineError( + `admin.titleField references unknown field "${titleField}".`, + { contentTypeId: id }, + ); + } + + return { + form: { fields: formFields }, + label: admin.label, + list: { + columns, + defaultOrder: admin.list?.defaultOrder ?? "desc", + defaultOrderBy, + orderableFields, + searchableFields, + }, + navigation: { enabled: admin.navigation?.enabled ?? true }, + titleField, + }; +}; + +/** + * Declares a content type. The result is plain data - zod and objects only - + * so the same definition can be imported by `buildPlugin` (client) and by + * `createContentModel` in `src/database/*.ts` (server) without dragging Drizzle + * into a client bundle. + */ +export const defineContentType = < + TId extends string, + TFields extends ContentFieldsConstraint, +>({ + admin, + fields, + id, + indexes = [], + tableName, +}: { + admin: ContentAdminConfig; + fields: TFields; + id: TId; + indexes?: ContentIndexInput[]; + tableName: string; +}): ContentTypeDefinition => { + if (!CONTENT_ID_PATTERN.test(id)) { + throw new ContentEngineError( + `Content type id "${id}" must look like "plugin.entity" (lowercase, dot separated).`, + ); + } + + if (!CONTENT_TABLE_NAME_PATTERN.test(tableName)) { + throw new ContentEngineError( + `Table name "${tableName}" must be snake_case and start with a letter.`, + { contentTypeId: id }, + ); + } + + if (tableName.length > CONTENT_IDENTIFIER_MAX_LENGTH) { + throw new ContentEngineError( + `Table name "${tableName}" is longer than the Postgres identifier limit of ${CONTENT_IDENTIFIER_MAX_LENGTH} characters.`, + { contentTypeId: id }, + ); + } + + // `ContentFieldsConstraint` only pins `kind` (see its doc comment), so widen + // to the real descriptor union here. This is the only unchecked widening in + // the engine, and `assertFieldKind` below makes it true at runtime for + // anything that skipped the `field.*` builders. + const fieldMap = fields as unknown as ContentFieldMap; + const fieldNames = Object.keys(fieldMap); + if (fieldNames.length === 0) { + throw new ContentEngineError("A content type needs at least one field.", { + contentTypeId: id, + }); + } + + for (const name of fieldNames) { + assertFieldName(id, name); + assertFieldKind(id, name, fieldMap[name]); + assertField(id, name, fieldMap[name]); + } + + const knownColumns = new Set([...fieldNames, ...systemFields]); + const resolvedIndexes = resolveContentIndexes({ + contentTypeId: id, + declared: indexes.map(index => { + const on = index.on.map(String); + assertKnownColumns(id, "indexes", on, knownColumns); + + return { ...index, on }; + }), + fields: fieldMap, + tableName, + }); + + const resolvedAdmin = resolveAdmin(id, fieldMap, admin); + const permissionModule = + admin.permissionModule ?? slugifyModule(admin.label.plural); + + if (!CONTENT_TABLE_NAME_PATTERN.test(permissionModule)) { + throw new ContentEngineError( + `Could not derive a permission module name from label.plural "${admin.label.plural}". Set \`admin.permissionModule\` explicitly.`, + { contentTypeId: id }, + ); + } + + return { + admin: resolvedAdmin, + fields, + id, + indexes: resolvedIndexes, + permissionModule, + schemas: buildContentSchemas>({ + admin: resolvedAdmin, + fields: fieldMap, + }), + tableName, + }; +}; diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts new file mode 100644 index 000000000..c21815c71 --- /dev/null +++ b/packages/vitnode/src/content/errors.ts @@ -0,0 +1,23 @@ +/** + * Thrown while a content type definition is being built or registered - always + * at import/boot time, never per request. The message names the offending + * content type so a misconfigured plugin fails loudly and obviously. + */ +export class ContentEngineError extends Error { + constructor( + message: string, + options?: { cause?: unknown; contentTypeId?: string }, + ) { + super( + options?.contentTypeId + ? `[Content Engine] ${options.contentTypeId}: ${message}` + : `[Content Engine] ${message}`, + { cause: options?.cause }, + ); + + this.name = "ContentEngineError"; + this.contentTypeId = options?.contentTypeId; + } + + readonly contentTypeId: string | undefined; +} diff --git a/packages/vitnode/src/content/events.test-d.ts b/packages/vitnode/src/content/events.test-d.ts new file mode 100644 index 000000000..f35a0cbea --- /dev/null +++ b/packages/vitnode/src/content/events.test-d.ts @@ -0,0 +1,74 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import type { VitNodeEvents } from "../api/models/events"; +import type { ContentEventsFor } from "./events"; + +import { contentEventName } from "./events"; + +type ArticleEvents = ContentEventsFor; + +// The pattern plugins use. It compiles only if the mapped keys are statically +// known, which is exactly what makes the whole approach viable. +declare module "../api/models/events" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the members come from the mapped type + interface VitNodeEvents extends ContentEventsFor< + typeof testArticleContentType + > {} +} + +describe("content events", () => { + it("builds literal event names", () => { + expectTypeOf( + contentEventName(testArticleContentType.id, "created"), + ).toEqualTypeOf<"content.test.article.created">(); + expectTypeOf( + contentEventName(testArticleContentType.id, "deleted"), + ).toEqualTypeOf<"content.test.article.deleted">(); + }); + + it("keys the event map by literal name", () => { + expectTypeOf().toEqualTypeOf< + | "content.test.article.created" + | "content.test.article.deleted" + | "content.test.article.updated" + >(); + }); + + it("carries only the content identifier on create and delete", () => { + expectTypeOf< + ArticleEvents["content.test.article.created"] + >().toEqualTypeOf<{ contentId: number }>(); + expectTypeOf< + ArticleEvents["content.test.article.deleted"] + >().toEqualTypeOf<{ contentId: number }>(); + }); + + it("narrows changedFields to the content type's own field names", () => { + type Updated = ArticleEvents["content.test.article.updated"]; + + expectTypeOf().toEqualTypeOf< + ( + | "author" + | "category" + | "excerpt" + | "featured" + | "publishedAt" + | "status" + | "title" + | "views" + )[] + >(); + + assertType({ changedFields: ["title"], contentId: 1 }); + // @ts-expect-error - "slug" is not a field on this content type + assertType({ changedFields: ["slug"], contentId: 1 }); + }); + + it("registers the events on the global map", () => { + expectTypeOf< + VitNodeEvents["content.test.article.created"] + >().toEqualTypeOf<{ contentId: number }>(); + }); +}); diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts new file mode 100644 index 000000000..08f4ac28a --- /dev/null +++ b/packages/vitnode/src/content/events.ts @@ -0,0 +1,52 @@ +import type { ContentFieldName } from "./types"; + +export type ContentEventAction = "created" | "deleted" | "updated"; + +export interface ContentCreatedPayload { + contentId: number; +} + +export interface ContentDeletedPayload { + contentId: number; +} + +export interface ContentUpdatedPayload { + changedFields: ContentFieldName[]; + contentId: number; +} + +/** + * The three events a content type emits, as a literal-keyed map. + * + * Plugins graft these onto the global event map with one declaration - the + * same module-augmentation mechanism every other VitNode event uses: + * + * ```ts + * declare module "@vitnode/core/api/models/events" { + * interface VitNodeEvents + * extends ContentEventsFor {} + * } + * ``` + * + * `TDefinition` is concrete at the augmentation site, so the keys are + * statically known and `changedFields` narrows to the content type's own field + * names. The envelope already carries the actor, plugin and timestamp, so the + * payloads stay minimal. + */ +export type ContentEventsFor = Record< + `content.${TDefinition["id"]}.created`, + ContentCreatedPayload +> & + Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & + Record< + `content.${TDefinition["id"]}.updated`, + ContentUpdatedPayload + >; + +export const contentEventName = < + TId extends string, + TAction extends ContentEventAction, +>( + contentTypeId: TId, + action: TAction, +): `content.${TId}.${TAction}` => `content.${contentTypeId}.${action}`; diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts new file mode 100644 index 000000000..825b6054b --- /dev/null +++ b/packages/vitnode/src/content/fields.ts @@ -0,0 +1,189 @@ +import type { + AnyContentTypeDefinition, + ContentBooleanField, + ContentDateTimeField, + ContentEnumField, + ContentNumberField, + ContentOnDelete, + ContentRelationField, + ContentTextareaField, + ContentTextField, + ContentUserField, +} from "./types"; + +interface SharedArgs< + TRequired extends boolean = false, + TNullable extends boolean = false, +> { + description?: string; + nullable?: TNullable; + required?: TRequired; +} + +/** + * `required` and `nullable` default to `false`. The assertions keep the literal + * type parameter the caller inferred - `?? false` alone would widen it back to + * `boolean` and every downstream `nullable extends true` check would break. + */ +const shared = ( + args: SharedArgs, +): { nullable: TNullable; required: TRequired } => ({ + nullable: (args.nullable ?? false) as TNullable, + required: (args.required ?? false) as TRequired, +}); + +const text = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends string | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + unique?: boolean; + } = {}, +): ContentTextField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "text", +}); + +const textarea = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends string | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + } = {}, +): ContentTextareaField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "textarea", +}); + +const number = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends number | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + integer: boolean; + max?: number; + min?: number; + }, +): ContentNumberField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "number", +}); + +const boolean = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends boolean | undefined = undefined, +>( + args: SharedArgs & { defaultValue?: TDefault } = {}, +): ContentBooleanField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "boolean", +}); + +const enumField = < + const TValues extends readonly [string, ...string[]], + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefault extends TValues[number] | undefined = undefined, +>( + args: SharedArgs & { + defaultValue?: TDefault; + display?: "radio" | "select"; + length?: number; + values: TValues; + }, +): ContentEnumField => ({ + ...args, + ...shared(args), + defaultValue: args.defaultValue as TDefault, + kind: "enum", +}); + +const dateTime = < + TRequired extends boolean = false, + TNullable extends boolean = false, + TDefaultNow extends boolean = false, +>( + args: SharedArgs & { defaultNow?: TDefaultNow } = {}, +): ContentDateTimeField => ({ + ...args, + ...shared(args), + defaultNow: (args.defaultNow ?? false) as TDefaultNow, + kind: "dateTime", +}); + +/** + * A reference to a VitNode user. + * + * The only field builder whose `nullable` defaults to `true`, matching how + * every hand-written VitNode table stores an author (`blog_posts.authorId` is + * nullable with `ON DELETE SET NULL`): accounts get deleted, and their content + * should outlive them rather than disappear or block the deletion. Pass + * `nullable: false` and the `onDelete` default moves to `"restrict"`, because + * `"set null"` on a `NOT NULL` column is rejected at definition time. + */ +const user = < + TRequired extends boolean = false, + TNullable extends boolean = true, +>( + args: SharedArgs & { onDelete?: ContentOnDelete } = {}, +): ContentUserField => { + const nullable = (args.nullable ?? true) as TNullable; + + return { + ...args, + nullable, + required: (args.required ?? false) as TRequired, + kind: "user", + onDelete: args.onDelete ?? (nullable ? "set null" : "restrict"), + }; +}; + +const relation = < + TRequired extends boolean = false, + TNullable extends boolean = false, +>( + args: SharedArgs & { + onDelete?: ContentOnDelete; + target: () => AnyContentTypeDefinition; + }, +): ContentRelationField => ({ + ...args, + ...shared(args), + kind: "relation", + onDelete: args.onDelete ?? "restrict", +}); + +/** + * Field builders for `defineContentType`. Every builder returns plain data - + * no Drizzle, no React - so a content type definition is safe to import from + * both the API and a client component. + */ +export const field = { + boolean, + dateTime, + enum: enumField, + number, + relation, + text, + textarea, + user, +}; diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts new file mode 100644 index 000000000..7d5234485 --- /dev/null +++ b/packages/vitnode/src/content/index.ts @@ -0,0 +1,98 @@ +export { + contentEntityKey, + contentI18nKeys, + humanizeFieldName, +} from "./admin/labels"; +export { + buildContentColumnSpec, + buildContentFormSpec, + buildFormSchemaFromSpec, + contentFormValuesToPayload, + contentTitleFromValues, +} from "./admin/spec"; +export type { + ContentColumnSpec, + ContentEnumLabeller, + ContentFieldLabeller, + ContentFormFieldSpec, + ContentFormSpec, +} from "./admin/spec"; +/** + * Universal Content Engine - client-safe surface. + * + * Everything exported here is plain data plus zod: it is safe to import from a + * client component, from `buildPlugin`, and from `src/database/*.ts` (which + * Drizzle Kit executes). Anything that needs Drizzle or Hono lives in + * `@vitnode/core/content/server`. + */ +export { + CONTENT_DEFAULT_PAGE_SIZE, + CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_OPTIONS_LIMIT, + CONTENT_PERMISSIONS, + CONTENT_SYSTEM_FIELDS, + CONTENT_TEXT_DEFAULT_LENGTH, + RESERVED_FILTER_KEYS, +} from "./const"; +export { defineContentType } from "./define"; +export { ContentEngineError } from "./errors"; +export { contentEventName } from "./events"; +export type { + ContentCreatedPayload, + ContentDeletedPayload, + ContentEventAction, + ContentEventsFor, + ContentUpdatedPayload, +} from "./events"; +export { field } from "./fields"; +export { contentIndexName, toSnakeCase } from "./indexes"; +export { + contentAdminHref, + contentPermissionEntries, + contentTypeToPath, + findContentTypeById, + orderableColumns, + pathToContentTypeId, + validateContentTypes, + withContentPermissions, +} from "./registry"; +export type { RegisteredContentType } from "./registry"; +export { buildContentSchemas } from "./schemas"; +export type { ContentSchemas } from "./schemas"; +export type { + AnyContentTypeDefinition, + ContentAdminConfig, + ContentAdminLabel, + ContentAdminListConfig, + ContentBooleanField, + ContentCreateInput, + ContentDateTimeField, + ContentEnumField, + ContentFieldDescriptor, + ContentFieldInput, + ContentFieldKind, + ContentFieldMap, + ContentFieldName, + ContentFieldValue, + ContentFilterInput, + ContentIndexConfig, + ContentIndexInput, + ContentNumberField, + ContentOnDelete, + ContentOrderableFieldName, + ContentReferenceField, + ContentReferenceFieldName, + ContentRelationField, + ContentSelect, + ContentSystemField, + ContentTextareaField, + ContentTextField, + ContentTypeDefinition, + ContentUpdateInput, + ContentUserField, + FilterableContentFieldKind, + FilterableContentFieldName, + ResolvedContentAdminConfig, + ResolvedContentIndex, +} from "./types"; diff --git a/packages/vitnode/src/content/indexes.test.ts b/packages/vitnode/src/content/indexes.test.ts new file mode 100644 index 000000000..a87a3053e --- /dev/null +++ b/packages/vitnode/src/content/indexes.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentFieldMap } from "./types"; + +import { CONTENT_IDENTIFIER_MAX_LENGTH } from "./const"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; +import { + contentIndexName, + resolveContentIndexes, + shortenIdentifier, + toSnakeCase, +} from "./indexes"; + +const fields = { + category: field.relation({ + required: true, + target: () => { + throw new Error("not evaluated"); + }, + }), + code: field.text({ required: true, unique: true }), + status: field.enum({ defaultValue: "draft", values: ["draft", "live"] }), + title: field.text({ required: true }), +} satisfies ContentFieldMap; + +const resolve = ( + declared: { name?: string; on: string[]; unique?: boolean }[] = [], + fieldMap: ContentFieldMap = fields, +) => + resolveContentIndexes({ + contentTypeId: "test.thing", + declared, + fields: fieldMap, + tableName: "test_things", + }); + +const namesOf = (declared?: { name?: string; on: string[] }[]) => + resolve(declared).map(index => index.name); + +describe("toSnakeCase", () => { + it("splits camelCase the way the SQL identifiers do", () => { + expect(toSnakeCase("createdAt")).toBe("created_at"); + expect(toSnakeCase("publishedAtUtc")).toBe("published_at_utc"); + }); + + it("leaves an already snake_case name alone", () => { + expect(toSnakeCase("created_at")).toBe("created_at"); + }); +}); + +describe("shortenIdentifier", () => { + const long = (length: number) => "a".repeat(length); + + it("leaves a name inside the limit untouched", () => { + const name = long(CONTENT_IDENTIFIER_MAX_LENGTH); + + expect(shortenIdentifier(name)).toBe(name); + }); + + it("keeps the result inside the Postgres limit", () => { + expect(shortenIdentifier(long(200))).toHaveLength( + CONTENT_IDENTIFIER_MAX_LENGTH, + ); + }); + + it("is deterministic", () => { + expect(shortenIdentifier(long(200))).toBe(shortenIdentifier(long(200))); + }); + + it("does not collide when only the tail differs", () => { + // Plain truncation would map both of these onto the same identifier, which + // is exactly the failure mode the fingerprint exists to prevent. + const first = `${long(70)}_alpha_idx`; + const second = `${long(70)}_beta_idx`; + + expect(shortenIdentifier(first)).not.toBe(shortenIdentifier(second)); + }); +}); + +describe("contentIndexName", () => { + it("builds a deterministic snake_case name", () => { + expect( + contentIndexName({ + columns: ["status", "createdAt"], + tableName: "posts", + }), + ).toBe("posts_status_created_at_idx"); + }); + + it("uses the Postgres `_key` suffix for unique indexes", () => { + expect( + contentIndexName({ columns: ["code"], tableName: "posts", unique: true }), + ).toBe("posts_code_key"); + }); + + it("stays valid for very long table and column names", () => { + const name = contentIndexName({ + columns: ["someExtremelyDescriptiveColumnName"], + tableName: "a_very_long_plugin_scoped_content_table_name_indeed", + }); + + expect(name.length).toBeLessThanOrEqual(CONTENT_IDENTIFIER_MAX_LENGTH); + expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + }); +}); + +describe("resolveContentIndexes", () => { + it("indexes the timestamps and every foreign key", () => { + expect(namesOf()).toEqual( + expect.arrayContaining([ + "test_things_category_idx", + "test_things_created_at_idx", + "test_things_updated_at_idx", + ]), + ); + }); + + it("adds a unique index for `field.text({ unique: true })`", () => { + const code = resolve().find(index => index.name === "test_things_code_key"); + + expect(code).toEqual({ + name: "test_things_code_key", + on: ["code"], + unique: true, + }); + }); + + it("leaves a plain text field unindexed", () => { + expect(namesOf()).not.toContain("test_things_title_idx"); + expect(namesOf()).not.toContain("test_things_title_key"); + }); + + it("never emits two indexes on the same columns", () => { + const signatures = resolve([ + { on: ["category"] }, + { on: ["code"], unique: true }, + ]).map(index => index.on.join(",")); + + expect(new Set(signatures).size).toBe(signatures.length); + }); + + it("lets a declared index rename the automatic foreign-key one", () => { + const resolved = resolve([ + { name: "custom_category_idx", on: ["category"] }, + ]); + + expect(resolved.map(index => index.name)).toContain("custom_category_idx"); + expect(resolved.map(index => index.name)).not.toContain( + "test_things_category_idx", + ); + }); + + it("keeps uniqueness when a declared index covers a unique field", () => { + const resolved = resolve([{ name: "custom_code_idx", on: ["code"] }]); + const code = resolved.find(index => index.name === "custom_code_idx"); + + expect(code?.unique).toBe(true); + expect(resolved.map(index => index.name)).not.toContain( + "test_things_code_key", + ); + }); + + it("treats column order as part of the index identity", () => { + const resolved = resolve([ + { on: ["status", "title"] }, + { on: ["title", "status"] }, + ]); + + expect(resolved.map(index => index.name)).toEqual( + expect.arrayContaining([ + "test_things_status_title_idx", + "test_things_title_status_idx", + ]), + ); + }); + + it("is deterministic across calls", () => { + expect(resolve([{ on: ["status", "title"] }])).toEqual( + resolve([{ on: ["status", "title"] }]), + ); + }); + + describe("validation", () => { + it("rejects an empty column list", () => { + expect(() => resolve([{ on: [] }])).toThrow(/at least one column/); + }); + + it("rejects a column repeated inside one index", () => { + expect(() => resolve([{ on: ["status", "status"] }])).toThrow( + /lists "status" twice/, + ); + }); + + it("rejects two declared indexes on the same columns", () => { + expect(() => + resolve([{ on: ["status", "title"] }, { on: ["status", "title"] }]), + ).toThrow(/declared on the same columns/); + }); + + it("rejects a duplicate explicit index name", () => { + expect(() => + resolve([ + { name: "shared_idx", on: ["status"] }, + { name: "shared_idx", on: ["title"] }, + ]), + ).toThrow(/declared twice/); + }); + + it("rejects an explicit name that is not a Postgres identifier", () => { + expect(() => resolve([{ name: "Bad Name!", on: ["status"] }])).toThrow( + /must be snake_case/, + ); + }); + + it("rejects an explicit name past the identifier limit", () => { + expect(() => + resolve([{ name: `x${"y".repeat(63)}`, on: ["status"] }]), + ).toThrow(/identifier limit/); + }); + + it("rejects two indexes that resolve to the same name", () => { + expect(() => + resolve([ + { name: "test_things_created_at_idx", on: ["status"] }, + { on: ["createdAt"] }, + ]), + ).toThrow(ContentEngineError); + }); + }); +}); diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts new file mode 100644 index 000000000..297edbb19 --- /dev/null +++ b/packages/vitnode/src/content/indexes.ts @@ -0,0 +1,227 @@ +import type { + ContentFieldMap, + ContentIndexConfig, + ResolvedContentIndex, +} from "./types"; + +import { + CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_INDEX_NAME_PATTERN, + CONTENT_SYSTEM_FIELDS, +} from "./const"; +import { ContentEngineError } from "./errors"; + +/** `createdAt` -> `created_at`, matching the SQL identifiers in migrations. */ +export const toSnakeCase = (value: string): string => + value.replace(/[A-Z]/g, match => `_${match.toLowerCase()}`); + +/** + * FNV-1a, 32 bits, base36. Deterministic across processes and Node versions, + * needs no dependency, and is short enough to leave a readable prefix intact. + */ +const fingerprint = (value: string): string => { + let hash = 0x811c9dc5; + + for (let position = 0; position < value.length; position += 1) { + hash ^= value.charCodeAt(position); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash.toString(36).padStart(7, "0"); +}; + +/** + * Keeps an identifier inside Postgres' 63-character limit. + * + * Plain truncation is not enough: two long tables that differ only in their + * last few characters would collapse onto the same index name. Appending a + * fingerprint of the *full* name keeps the result readable and still distinct. + */ +export const shortenIdentifier = (name: string): string => { + if (name.length <= CONTENT_IDENTIFIER_MAX_LENGTH) return name; + + const suffix = `_${fingerprint(name)}`; + + return `${name.slice(0, CONTENT_IDENTIFIER_MAX_LENGTH - suffix.length)}${suffix}`; +}; + +/** + * The deterministic name of a generated index: `
__idx`, or + * `_key` when it is unique - the suffix Postgres itself uses for unique + * constraints. + */ +export const contentIndexName = ({ + columns, + tableName, + unique = false, +}: { + columns: readonly string[]; + tableName: string; + unique?: boolean; +}): string => + shortenIdentifier( + [tableName, ...columns.map(toSnakeCase), unique ? "key" : "idx"].join("_"), + ); + +/** + * Identity of an index for deduplication. Column order matters: an index on + * `(status, createdAt)` cannot serve a lookup on `(createdAt, status)`. + */ +const signatureOf = (columns: readonly string[]): string => columns.join(","); + +const assertDeclaredIndex = ( + contentTypeId: string, + index: ContentIndexConfig, +): void => { + if (index.on.length === 0) { + throw new ContentEngineError("An index needs at least one column.", { + contentTypeId, + }); + } + + const repeated = index.on.find( + (column, position) => index.on.indexOf(column) !== position, + ); + if (repeated !== undefined) { + throw new ContentEngineError( + `Index on [${index.on.join(", ")}] lists "${repeated}" twice.`, + { contentTypeId }, + ); + } + + if (index.name === undefined) return; + + if (!CONTENT_INDEX_NAME_PATTERN.test(index.name)) { + throw new ContentEngineError( + `Index name "${index.name}" must be snake_case and start with a lowercase letter.`, + { contentTypeId }, + ); + } + + if (index.name.length > CONTENT_IDENTIFIER_MAX_LENGTH) { + throw new ContentEngineError( + `Index name "${index.name}" is longer than the Postgres identifier limit of ${CONTENT_IDENTIFIER_MAX_LENGTH} characters.`, + { contentTypeId }, + ); + } +}; + +const named = ( + tableName: string, + index: ContentIndexConfig, +): ResolvedContentIndex => ({ + name: + index.name ?? + contentIndexName({ columns: index.on, tableName, unique: index.unique }), + on: [...index.on], + unique: index.unique ?? false, +}); + +/** + * Expands the declared indexes into the full set the table will carry, then + * removes the redundant ones. + * + * Four sources feed in, in descending precedence: + * + * 1. `indexes` declared on the content type, + * 2. `field.text({ unique: true })`, + * 3. every foreign key (`relation` and `user` fields), + * 4. `createdAt` and `updatedAt`, which back the default ordering. + * + * Two entries covering the same columns collapse into one: the first name wins, + * and the index is unique if *any* of them asked for uniqueness. So declaring + * `{ on: ["category"] }` simply renames the automatic foreign-key index rather + * than adding a second one, and declaring `{ on: ["code"], unique: true }` + * beside `field.text({ unique: true })` still yields exactly one index. + * + * Names are only checked against *this* content type here. Postgres scopes index + * names to the schema, so `validateContentTypes` re-checks them across every + * installed content type - that is the only place the whole set is visible. + */ +export const resolveContentIndexes = ({ + contentTypeId, + declared, + fields, + tableName, +}: { + contentTypeId: string; + declared: readonly ContentIndexConfig[]; + fields: ContentFieldMap; + tableName: string; +}): ResolvedContentIndex[] => { + const seenNames = new Map(); + const seenSignatures = new Set(); + + for (const index of declared) { + assertDeclaredIndex(contentTypeId, index); + + const signature = signatureOf(index.on); + if (seenSignatures.has(signature)) { + throw new ContentEngineError( + `Two indexes are declared on the same columns [${index.on.join(", ")}]. Remove one of them.`, + { contentTypeId }, + ); + } + seenSignatures.add(signature); + + if (index.name === undefined) continue; + if (seenNames.has(index.name)) { + throw new ContentEngineError( + `Index name "${index.name}" is declared twice.`, + { contentTypeId }, + ); + } + seenNames.set(index.name, [...index.on]); + } + + const fieldEntries = Object.entries(fields); + const candidates: ResolvedContentIndex[] = [ + ...declared.map(index => named(tableName, index)), + ...fieldEntries + .filter( + ([, fieldValue]) => fieldValue.kind === "text" && fieldValue.unique, + ) + .map(([name]) => named(tableName, { on: [name], unique: true })), + ...fieldEntries + .filter( + ([, fieldValue]) => + fieldValue.kind === "relation" || fieldValue.kind === "user", + ) + .map(([name]) => named(tableName, { on: [name] })), + ...CONTENT_SYSTEM_FIELDS.filter(name => name !== "id").map(name => + named(tableName, { on: [name] }), + ), + ]; + + const bySignature = new Map(); + + for (const candidate of candidates) { + const signature = signatureOf(candidate.on); + const existing = bySignature.get(signature); + + if (!existing) { + bySignature.set(signature, candidate); + continue; + } + + // Same columns, so one index serves both - but a uniqueness requirement + // from any source has to survive the merge. + existing.unique ||= candidate.unique; + } + + const resolved = [...bySignature.values()]; + const byName = new Map(); + + for (const index of resolved) { + const collision = byName.get(index.name); + if (collision) { + throw new ContentEngineError( + `Indexes on [${collision.on.join(", ")}] and [${index.on.join(", ")}] both resolve to the name "${index.name}".`, + { contentTypeId }, + ); + } + byName.set(index.name, index); + } + + return resolved; +}; diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts new file mode 100644 index 000000000..f7da4d28e --- /dev/null +++ b/packages/vitnode/src/content/registry.test.ts @@ -0,0 +1,301 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { RegisteredContentType } from "./registry"; + +import { defineContentType } from "./define"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; +import { + contentAdminHref, + contentTypeToPath, + findContentTypeById, + orderableColumns, + pathToContentTypeId, + validateContentTypes, + withContentPermissions, +} from "./registry"; + +// `widget()` below builds definitions through `Partial>`, which +// erases the inferred field map down to the bare constraint. Real call sites +// keep their concrete map, so this widening only exists for the test helper. +const entry = ( + definition: RegisteredContentType["definition"] | ReturnType, + pluginId = "@vitnode/example", +): RegisteredContentType => ({ + definition: definition as RegisteredContentType["definition"], + pluginId, +}); + +const widget = ( + overrides: Partial[0]> = {}, +) => + defineContentType({ + id: "test.widget", + tableName: "test_widgets", + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Widgets", singular: "Widget" } }, + ...overrides, + }); + +describe("validateContentTypes", () => { + it("accepts distinct content types", () => { + expect(() => + validateContentTypes([ + entry(testArticleContentType), + entry(testCategoryContentType), + ]), + ).not.toThrow(); + }); + + it("returns entries sorted by id, whatever the registration order", () => { + const sorted = validateContentTypes([ + entry(testArticleContentType), + entry(testCategoryContentType), + ]); + + expect(sorted.map(item => item.definition.id)).toEqual([ + "test.article", + "test.category", + ]); + }); + + it("rejects a duplicate content type id and names both plugins", () => { + expect(() => + validateContentTypes([ + entry(widget(), "@vitnode/a"), + entry(widget({ tableName: "test_widgets_two" }), "@vitnode/b"), + ]), + ).toThrow(/@vitnode\/a .* @vitnode\/b/); + }); + + it("rejects a duplicate table name across plugins", () => { + expect(() => + validateContentTypes([ + entry(widget(), "@vitnode/a"), + entry(widget({ id: "test.other" }), "@vitnode/b"), + ]), + ).toThrow(/Table "test_widgets" is claimed by both/); + }); + + it("rejects two content types deriving the same permission module in one plugin", () => { + expect(() => + validateContentTypes([ + entry(widget()), + entry(widget({ id: "test.other", tableName: "test_others" })), + ]), + ).toThrow(/Permission module "widgets" is derived by both/); + }); + + it("allows the same permission module in different plugins", () => { + expect(() => + validateContentTypes([ + entry(widget(), "@vitnode/a"), + entry( + widget({ id: "test.other", tableName: "test_others" }), + "@vitnode/b", + ), + ]), + ).not.toThrow(); + }); + + it.each(["cursor", "first", "last", "order", "orderBy", "search"])( + "rejects a field named %s, which would shadow a pagination parameter", + name => { + expect(() => + validateContentTypes([ + entry(widget({ fields: { [name]: field.text({ required: true }) } })), + ]), + ).toThrow(ContentEngineError); + }, + ); +}); + +// Postgres index names live in the schema, not in the table, so two content +// types sharing one is a migration that fails halfway through - long after +// `defineContentType` has had its say. +describe("global index names", () => { + const other = ( + overrides: Partial[0]> = {}, + ) => + widget({ + id: "test.other", + tableName: "test_others", + admin: { label: { plural: "Others", singular: "Other" } }, + ...overrides, + }); + + /** Every index name a definition resolved to. */ + const namesOf = (definition: ReturnType) => + definition.indexes.map(index => index.name); + + it("accepts two content types whose index names differ", () => { + expect(() => + validateContentTypes([entry(widget()), entry(other())]), + ).not.toThrow(); + }); + + it("does not collide on generated names, because the table name is in them", () => { + const [first, second] = [widget(), other()]; + + expect(namesOf(first)).toContain("test_widgets_created_at_idx"); + expect(namesOf(second)).toContain("test_others_created_at_idx"); + expect( + namesOf(first).filter(name => namesOf(second).includes(name)), + ).toEqual([]); + }); + + it("rejects the same explicit index name inside one plugin", () => { + expect(() => + validateContentTypes([ + entry( + widget({ indexes: [{ name: "shared_title_idx", on: ["title"] }] }), + ), + entry( + other({ indexes: [{ name: "shared_title_idx", on: ["title"] }] }), + ), + ]), + ).toThrow(/Index name "shared_title_idx" is used by both/); + }); + + it("rejects the same explicit index name across plugins", () => { + expect(() => + validateContentTypes([ + entry( + widget({ indexes: [{ name: "shared_title_idx", on: ["title"] }] }), + "@vitnode/a", + ), + entry( + other({ indexes: [{ name: "shared_title_idx", on: ["title"] }] }), + "@vitnode/b", + ), + ]), + ).toThrow(/Index name "shared_title_idx" is used by both/); + }); + + it("names both owners, with their plugin, content type and table", () => { + expect(() => + validateContentTypes([ + entry( + widget({ indexes: [{ name: "shared_title_idx", on: ["title"] }] }), + "@vitnode/a", + ), + entry( + other({ indexes: [{ name: "shared_title_idx", on: ["title"] }] }), + "@vitnode/b", + ), + ]), + ).toThrow( + '@vitnode/a -> test.widget (table "test_widgets", columns [title]) and @vitnode/b -> test.other (table "test_others", columns [title])', + ); + }); + + it("fails on the duplicate content type first, not on its identical indexes", () => { + expect(() => + validateContentTypes([ + entry(widget(), "@vitnode/a"), + entry(widget({ tableName: "test_widgets_two" }), "@vitnode/b"), + ]), + ).toThrow(/Duplicate content type id/); + }); + + // Two table names this long share every character a truncated index name can + // keep, so only the fingerprint of the full name tells them apart. + it("keeps shortened generated names distinct when the long originals differ", () => { + const base = `t_${"a".repeat(56)}`; + const first = other({ id: "test.long1", tableName: `${base}_x` }); + const second = other({ id: "test.long2", tableName: `${base}_y` }); + + const [firstName, secondName] = [first, second].map( + definition => + definition.indexes.find(index => index.on[0] === "createdAt")?.name, + ); + + expect(firstName).not.toBe(secondName); + expect(firstName).toHaveLength(63); + expect(secondName).toHaveLength(63); + expect(() => + validateContentTypes([ + entry(first, "@vitnode/a"), + entry(second, "@vitnode/b"), + ]), + ).not.toThrow(); + }); +}); + +describe("withContentPermissions", () => { + it("derives the four permissions per content type", () => { + const merged = withContentPermissions({}, [entry(testArticleContentType)]); + + expect(merged?.admin?.test_articles).toEqual([ + "can_view", + { dependsOn: ["can_view"], permission: "can_create" }, + { dependsOn: ["can_view"], permission: "can_edit" }, + { dependsOn: ["can_view"], permission: "can_delete" }, + ]); + }); + + it("keeps an explicitly declared module untouched", () => { + const merged = withContentPermissions( + { admin: { test_articles: ["can_view"] } }, + [entry(testArticleContentType)], + ); + + expect(merged?.admin?.test_articles).toEqual(["can_view"]); + }); + + it("leaves other modules alone", () => { + const merged = withContentPermissions( + { admin: { posts: ["can_view", "can_edit"] } }, + [entry(testArticleContentType)], + ); + + expect(merged?.admin?.posts).toEqual(["can_view", "can_edit"]); + expect(merged?.admin?.test_articles).toBeDefined(); + }); + + it("passes the config through untouched when there are no content types", () => { + const permissionStaff = { admin: { posts: ["can_view"] } }; + + expect(withContentPermissions(permissionStaff, [])).toBe(permissionStaff); + }); +}); + +describe("routing helpers", () => { + it("maps a content type id onto the catch-all path", () => { + expect(contentTypeToPath("example.article")).toBe("example/article"); + expect(contentAdminHref("example.article")).toBe( + "/admin/content/example/article", + ); + }); + + it("round-trips the catch-all slug", () => { + expect(pathToContentTypeId(["example", "article"])).toBe("example.article"); + }); + + it("finds a registered content type by id", () => { + const entries = validateContentTypes([entry(testArticleContentType)]); + + expect(findContentTypeById(entries, "test.article")?.pluginId).toBe( + "@vitnode/example", + ); + expect(findContentTypeById(entries, "test.nope")).toBeUndefined(); + }); +}); + +describe("orderableColumns", () => { + it("combines the declared allowlist with the system columns", () => { + expect(orderableColumns(testArticleContentType)).toEqual([ + "title", + "status", + "id", + "createdAt", + "updatedAt", + ]); + }); +}); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts new file mode 100644 index 000000000..a9f2300d0 --- /dev/null +++ b/packages/vitnode/src/content/registry.ts @@ -0,0 +1,192 @@ +import type { + PermissionStaffConfig, + PermissionStaffEntryInput, + PermissionStaffModulesInput, +} from "../api/lib/permission-staff"; +import type { AnyContentTypeDefinition } from "./types"; + +import { + CONTENT_PERMISSIONS, + CONTENT_SYSTEM_FIELDS, + RESERVED_FILTER_KEYS, +} from "./const"; +import { ContentEngineError } from "./errors"; + +/** A definition plus the plugin that registered it. */ +export interface RegisteredContentType { + definition: AnyContentTypeDefinition; + pluginId: string; +} + +const describe = (entry: RegisteredContentType): string => + `${entry.pluginId} -> ${entry.definition.id}`; + +const describeIndexOwner = (owner: IndexOwner): string => + `${describe(owner.entry)} (table "${owner.entry.definition.tableName}", columns [${owner.columns.join(", ")}])`; + +interface IndexOwner { + columns: string[]; + entry: RegisteredContentType; +} + +/** + * Validates a set of content types coming from one or more plugins. + * + * Runs at boot (or at plugin build time), never per request, so a + * misconfiguration fails loudly and immediately. Returns the entries sorted by + * id so registries stay deterministic across processes. + * + * This is the only place that sees *every* installed content type at once, + * which makes it the only place that can catch a schema-wide clash: a duplicate + * table name, or two content types resolving to the same Postgres index name. + */ +export const validateContentTypes = ( + entries: RegisteredContentType[], +): RegisteredContentType[] => { + const byId = new Map(); + const byTable = new Map(); + const byPermission = new Map(); + const byIndexName = new Map(); + + for (const entry of entries) { + const { definition, pluginId } = entry; + + const duplicateId = byId.get(definition.id); + if (duplicateId) { + throw new ContentEngineError( + `Duplicate content type id, registered by both ${describe(duplicateId)} and ${describe(entry)}.`, + { contentTypeId: definition.id }, + ); + } + byId.set(definition.id, entry); + + const duplicateTable = byTable.get(definition.tableName); + if (duplicateTable) { + throw new ContentEngineError( + `Table "${definition.tableName}" is claimed by both ${describe(duplicateTable)} and ${describe(entry)}.`, + { contentTypeId: definition.id }, + ); + } + byTable.set(definition.tableName, entry); + + // Permission modules are scoped per plugin, so only a collision inside one + // plugin is ambiguous. + const permissionKey = `${pluginId}:${definition.permissionModule}`; + const duplicatePermission = byPermission.get(permissionKey); + if (duplicatePermission) { + throw new ContentEngineError( + `Permission module "${definition.permissionModule}" is derived by both ${describe(duplicatePermission)} and ${describe(entry)}. Set \`admin.permissionModule\` on one of them.`, + { contentTypeId: definition.id }, + ); + } + byPermission.set(permissionKey, entry); + + assertFilterKeys(definition); + + // `resolveContentIndexes` already rejects a collision inside one content + // type. Postgres index names are unique per *schema*, though, so two + // content types - from one plugin or from two - cannot share one either. + for (const index of definition.indexes) { + const owner = byIndexName.get(index.name); + if (owner) { + throw new ContentEngineError( + `Index name "${index.name}" is used by both ${describeIndexOwner(owner)} and ${describeIndexOwner({ columns: index.on, entry })}. Postgres index names are unique per schema, so rename one of them.`, + { contentTypeId: definition.id }, + ); + } + byIndexName.set(index.name, { columns: index.on, entry }); + } + } + + return [...entries].sort((a, b) => + a.definition.id.localeCompare(b.definition.id), + ); +}; + +const reservedFilterKeys: readonly string[] = RESERVED_FILTER_KEYS; + +/** + * A field named `search`, `cursor`, `first`, ... would shadow a pagination + * query parameter on the generated list route. + */ +const assertFilterKeys = (definition: AnyContentTypeDefinition): void => { + const fields = definition.fields; + const clash = Object.keys(fields).find(name => + reservedFilterKeys.includes(name), + ); + + if (clash !== undefined) { + throw new ContentEngineError( + `Field "${clash}" collides with the "${clash}" pagination query parameter. Rename the field.`, + { contentTypeId: definition.id }, + ); + } +}; + +export const findContentTypeById = ( + entries: readonly RegisteredContentType[], + id: string, +): RegisteredContentType | undefined => + entries.find(entry => entry.definition.id === id); + +/** `example.article` -> `example/article`, for `/admin/content/[...slug]`. */ +export const contentTypeToPath = (id: string): string => + id.split(".").join("/"); + +/** `["example", "article"]` -> `example.article`. */ +export const pathToContentTypeId = (slug: readonly string[]): string => + slug.join("."); + +/** `/admin/content/example/article` */ +export const contentAdminHref = (id: string): string => + `/admin/content/${contentTypeToPath(id)}`; + +/** + * The four permissions every content type gets. `can_view` gates the list and + * the nav item; the writes depend on it so a role cannot create rows it cannot + * see. + */ +export const contentPermissionEntries = (): PermissionStaffEntryInput[] => [ + CONTENT_PERMISSIONS.view, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.create, + }, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.edit, + }, + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.delete, + }, +]; + +/** + * Merges the derived content permissions into a plugin's `permissionStaff`. + * An explicitly declared module always wins, so a plugin can still hand-tune + * the permissions of a generated content type. + */ +export const withContentPermissions = ( + permissionStaff: PermissionStaffConfig | undefined, + entries: readonly RegisteredContentType[], +): PermissionStaffConfig | undefined => { + if (entries.length === 0) return permissionStaff; + + const admin: PermissionStaffModulesInput = { ...permissionStaff?.admin }; + + for (const { definition } of entries) { + if (admin[definition.permissionModule]) continue; + admin[definition.permissionModule] = contentPermissionEntries(); + } + + return { ...permissionStaff, admin }; +}; + +/** Column names a generated route may order by. */ +export const orderableColumns = ( + definition: AnyContentTypeDefinition, +): string[] => [ + ...definition.admin.list.orderableFields, + ...CONTENT_SYSTEM_FIELDS, +]; diff --git a/packages/vitnode/src/content/schemas.test.ts b/packages/vitnode/src/content/schemas.test.ts new file mode 100644 index 000000000..da23d6bf9 --- /dev/null +++ b/packages/vitnode/src/content/schemas.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +const { schemas } = testArticleContentType; + +const valid = { category: 1, title: "Hello world" }; + +describe("generated schemas", () => { + describe("create", () => { + it("accepts the required fields alone", () => { + expect(schemas.create.safeParse(valid).success).toBe(true); + }); + + it("applies declared defaults, matching the column defaults", () => { + expect(schemas.create.parse(valid)).toMatchObject({ + featured: false, + status: "draft", + views: 0, + }); + }); + + it("rejects a missing required field", () => { + expect(schemas.create.safeParse({ title: "Hello world" }).success).toBe( + false, + ); + }); + + it("rejects unknown keys instead of stripping them", () => { + const result = schemas.create.safeParse({ ...valid, slug: "nope" }); + + expect(result.success).toBe(false); + }); + + it("rejects the generated system columns", () => { + for (const key of ["id", "createdAt", "updatedAt"]) { + expect(schemas.create.safeParse({ ...valid, [key]: 1 }).success).toBe( + false, + ); + } + }); + + it("enforces the declared string bounds", () => { + expect(schemas.create.safeParse({ ...valid, title: "ab" }).success).toBe( + false, + ); + expect( + schemas.create.safeParse({ ...valid, title: "a".repeat(201) }).success, + ).toBe(false); + }); + + it("enforces the declared number bounds", () => { + expect(schemas.create.safeParse({ ...valid, views: -1 }).success).toBe( + false, + ); + expect(schemas.create.safeParse({ ...valid, views: 1.5 }).success).toBe( + false, + ); + }); + + it("keeps nullable and optional distinct", () => { + // `excerpt` is nullable, so `null` is a value... + expect( + schemas.create.safeParse({ ...valid, excerpt: null }).success, + ).toBe(true); + // ...but `title` is not. + expect(schemas.create.safeParse({ ...valid, title: null }).success).toBe( + false, + ); + }); + + it("takes dateTime as an ISO 8601 string", () => { + expect( + schemas.create.safeParse({ + ...valid, + publishedAt: "2026-08-02T10:00:00.000Z", + }).success, + ).toBe(true); + expect( + schemas.create.safeParse({ ...valid, publishedAt: "2026-08-02" }) + .success, + ).toBe(false); + }); + + it("rejects a value outside the enum", () => { + expect( + schemas.create.safeParse({ ...valid, status: "nope" }).success, + ).toBe(false); + }); + + it("rejects a non-positive relation identifier", () => { + expect(schemas.create.safeParse({ ...valid, category: 0 }).success).toBe( + false, + ); + }); + }); + + describe("update", () => { + it("accepts a single field", () => { + expect(schemas.update.safeParse({ title: "Updated" }).success).toBe(true); + }); + + it("rejects an empty payload", () => { + expect(schemas.update.safeParse({}).success).toBe(false); + }); + + it("still rejects unknown keys and bad values", () => { + expect(schemas.update.safeParse({ slug: "nope" }).success).toBe(false); + expect(schemas.update.safeParse({ views: -1 }).success).toBe(false); + }); + + it("never re-applies create defaults, so a partial update cannot reset a column", () => { + expect(schemas.update.parse({ title: "Updated" })).toEqual({ + title: "Updated", + }); + }); + }); + + describe("select", () => { + it("describes the API response, dates included", () => { + const row = { + author: null, + category: 1, + createdAt: new Date(), + excerpt: null, + featured: false, + id: 1, + publishedAt: null, + status: "draft", + title: "Hello world", + updatedAt: new Date(), + views: 0, + }; + + expect(schemas.select.safeParse(row).success).toBe(true); + }); + }); + + describe("order", () => { + it("allows the declared orderable fields and the system columns", () => { + for (const orderBy of [ + "title", + "status", + "createdAt", + "updatedAt", + "id", + ]) { + expect(schemas.order.safeParse({ orderBy }).success).toBe(true); + } + }); + + it("rejects a column that is not allowlisted", () => { + expect(schemas.order.safeParse({ orderBy: "views" }).success).toBe(false); + expect( + schemas.order.safeParse({ orderBy: "id; drop table" }).success, + ).toBe(false); + }); + + it("only allows asc and desc", () => { + expect(schemas.order.safeParse({ order: "sideways" }).success).toBe( + false, + ); + }); + }); + + describe("filters", () => { + it("exposes only filterable fields", () => { + // `excerpt` is a textarea: it is searchable, not equality-filterable. + expect(Object.keys(schemas.filters.shape).sort()).toEqual([ + "author", + "category", + "featured", + "status", + "title", + "views", + ]); + }); + + it("parses query-string values", () => { + expect( + schemas.filters.parse({ category: "3", featured: "true" }), + ).toMatchObject({ category: 3, featured: "true" }); + }); + + it("rejects an enum filter outside the declared values", () => { + expect(schemas.filters.safeParse({ status: "nope" }).success).toBe(false); + }); + }); + + describe("params", () => { + it("coerces the identifier from the path", () => { + expect(schemas.params.parse({ id: "42" })).toEqual({ id: 42 }); + }); + }); + + describe("form", () => { + it("survives z.toJSONSchema, which AutoForm runs on every schema", () => { + // `z.date()` throws here, which is why the form variant exists at all. + expect(() => z.toJSONSchema(schemas.form)).not.toThrow(); + }); + + it("exposes the declared form fields with their defaults", () => { + const json = z.toJSONSchema(schemas.form); + + expect(Object.keys(json.properties ?? {})).toEqual( + testArticleContentType.admin.form.fields, + ); + expect(json.properties?.status).toMatchObject({ default: "draft" }); + }); + + it("honours an explicit form field list", () => { + const definition = defineContentType({ + id: "test.formsubset", + tableName: "test_form_subsets", + fields: { + title: field.text({ required: true }), + internalNote: field.textarea({ nullable: true }), + }, + admin: { + label: { plural: "Subsets", singular: "Subset" }, + form: { fields: ["title"] }, + }, + }); + + expect(Object.keys(definition.schemas.form.shape)).toEqual(["title"]); + }); + }); +}); diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts new file mode 100644 index 000000000..62aa40aa1 --- /dev/null +++ b/packages/vitnode/src/content/schemas.ts @@ -0,0 +1,258 @@ +import { z } from "zod"; + +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentFieldDescriptor, + ContentFieldMap, + ContentSelect, + ContentUpdateInput, + ResolvedContentAdminConfig, +} from "./types"; + +import { CONTENT_SYSTEM_FIELDS, isFilterableFieldKind } from "./const"; + +export interface ContentSchemas { + /** Request body for create. Rejects unknown keys and system columns. */ + create: z.ZodType>; + /** + * Query-string filters, restricted to filterable fields. Non-strict: it is + * parsed against the whole query string, so unrecognised keys are ignored. + */ + filters: z.ZodObject; + /** + * The create/update shape as `AutoForm` needs it: a plain `ZodObject` with + * no `z.date()` anywhere, because `AutoForm` runs `z.toJSONSchema` on it and + * Zod v4 throws on dates. + */ + form: z.ZodObject; + /** `orderBy` allowlist plus direction. */ + order: z.ZodObject; + /** Path parameters for the detail/update/delete routes. */ + params: z.ZodObject<{ id: z.ZodCoercedNumber }>; + /** API response shape. */ + select: z.ZodType>; + /** + * The same shape as `select`, but left as a `ZodObject` so the generated + * routes can `.extend(...)` it with the joined relation labels. + */ + selectObject: z.ZodObject; + /** Request body for update. Every field optional, but never empty. */ + update: z.ZodType>; +} + +const textSchema = (fieldValue: { + maxLength?: number; + minLength?: number; +}): z.ZodString => { + let schema = z.string(); + if (fieldValue.minLength !== undefined) { + schema = schema.min(fieldValue.minLength); + } + if (fieldValue.maxLength !== undefined) { + schema = schema.max(fieldValue.maxLength); + } + + return schema; +}; + +const numberSchema = (fieldValue: { + integer: boolean; + max?: number; + min?: number; +}): z.ZodNumber => { + let schema = fieldValue.integer ? z.number().int() : z.number(); + if (fieldValue.min !== undefined) schema = schema.min(fieldValue.min); + if (fieldValue.max !== undefined) schema = schema.max(fieldValue.max); + + return schema; +}; + +/** Row identifiers are always positive integers, whatever the field kind. */ +const referenceSchema = (): z.ZodNumber => z.number().int().positive(); + +/** The value as it leaves the API. */ +const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { + switch (fieldValue.kind) { + case "boolean": + return z.boolean(); + case "dateTime": + return z.date(); + case "enum": + return z.enum(fieldValue.values); + case "number": + return numberSchema(fieldValue); + case "relation": + case "user": + return referenceSchema(); + case "text": + case "textarea": + return textSchema(fieldValue); + } +}; + +/** The value as it arrives from a client. `dateTime` is an ISO 8601 string. */ +const baseInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { + if (fieldValue.kind === "dateTime") return z.iso.datetime(); + + return baseSelectSchema(fieldValue); +}; + +const applyNullable = ( + schema: z.ZodType, + fieldValue: ContentFieldDescriptor, +): z.ZodType => (fieldValue.nullable ? schema.nullable() : schema); + +/** + * `required` -> present. Otherwise a declared default becomes a Zod default so + * the value the API writes always matches the column default, and everything + * else is simply optional. + */ +const applyPresence = ( + schema: z.ZodType, + fieldValue: ContentFieldDescriptor, +): z.ZodType => { + if (fieldValue.required) return schema; + + if ( + fieldValue.kind !== "dateTime" && + fieldValue.kind !== "relation" && + fieldValue.kind !== "user" && + fieldValue.defaultValue !== undefined + ) { + return schema.default(fieldValue.defaultValue); + } + + return schema.optional(); +}; + +const inputShape = ( + fields: ContentFieldMap, + names: readonly string[], +): z.ZodRawShape => + Object.fromEntries( + names.map(name => { + const fieldValue = fields[name]; + + return [ + name, + applyPresence( + applyNullable(baseInputSchema(fieldValue), fieldValue), + fieldValue, + ), + ]; + }), + ); + +/** + * Update never applies create defaults: `PUT { title }` must leave `status`, + * `views` and every other defaulted column alone, not silently reset them to + * the column default. Every field is simply optional here. + */ +const updateShape = ( + fields: ContentFieldMap, + names: readonly string[], +): z.ZodRawShape => + Object.fromEntries( + names.map(name => { + const fieldValue = fields[name]; + + return [ + name, + applyNullable(baseInputSchema(fieldValue), fieldValue).optional(), + ]; + }), + ); + +/** + * The equality filters a generated list route accepts, keyed by field name. + * + * Filters arrive as query-string values, so every entry parses and coerces from + * a string. Only kinds in `CONTENT_FILTERABLE_FIELD_KINDS` get one - the same + * list the query builder and `FilterableContentFieldKind` use. + * + * A plain (non-strict) object on purpose: the list route hands it the *whole* + * query string, which also carries `cursor`, `first`, `last`, `order`, `orderBy` + * and `search`. Those are parsed separately, so this schema ignores every key it + * does not recognise rather than rejecting it. The upshot is that a query string + * cannot smuggle an unsupported field into `buildFilterCondition` - it simply + * never appears in the parsed result. A direct service call can still pass one, + * which is why the query builder re-checks kind and nullability itself. + */ +const filterShape = (fields: ContentFieldMap): z.ZodRawShape => + Object.fromEntries( + Object.entries(fields) + .filter(([, fieldValue]) => isFilterableFieldKind(fieldValue.kind)) + .map(([name, fieldValue]) => { + switch (fieldValue.kind) { + case "boolean": + return [name, z.enum(["true", "false"]).optional()]; + case "enum": + return [name, z.enum(fieldValue.values).optional()]; + case "number": + case "relation": + case "user": + return [name, z.coerce.number().optional()]; + default: + return [name, z.string().optional()]; + } + }), + ); + +/** + * Takes only the two pieces it needs rather than a whole definition, so + * `defineContentType` can call it before the definition object exists and + * without re-widening its field map. + */ +export const buildContentSchemas = ({ + admin, + fields, +}: { + admin: ResolvedContentAdminConfig; + fields: ContentFieldMap; +}): ContentSchemas => { + const fieldNames = Object.keys(fields); + + const selectShape: z.ZodRawShape = { + id: z.number(), + ...Object.fromEntries( + fieldNames.map(name => [ + name, + applyNullable(baseSelectSchema(fields[name]), fields[name]), + ]), + ), + createdAt: z.date(), + updatedAt: z.date(), + }; + + // `strictObject` blocks mass assignment: an unknown key is an error, not + // something quietly stripped. System columns are absent from the shape, so + // they can never be set from a request. + const create = z.strictObject(inputShape(fields, fieldNames)); + const update = z + .strictObject(updateShape(fields, fieldNames)) + .refine(value => Object.keys(value).length > 0, { + message: "Provide at least one field to update.", + }); + + const orderable = [...admin.list.orderableFields, ...CONTENT_SYSTEM_FIELDS]; + const selectObject = z.object(selectShape); + + return { + // The shapes are assembled in a loop, so their Zod types are erased. + // Re-attaching the descriptor-derived types here means every consumer - + // route handler, service, AdminCP - stays fully typed with no further + // casts. `buildContentSchemas` is covered by `schemas.test-d.ts`. + create: create as unknown as z.ZodType>, + filters: z.object(filterShape(fields)), + form: z.object(inputShape(fields, admin.form.fields)), + order: z.object({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(orderable as [string, ...string[]]).optional(), + }), + params: z.object({ id: z.coerce.number() }), + select: selectObject as unknown as z.ZodType>, + selectObject, + update: update as unknown as z.ZodType>, + }; +}; diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts new file mode 100644 index 000000000..d967f1ae2 --- /dev/null +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -0,0 +1,133 @@ +import type { AnyPgColumn, PgColumnBuilderBase } from "drizzle-orm/pg-core"; + +import { + boolean, + doublePrecision, + integer, + serial, + text, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; + +import type { ContentFieldDescriptor } from "../types"; + +import { + CONTENT_ENUM_DEFAULT_LENGTH, + CONTENT_TEXT_DEFAULT_LENGTH, +} from "../const"; +import { ContentEngineError } from "../errors"; + +export type ColumnReferenceThunk = () => AnyPgColumn; + +/** + * The three columns every content table gets, matching the conventions used by + * all 22 core tables: a `serial` primary key, `defaultNow()` on `createdAt`, + * and `defaultNow().$onUpdate(...)` on `updatedAt`. + */ +export const buildSystemColumns = (): Record => ({ + id: serial().primaryKey(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +/** + * Applies `NOT NULL` and the column default. + * + * Written as a generic over the concrete builder so each `default(...)` call + * sees the narrowed value type - a single shared `default()` at the end would + * have to accept the union of every field kind's value. + */ +const withModifiers = < + TBuilder extends { + default: (value: TValue) => TBuilder; + notNull: () => TBuilder; + }, + TValue, +>( + builder: TBuilder, + { defaultValue, nullable }: { defaultValue?: TValue; nullable: boolean }, +): TBuilder => { + const withNull = nullable ? builder : builder.notNull(); + + return defaultValue === undefined ? withNull : withNull.default(defaultValue); +}; + +/** + * Compiles one field descriptor into a Drizzle column builder. + * + * `nullable` drives `NOT NULL`, and a declared `defaultValue` becomes the + * column default so Postgres and the generated Zod schema agree. + */ +export const buildContentColumn = ({ + contentTypeId, + fieldValue, + name, + reference, +}: { + contentTypeId: string; + fieldValue: ContentFieldDescriptor; + name: string; + reference?: ColumnReferenceThunk; +}): PgColumnBuilderBase => { + const { nullable } = fieldValue; + + switch (fieldValue.kind) { + case "boolean": + return withModifiers(boolean(), { + defaultValue: fieldValue.defaultValue, + nullable, + }); + case "dateTime": { + const column = nullable ? timestamp() : timestamp().notNull(); + + return fieldValue.defaultNow ? column.defaultNow() : column; + } + case "enum": + return withModifiers( + varchar({ + enum: fieldValue.values as [string, ...string[]], + length: fieldValue.length ?? CONTENT_ENUM_DEFAULT_LENGTH, + }), + { defaultValue: fieldValue.defaultValue, nullable }, + ); + case "number": + return withModifiers(fieldValue.integer ? integer() : doublePrecision(), { + defaultValue: fieldValue.defaultValue, + nullable, + }); + case "relation": + case "user": { + if (!reference) { + throw new ContentEngineError( + `Field "${name}" is a ${fieldValue.kind} reference but no target column was resolved.`, + { contentTypeId }, + ); + } + + const column = integer().references(reference, { + onDelete: fieldValue.onDelete, + // Identifiers are `serial`, so an update is only ever a repair; cascade + // keeps children pointing at the right row either way. + onUpdate: "cascade", + }); + + return nullable ? column : column.notNull(); + } + case "text": + return withModifiers( + varchar({ + length: fieldValue.maxLength ?? CONTENT_TEXT_DEFAULT_LENGTH, + }), + { defaultValue: fieldValue.defaultValue, nullable }, + ); + case "textarea": + return withModifiers(text(), { + defaultValue: fieldValue.defaultValue, + nullable, + }); + } +}; diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts new file mode 100644 index 000000000..25e84037c --- /dev/null +++ b/packages/vitnode/src/content/server/emit.ts @@ -0,0 +1,57 @@ +import type { Context } from "hono"; + +import type { VitNodeEventName } from "../../api/models/events"; +import type { + ContentCreatedPayload, + ContentDeletedPayload, + ContentEventAction, + ContentUpdatedPayload, +} from "../events"; +import type { AnyContentTypeDefinition } from "../types"; + +import { contentEventName } from "../events"; + +type ContentPayload = + | ContentCreatedPayload + | ContentDeletedPayload + | ContentUpdatedPayload; + +/** + * `EventsModel.emit` as this module needs to see it. + * + * Narrowing the model rather than casting the payload is deliberate: the shape + * of `VitNodeEvents` here depends on whether a `declare module` block happens to + * be in the current TypeScript program, and core is compiled both ways - with + * the type tests (lint, `test:types`) and without them (`build:plugins`). A + * payload cast is "unnecessary" in one program and required in the other, so the + * autofixer and the build take turns breaking each other. This does not move. + */ +interface ContentEventEmitter { + emit: (name: VitNodeEventName, payload: ContentPayload) => Promise; +} + +/** + * Emits a content event after a successful write. + * + * This is the single place where a runtime event name is reconciled with the + * global event map, and it has to be: `VitNodeEvents` only gains + * `content..created` and friends from the *plugin's* `declare module` block, + * so those keys do not exist while core compiles itself - and a generated route + * only ever holds an `AnyContentTypeDefinition`, whose `id` is a plain `string`. + * `ContentEventsFor` is what makes the name/payload pairing sound at the + * plugin's augmentation site; see `events.test-d.ts`. + * + * Call it only once the database write has returned - never inside a + * transaction callback. + */ +export const emitContentEvent = async ( + c: Context, + definition: AnyContentTypeDefinition, + action: ContentEventAction, + payload: ContentPayload, +): Promise => { + const name = contentEventName(definition.id, action) as VitNodeEventName; + const events = c.get("events") as unknown as ContentEventEmitter; + + await events.emit(name, payload); +}; diff --git a/packages/vitnode/src/content/server/http-errors.test.ts b/packages/vitnode/src/content/server/http-errors.test.ts new file mode 100644 index 000000000..28745da1e --- /dev/null +++ b/packages/vitnode/src/content/server/http-errors.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +import { HTTPException } from "hono/http-exception"; +import { describe, expect, it } from "vitest"; + +import { withHttpErrors } from "./http-errors"; + +const pgError = (code: string) => + Object.assign(new Error("driver said no"), { code }); + +/** How Drizzle actually surfaces a driver failure. */ +const drizzleWrapped = (code: string) => + Object.assign(new Error("Failed query"), { cause: pgError(code) }); + +const reject = async (error: unknown): Promise => { + await Promise.resolve(); + throw error; +}; + +const statusOf = async ( + error: unknown, + action: "create" | "delete" | "update", +) => { + try { + await withHttpErrors(action, async () => await reject(error)); + } catch (thrown) { + if (thrown instanceof HTTPException) return thrown.status; + throw thrown; + } + + return 200; +}; + +describe("withHttpErrors", () => { + it("passes a successful result through", async () => { + await expect( + withHttpErrors("create", async () => await Promise.resolve("ok")), + ).resolves.toBe("ok"); + }); + + it("maps a restricted delete to 409", async () => { + await expect(statusOf(pgError("23503"), "delete")).resolves.toBe(409); + }); + + it("maps a missing relation on write to 400", async () => { + await expect(statusOf(pgError("23503"), "create")).resolves.toBe(400); + await expect(statusOf(pgError("23503"), "update")).resolves.toBe(400); + }); + + it("maps a unique violation to 409", async () => { + await expect(statusOf(pgError("23505"), "create")).resolves.toBe(409); + }); + + it("maps a not-null violation to 400", async () => { + await expect(statusOf(pgError("23502"), "create")).resolves.toBe(400); + }); + + it("unwraps the code Drizzle hides behind `cause`", async () => { + // Drizzle throws `DrizzleQueryError`, whose own `code` is undefined - the + // real Postgres error sits on `cause`. + await expect(statusOf(drizzleWrapped("23503"), "delete")).resolves.toBe( + 409, + ); + await expect(statusOf(drizzleWrapped("23505"), "create")).resolves.toBe( + 409, + ); + }); + + it("never leaks the driver message", async () => { + try { + await withHttpErrors( + "delete", + async () => await reject(drizzleWrapped("23503")), + ); + } catch (error) { + expect((error as HTTPException).message).not.toContain("driver said no"); + } + }); + + it("rethrows anything it does not recognise, for the 500 handler", async () => { + const unknown = new Error("boom"); + + await expect( + withHttpErrors("create", async () => await reject(unknown)), + ).rejects.toBe(unknown); + }); + + it("passes an HTTPException through untouched", async () => { + const notFound = new HTTPException(404); + + await expect( + withHttpErrors("update", async () => await reject(notFound)), + ).rejects.toBe(notFound); + }); +}); diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts new file mode 100644 index 000000000..f1a393797 --- /dev/null +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -0,0 +1,76 @@ +import { HTTPException } from "hono/http-exception"; +import { ZodError } from "zod"; + +/** Postgres error codes the engine translates into a useful HTTP status. */ +const FOREIGN_KEY_VIOLATION = "23503"; +const UNIQUE_VIOLATION = "23505"; +const NOT_NULL_VIOLATION = "23502"; + +/** + * Digs the Postgres error code out of whatever the driver threw. + * + * Drizzle wraps driver failures in a `DrizzleQueryError` whose own `code` is + * undefined and whose `cause` holds the real error, so reading `error.code` + * alone would turn every constraint violation into a 500. + */ +const errorCode = (error: unknown, depth = 0): string | undefined => { + if (typeof error !== "object" || error === null || depth > 3) + return undefined; + + const { cause, code } = error as { cause?: unknown; code?: unknown }; + if (typeof code === "string" && code !== "") return code; + + return errorCode(cause, depth + 1); +}; + +/** + * Turns a Postgres constraint failure into an HTTP response. + * + * The driver's message can name columns, constraints and even values, so it + * never reaches the client - only a generic sentence does. Anything unrecognised + * is rethrown for `app.onError`, which logs the detail and returns a bare 500. + */ +export const rethrowAsHttpError = ( + error: unknown, + { action }: { action: "create" | "delete" | "update" }, +): never => { + // The service validates its own input, so a payload that slipped past the + // route's validator surfaces here. The issue tree stays out of the response: + // it names internal field paths, and the route schema already described the + // contract in OpenAPI. + if (error instanceof ZodError) { + throw new HTTPException(400, { message: "Invalid input data." }); + } + + switch (errorCode(error)) { + case FOREIGN_KEY_VIOLATION: + throw new HTTPException(action === "delete" ? 409 : 400, { + message: + action === "delete" + ? "This record is still referenced by other content." + : "A related record does not exist.", + }); + case NOT_NULL_VIOLATION: + throw new HTTPException(400, { message: "A required field is missing." }); + case UNIQUE_VIOLATION: + throw new HTTPException(409, { + message: "A record with these values already exists.", + }); + default: + throw error; + } +}; + +/** Runs a write and maps any constraint failure onto an HTTP status. */ +export const withHttpErrors = async ( + action: "create" | "delete" | "update", + run: () => Promise, +): Promise => { + try { + return await run(); + } catch (error) { + if (error instanceof HTTPException) throw error; + + return rethrowAsHttpError(error, { action }); + } +}; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts new file mode 100644 index 000000000..cfa76be02 --- /dev/null +++ b/packages/vitnode/src/content/server/index.ts @@ -0,0 +1,45 @@ +/** + * Universal Content Engine - server surface. + * + * Imports Drizzle, so this must never be reachable from a client component. + * It must also never import `server-only`: that package's `default` export + * throws under plain Node, and both `apps/api` and `drizzle-kit` load these + * modules in plain Node. + */ +export { buildContentColumn, buildSystemColumns } from "./column-builders"; +export type { ColumnReferenceThunk } from "./column-builders"; +export { emitContentEvent } from "./emit"; +export { rethrowAsHttpError, withHttpErrors } from "./http-errors"; +export { createContentModel } from "./model"; +export type { ContentModel } from "./model"; +export { buildContentAdminModule } from "./module"; +export { + buildFilterCondition, + buildOrderColumn, + buildSearchCondition, + diffChangedFields, + escapeLikePattern, + toColumnValues, +} from "./query"; +export { buildContentRoutes } from "./routes"; +export { createContentService } from "./service"; +export type { + ContentDatabase, + ContentFindManyArgs, + ContentLabels, + ContentListRow, + ContentPageInfo, + ContentService, + ContentServiceOptions, + ContentUpdateResult, +} from "./service"; +export { contentTableColumns, createContentTable } from "./table"; +export type { + ContentColumnBuilder, + ContentColumnBuilders, + ContentColumnName, + ContentReferences, + ContentSystemColumnBuilders, + ContentTable, + ContentTableFor, +} from "./types"; diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts new file mode 100644 index 000000000..ee781dfda --- /dev/null +++ b/packages/vitnode/src/content/server/model.ts @@ -0,0 +1,75 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import type { ContentSchemas } from "../schemas"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentService } from "./service"; +import type { + ContentColumnName, + ContentReferences, + ContentTableFor, +} from "./types"; + +import { createContentService } from "./service"; +import { contentTableColumns, createContentTable } from "./table"; + +export interface ContentModel { + /** Column name -> Drizzle column, for filters, ordering and custom queries. */ + columns: Record, PgColumn>; + definition: TDefinition; + /** The definition's schemas, re-typed for this concrete content type. */ + schemas: ContentSchemas; + /** Typed repository bound to the request's database handle. */ + service: (c: Context) => ContentService; + /** The generated `pgTable`. Export it so Drizzle Kit can find it. */ + table: ContentTableFor; +} + +/** + * Turns a content type definition into its database model. + * + * Belongs in the plugin's `src/database/.ts`, next to the table export + * Drizzle Kit globs: + * + * ```ts + * export const articleContent = createContentModel(articleContentType, { + * references: { category: () => example_categories.id }, + * }); + * + * export const example_articles = articleContent.table; + * ``` + * + * Server-only. Never import it from a client component - and never add + * `server-only` to this module either, since `apps/api` and `drizzle-kit` both + * load it in plain Node, where that package throws. + */ +export const createContentModel = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + options: { references?: ContentReferences } = {}, +): ContentModel => { + const table = createContentTable(definition, options); + const columns = contentTableColumns(definition, table); + // `ContentTypeDefinition` declares `schemas` against its own type parameters, + // and reading it through the `AnyContentTypeDefinition` constraint widens the + // row types back to the base field map. The object was built from this very + // definition, so this restores what TypeScript lost rather than asserting + // anything new. + const schemas: ContentSchemas = definition.schemas; + + return { + columns, + definition, + schemas, + service: (c: Context) => + createContentService({ + c, + columns, + definition, + schemas, + table, + }), + table, + }; +}; diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts new file mode 100644 index 000000000..bb201a09e --- /dev/null +++ b/packages/vitnode/src/content/server/module.ts @@ -0,0 +1,56 @@ +import type { BuildModuleReturn } from "../../api/lib/module"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; + +import { buildModule } from "../../api/lib/module"; +import { buildContentRoutes } from "./routes"; +import { assertContentReferences } from "./table"; + +/** + * Builds the generated CRUD module for a plugin's content types. + * + * Nest it inside the plugin's own `admin` module - Hono only serves the last + * sub-app mounted at a given prefix, so the engine must never add a second + * `/admin` of its own: + * + * ```ts + * export const adminModule = buildModule({ + * pluginId: CONFIG_PLUGIN.pluginId, + * name: "admin", + * routes: [], + * modules: [buildContentAdminModule({ pluginId, contentTypes: [articleContent] })], + * }); + * ``` + * + * That yields `/api/{pluginId}/admin/content/{module}`. `buildApiPlugin` walks + * the module tree, so the content types registered here also drive the + * registry and the derived staff permissions - they are declared exactly once. + */ +export const buildContentAdminModule =

({ + contentTypes, + pluginId, +}: { + contentTypes: ContentModel[]; + pluginId: P; +}): BuildModuleReturn => { + const modules = contentTypes.map(model => { + // Every `src/database/*.ts` has loaded by the time this module is built, so + // it is the first safe moment to check that each relation points at the + // table its descriptor promised. + assertContentReferences(model.table); + + return buildModule({ + pluginId, + name: model.definition.permissionModule, + routes: buildContentRoutes(model, { pluginId }), + }); + }); + + return buildModule({ + pluginId, + name: "content", + routes: [], + modules, + contentTypes: contentTypes.map(model => model.definition), + }); +}; diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts new file mode 100644 index 000000000..3e71a6baa --- /dev/null +++ b/packages/vitnode/src/content/server/query.test.ts @@ -0,0 +1,349 @@ +// @vitest-environment node +import type { SQL } from "drizzle-orm"; + +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { defineContentType } from "@/content/define"; +import { field } from "@/content/fields"; +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError } from "../errors"; +import { + buildFilterCondition, + buildOrderColumn, + buildSearchCondition, + diffChangedFields, + escapeLikePattern, + toColumnValues, +} from "./query"; +import { contentTableColumns, createContentTable } from "./table"; + +const categories = createContentTable( + testArticleContentType.fields.category.kind === "relation" + ? testArticleContentType.fields.category.target() + : testArticleContentType, +); +const table = createContentTable(testArticleContentType, { + references: { category: () => categories.id }, +}); +const columns = contentTableColumns(testArticleContentType, table); +const fields = testArticleContentType.fields; +const contentTypeId = testArticleContentType.id; + +/** + * A focused fixture for the null-filter rules: the shared article type has a + * nullable `user` field but no nullable *relation*, and both sides of that rule + * need proving. + */ +const referenceType = defineContentType({ + id: "test.reference", + tableName: "test_references", + fields: { + parent: field.relation({ + nullable: true, + onDelete: "set null", + target: () => testCategoryContentType, + }), + root: field.relation({ + required: true, + onDelete: "restrict", + target: () => testCategoryContentType, + }), + }, + admin: { label: { plural: "References", singular: "Reference" } }, +}); + +const referenceTable = createContentTable(referenceType, { + references: { parent: () => categories.id, root: () => categories.id }, +}); +const referenceColumns = contentTableColumns(referenceType, referenceTable); + +const dialect = new PgDialect(); + +/** The SQL text and bound parameters Drizzle would actually send. */ +const compile = ( + condition: SQL | undefined, +): { params: unknown[]; sql: string } => { + if (!condition) throw new Error("Expected a condition."); + + const { params, sql } = dialect.sqlToQuery(condition); + + return { params, sql }; +}; + +/** Pulls the bound `ilike` patterns out of a built SQL condition. */ +const patternsIn = (condition: unknown): string[] => { + if (typeof condition === "string") return [condition]; + if ( + condition && + typeof condition === "object" && + "queryChunks" in condition && + Array.isArray(condition.queryChunks) + ) { + return condition.queryChunks.flatMap(patternsIn); + } + + return []; +}; + +describe("escapeLikePattern", () => { + it.each([ + ["100%", "100\\%"], + ["a_b", "a\\_b"], + ["back\\slash", "back\\\\slash"], + ["plain", "plain"], + ])("escapes %s", (input, expected) => { + expect(escapeLikePattern(input)).toBe(expected); + }); +}); + +describe("buildSearchCondition", () => { + it("returns nothing without a term or columns", () => { + expect(buildSearchCondition([columns.title], undefined)).toBeUndefined(); + expect(buildSearchCondition([columns.title], " ")).toBeUndefined(); + expect(buildSearchCondition([], "hello")).toBeUndefined(); + }); + + it("escapes wildcards so a literal % cannot match every row", () => { + expect(patternsIn(buildSearchCondition([columns.title], "100%"))).toEqual([ + "%100\\%%", + ]); + }); + + it("passes a plain term through unescaped", () => { + expect(patternsIn(buildSearchCondition([columns.title], "hello"))).toEqual([ + "%hello%", + ]); + }); + + it("searches every given column", () => { + expect( + patternsIn(buildSearchCondition([columns.title, columns.excerpt], "hi")), + ).toEqual(["%hi%", "%hi%"]); + }); +}); + +describe("buildFilterCondition", () => { + const filter = (filters: Record) => + buildFilterCondition({ columns, contentTypeId, fields, filters }); + + it("ignores undefined values", () => { + expect(filter({ status: undefined })).toBeUndefined(); + }); + + it("joins several conditions with and", () => { + const { params, sql } = compile(filter({ category: 3, status: "draft" })); + + expect(sql).toBe( + '("test_articles"."category" = $1 and "test_articles"."status" = $2)', + ); + expect(params).toEqual([3, "draft"]); + }); + + it.each([ + ["text", "title", "Hello", "Hello"], + ["enum", "status", "draft", "draft"], + ["number", "views", 3, 3], + ["boolean", "featured", true, true], + ["relation", "category", 3, 3], + ["user", "author", 5, 5], + ])("filters a %s field by equality", (_kind, name, value, expected) => { + const { params, sql } = compile(filter({ [name]: value })); + + expect(sql).toBe(`"test_articles"."${name}" = $1`); + expect(params).toEqual([expected]); + }); + + it("coerces the string form of a boolean filter", () => { + expect(compile(filter({ featured: "true" })).params).toEqual([true]); + expect(compile(filter({ featured: "false" })).params).toEqual([false]); + }); + + it("rejects a filter that is not a declared field", () => { + expect(() => filter({ "id; drop table": 1 })).toThrow(ContentEngineError); + }); + + // The public filter type already excludes these kinds. A cast, a JavaScript + // caller or an object assembled at runtime does not, so the runtime guard is + // the one that actually holds the contract. + it("rejects a textarea field, which has no equality filter", () => { + expect(() => filter({ excerpt: "text" })).toThrow( + /Field "excerpt" of kind "textarea" cannot be used as a generated equality filter/, + ); + }); + + it("rejects a dateTime field, which has no equality filter", () => { + expect(() => filter({ publishedAt: "2026-08-03T10:00:00.000Z" })).toThrow( + /Field "publishedAt" of kind "dateTime" cannot be used as a generated equality filter/, + ); + }); + + it("names the content type when it rejects a kind", () => { + expect(() => filter({ excerpt: "text" })).toThrow(/test\.article/); + }); + + describe("null", () => { + const referenceFilter = (filters: Record) => + buildFilterCondition({ + columns: referenceColumns, + contentTypeId: referenceType.id, + fields: referenceType.fields, + filters, + }); + + it("builds IS NULL for a nullable user field", () => { + const { params, sql } = compile(filter({ author: null })); + + expect(sql).toBe('"test_articles"."author" is null'); + expect(params).toEqual([]); + }); + + it("builds IS NULL for a nullable relation field", () => { + expect(compile(referenceFilter({ parent: null })).sql).toBe( + '"test_references"."parent" is null', + ); + }); + + it("still uses equality for a real identifier", () => { + const { params, sql } = compile(referenceFilter({ parent: 4 })); + + expect(sql).toBe('"test_references"."parent" = $1'); + expect(params).toEqual([4]); + }); + + it("rejects null on a non-nullable relation rather than generating IS NULL", () => { + expect(() => referenceFilter({ root: null })).toThrow( + /Field "root" is not nullable/, + ); + expect(() => referenceFilter({ root: null })).toThrow(/test\.reference/); + }); + + it("mixes IS NULL with an equality condition", () => { + expect(compile(referenceFilter({ parent: null, root: 2 })).sql).toBe( + '("test_references"."parent" is null and "test_references"."root" = $1)', + ); + }); + }); +}); + +describe("buildOrderColumn", () => { + const orderable = ["title", "status", "createdAt", "updatedAt", "id"]; + + it("falls back to the default when nothing is requested", () => { + expect( + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: undefined, + orderable, + }), + ).toBe(columns.updatedAt); + }); + + it("resolves an allowlisted column", () => { + expect( + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: "title", + orderable, + }), + ).toBe(columns.title); + }); + + it("rejects a column outside the allowlist", () => { + expect(() => + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: "views", + orderable, + }), + ).toThrow(/Cannot order by "views"/); + }); + + it("never lets a raw identifier through", () => { + expect(() => + buildOrderColumn({ + columns, + contentTypeId, + fallback: "updatedAt", + orderBy: "id) --", + orderable, + }), + ).toThrow(ContentEngineError); + }); +}); + +describe("diffChangedFields", () => { + const names = ["publishedAt", "status", "title", "views"] as const; + const current = { + publishedAt: new Date("2026-01-01T00:00:00.000Z"), + status: "draft", + title: "Hello", + views: 3, + }; + + it("reports only the keys that actually moved", () => { + expect( + diffChangedFields(names, current, { + status: "draft", + title: "Changed", + }), + ).toEqual(["title"]); + }); + + it("ignores undefined values", () => { + expect(diffChangedFields(names, current, { title: undefined })).toEqual([]); + }); + + it("never reports a key the content type does not declare", () => { + expect( + diffChangedFields(names, current, { smuggled: "value", title: "Moved" }), + ).toEqual(["title"]); + }); + + it("compares dates by instant, not identity", () => { + expect( + diffChangedFields(names, current, { + publishedAt: "2026-01-01T00:00:00.000Z", + }), + ).toEqual([]); + expect( + diffChangedFields(names, current, { + publishedAt: "2026-02-01T00:00:00.000Z", + }), + ).toEqual(["publishedAt"]); + }); + + it("treats clearing a date as a change", () => { + expect(diffChangedFields(names, current, { publishedAt: null })).toEqual([ + "publishedAt", + ]); + }); +}); + +describe("toColumnValues", () => { + it("turns ISO strings into Dates for dateTime fields only", () => { + const result = toColumnValues(fields, { + publishedAt: "2026-08-02T10:00:00.000Z", + title: "2026-08-02T10:00:00.000Z", + }); + + expect(result.publishedAt).toBeInstanceOf(Date); + expect(result.title).toBe("2026-08-02T10:00:00.000Z"); + }); + + it("leaves null alone", () => { + expect( + toColumnValues(fields, { publishedAt: null }).publishedAt, + ).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts new file mode 100644 index 000000000..0f30bd419 --- /dev/null +++ b/packages/vitnode/src/content/server/query.ts @@ -0,0 +1,184 @@ +import type { SQL } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; + +import { and, eq, ilike, isNull, or } from "drizzle-orm"; + +import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; + +import { + CONTENT_FILTERABLE_FIELD_KINDS, + isFilterableFieldKind, +} from "../const"; +import { ContentEngineError } from "../errors"; + +/** + * Escapes the `LIKE` wildcards so a search for "100%" matches the literal text + * rather than every row. Backslash is Postgres' default escape character. + */ +export const escapeLikePattern = (value: string): string => + value.replace(/[\\%_]/g, match => `\\${match}`); + +export const buildSearchCondition = ( + columns: readonly PgColumn[], + term: string | undefined, +): SQL | undefined => { + const trimmed = term?.trim(); + if (!columns.length || !trimmed) return undefined; + + const pattern = `%${escapeLikePattern(trimmed)}%`; + + return or(...columns.map(column => ilike(column, pattern))); +}; + +const filterValue = ( + fieldValue: ContentFieldDescriptor, + raw: unknown, +): unknown => { + if (fieldValue.kind === "boolean") return raw === "true" || raw === true; + + return raw; +}; + +/** + * Builds an equality filter from validated query parameters. + * + * Filter keys are looked up in the column map, so no caller can reach a SQL + * identifier. A key that is not a declared field is a hard error here rather + * than something quietly dropped - the generated list route has already stripped + * the query-string keys that are not filters, so anything arriving with an + * unrecognised key came from code, and code should hear about it. + * + * The public filter type also excludes the kinds that have no equality filter, + * and `null` on a `NOT NULL` field. Both are re-checked here because a cast, a + * plain-JavaScript caller or an object built at runtime can bypass the type - + * and the type is not what protects the query. + */ +export const buildFilterCondition = ({ + columns, + contentTypeId, + fields, + filters, +}: { + columns: Record; + contentTypeId: string; + fields: ContentFieldMap; + filters: Record; +}): SQL | undefined => { + const conditions: SQL[] = []; + + for (const [name, raw] of Object.entries(filters)) { + if (raw === undefined) continue; + + const fieldValue = fields[name]; + const column = columns[name]; + if (!fieldValue || !column) { + throw new ContentEngineError(`Unknown filter "${name}".`, { + contentTypeId, + }); + } + + if (!isFilterableFieldKind(fieldValue.kind)) { + throw new ContentEngineError( + `Field "${name}" of kind "${fieldValue.kind}" cannot be used as a generated equality filter. Filterable kinds: ${CONTENT_FILTERABLE_FIELD_KINDS.join(", ")}. Write a custom route for anything else.`, + { contentTypeId }, + ); + } + + // `null` is a value, not a parameter: `column = NULL` is never true. + if (raw === null) { + if (!fieldValue.nullable) { + throw new ContentEngineError( + `Field "${name}" is not nullable, so it can never hold null. Drop the filter, or declare the field \`nullable: true\`.`, + { contentTypeId }, + ); + } + + conditions.push(isNull(column)); + continue; + } + + conditions.push(eq(column, filterValue(fieldValue, raw))); + } + + if (conditions.length === 0) return undefined; + + return conditions.length === 1 ? conditions[0] : and(...conditions); +}; + +/** + * Resolves `orderBy` against the allowlist. The request only ever picks a name + * from the list; the column object itself comes from the model. + */ +export const buildOrderColumn = ({ + columns, + contentTypeId, + fallback, + orderBy, + orderable, +}: { + columns: Record; + contentTypeId: string; + fallback: string; + orderable: readonly string[]; + orderBy: string | undefined; +}): PgColumn => { + const name = orderBy ?? fallback; + + if (!orderable.includes(name)) { + throw new ContentEngineError( + `Cannot order by "${name}". Allowed: ${orderable.join(", ")}.`, + { contentTypeId }, + ); + } + + const column = columns[name]; + if (!column) { + throw new ContentEngineError(`No column named "${name}".`, { + contentTypeId, + }); + } + + return column; +}; + +const sameValue = (current: unknown, next: unknown): boolean => { + if (current instanceof Date) { + if (next === null || next === undefined) return false; + + return current.getTime() === new Date(next as string).getTime(); + } + + return current === next; +}; + +/** + * The keys an update actually changes. Values equal to what is already stored + * are dropped, so `content.*.updated` never reports a field that did not move. + * + * Driven by the content type's own field names rather than by `Object.keys` on + * the patch: that keeps the result typed as the field-name union, and it can + * never surface a key the content type does not declare. + */ +export const diffChangedFields = ( + fieldNames: readonly TName[], + current: Record, + patch: Record, +): TName[] => + fieldNames.filter( + name => patch[name] !== undefined && !sameValue(current[name], patch[name]), + ); + +/** `dateTime` values arrive as ISO strings and have to become `Date` columns. */ +export const toColumnValues = ( + fields: ContentFieldMap, + values: Record, +): Record => + Object.fromEntries( + Object.entries(values).map(([name, value]) => { + if (fields[name]?.kind !== "dateTime" || typeof value !== "string") { + return [name, value]; + } + + return [name, new Date(value)]; + }), + ); diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts new file mode 100644 index 000000000..8b2969837 --- /dev/null +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -0,0 +1,451 @@ +// @vitest-environment node +import type { Context, MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; + +let permissionGranted = true; + +// `assertStaffPermission` reads roles out of the database. The routes' job is +// to *call* it with the right module and permission, so the check itself is +// replaced with a switchable verdict. +vi.mock("../../api/lib/check-staff-permission", () => ({ + assertStaffPermission: async () => { + if (!permissionGranted) { + const { HTTPException } = await import("hono/http-exception"); + throw new HTTPException(403, { message: "Forbidden" }); + } + }, +})); + +const categories = createContentModel(testCategoryContentType); +const articles = createContentModel(testArticleContentType, { + references: { category: () => categories.table.id }, +}); + +const PLUGIN_ID = "@vitnode/example"; + +const adminUser = { + avatarColor: "000000", + birthday: null, + createdAt: new Date(), + email: "test@test.com", + emailVerified: true, + id: 1, + language: "en", + name: "Test", + nameCode: "test", + newsletter: false, + roleId: 1, +}; + +interface Harness { + app: OpenAPIHono; + emitted: { name: string; payload: unknown }[]; + service: Record>; +} + +/** + * Mounts the generated routes with the service and permission check stubbed, + * so each test drives the real Hono pipeline (validation, status codes, error + * mapping) without a database. + */ +const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { + const emitted: Harness["emitted"] = []; + const service = { + create: vi.fn(), + delete: vi.fn(), + findById: vi.fn(), + findMany: vi.fn(), + options: vi.fn(), + update: vi.fn(), + }; + + permissionGranted = allow; + vi.spyOn(articles, "service").mockReturnValue(service); + + const app = new OpenAPIHono(); + + // Stands in for `globalMiddleware` + the admin session middleware. + const context: MiddlewareHandler = async (c, next) => { + c.set("events", { + emit: async (name: string, payload: unknown) => { + await Promise.resolve(); + emitted.push({ name, payload }); + }, + } as unknown as Context["var"]["events"]); + c.set("admin", allow ? { user: adminUser } : null); + await next(); + }; + app.use("*", context); + + for (const { handler, route } of buildContentRoutes(articles, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, emitted, service }; +}; + +const json = (body: unknown) => ({ + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, +}); + +const row = { + author: null, + category: 1, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + excerpt: null, + featured: false, + id: 7, + publishedAt: null, + status: "draft" as const, + title: "Hello world", + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + views: 0, +}; + +describe("generated content routes", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe("list", () => { + it("returns edges and pageInfo", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ + edges: [{ ...row, labels: { author: null, category: "News" } }], + pageInfo: { + count: 1, + endCursor: 7, + hasNextPage: false, + hasPreviousPage: false, + startCursor: 7, + totalCount: 1, + }, + }); + + const res = await app.request("/"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + edges: [{ id: 7, labels: { category: "News" } }], + }); + }); + + it("passes pagination and search through to the service", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ edges: [], pageInfo: {} }); + + await app.request("/?first=5&search=hello&cursor=3"); + + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + query: { cursor: "3", first: "5", last: undefined, search: "hello" }, + }), + ); + }); + + // Neither query schema is strict, so an unrelated parameter is dropped + // rather than turned into a 400. What matters is that it cannot reach the + // service, and that a declared filter still does. + it("ignores unrelated query parameters and passes only declared filters", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ edges: [], pageInfo: {} }); + + const res = await app.request("/?status=published&nope=1&excerpt=prose"); + + expect(res.status).toBe(200); + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ filters: { status: "published" } }), + ); + }); + + it("rejects an order column outside the allowlist", async () => { + const { app } = harness(); + + const res = await app.request("/?orderBy=views"); + + expect(res.status).toBe(400); + }); + + it("accepts an allowlisted order column", async () => { + const { app, service } = harness(); + service.findMany.mockResolvedValue({ edges: [], pageInfo: {} }); + + const res = await app.request("/?orderBy=title&order=asc"); + + expect(res.status).toBe(200); + expect(service.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { column: "title", order: "asc" } }), + ); + }); + }); + + describe("detail", () => { + it("returns the row", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(row); + + const res = await app.request("/7"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ id: 7 }); + }); + + it("returns 404 for a missing row", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(null); + + expect((await app.request("/7")).status).toBe(404); + }); + + it("rejects a non-numeric identifier", async () => { + const { app } = harness(); + + expect((await app.request("/abc")).status).toBe(400); + }); + }); + + describe("create", () => { + it("returns 201 and emits after the write", async () => { + const { app, emitted, service } = harness(); + service.create.mockResolvedValue(row); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, title: "Hello world" }), + }); + + expect(res.status).toBe(201); + expect(emitted).toEqual([ + { name: "content.test.article.created", payload: { contentId: 7 } }, + ]); + }); + + it("returns 400 and emits nothing when validation fails", async () => { + const { app, emitted, service } = harness(); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, title: "no" }), + }); + + expect(res.status).toBe(400); + expect(service.create).not.toHaveBeenCalled(); + expect(emitted).toEqual([]); + }); + + it("rejects unknown keys", async () => { + const { app } = harness(); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, slug: "nope", title: "Hello world" }), + }); + + expect(res.status).toBe(400); + }); + + it("rejects an attempt to set a system column", async () => { + const { app } = harness(); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 1, id: 99, title: "Hello world" }), + }); + + expect(res.status).toBe(400); + }); + + it("maps a foreign key violation to 400 without leaking the driver message", async () => { + const { app, service } = harness(); + service.create.mockRejectedValue( + Object.assign( + new Error('insert violates "example_articles_category_fkey"'), + { + code: "23503", + }, + ), + ); + + const res = await app.request("/", { + method: "POST", + ...json({ category: 999, title: "Hello world" }), + }); + + expect(res.status).toBe(400); + expect(await res.text()).not.toContain("fkey"); + }); + }); + + describe("update", () => { + it("returns 200 and emits the changed fields", async () => { + const { app, emitted, service } = harness(); + service.update.mockResolvedValue({ + changedFields: ["title"], + row: { ...row, title: "Changed" }, + }); + + const res = await app.request("/7", { + method: "PUT", + ...json({ title: "Changed" }), + }); + + expect(res.status).toBe(200); + expect(emitted).toEqual([ + { + name: "content.test.article.updated", + payload: { changedFields: ["title"], contentId: 7 }, + }, + ]); + }); + + it("rejects an empty payload", async () => { + const { app, service } = harness(); + + const res = await app.request("/7", { method: "PUT", ...json({}) }); + + expect(res.status).toBe(400); + expect(service.update).not.toHaveBeenCalled(); + }); + + it("returns 404 for a missing row", async () => { + const { app, service } = harness(); + service.update.mockResolvedValue(null); + + const res = await app.request("/7", { + method: "PUT", + ...json({ title: "Changed" }), + }); + + expect(res.status).toBe(404); + }); + + it("does not emit when nothing actually changed", async () => { + const { app, emitted, service } = harness(); + service.update.mockResolvedValue({ changedFields: [], row }); + + await app.request("/7", { + method: "PUT", + ...json({ title: "Hello world" }), + }); + + expect(emitted).toEqual([]); + }); + }); + + describe("delete", () => { + it("returns 200 and emits after the write", async () => { + const { app, emitted, service } = harness(); + service.delete.mockResolvedValue(row); + + expect((await app.request("/7", { method: "DELETE" })).status).toBe(200); + expect(emitted).toEqual([ + { name: "content.test.article.deleted", payload: { contentId: 7 } }, + ]); + }); + + it("returns 404 for a missing row", async () => { + const { app, service } = harness(); + service.delete.mockResolvedValue(null); + + expect((await app.request("/7", { method: "DELETE" })).status).toBe(404); + }); + + it("maps a restricted foreign key to 409", async () => { + const { app, service } = harness(); + service.delete.mockRejectedValue( + Object.assign(new Error("still referenced"), { code: "23503" }), + ); + + const res = await app.request("/7", { method: "DELETE" }); + + expect(res.status).toBe(409); + }); + }); + + describe("options", () => { + it("returns picker options", async () => { + const { app, service } = harness(); + service.options.mockResolvedValue([{ label: "News", value: 3 }]); + + const res = await app.request("/options/category?search=ne"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + items: [{ label: "News", value: 3 }], + }); + expect(service.options).toHaveBeenCalledWith("category", "ne"); + }); + }); + + describe("staff permissions", () => { + it.each([ + ["GET", "/"], + ["GET", "/7"], + ["GET", "/options/category"], + ["POST", "/"], + ["PUT", "/7"], + ["DELETE", "/7"], + ])("returns 403 for %s %s without permission", async (method, path) => { + const { app } = harness({ allow: false }); + + const res = await app.request(path, { + method, + ...(method === "POST" || method === "PUT" + ? json({ title: "Hello world", category: 1 }) + : {}), + }); + + expect(res.status).toBe(403); + }); + }); + + describe("OpenAPI", () => { + const document = () => + harness().app.getOpenAPIDocument({ + info: { title: "t", version: "1" }, + openapi: "3.0.0", + }); + + it("documents every operation", () => { + const doc = document(); + + expect(Object.keys(doc.paths).sort()).toEqual([ + "/", + "/options/{field}", + "/{id}", + ]); + expect(Object.keys(doc.paths["/{id}"]).sort()).toEqual([ + "delete", + "get", + "put", + ]); + }); + + it.each([ + ["/", "get", ["200", "400"]], + ["/", "post", ["201", "400", "409"]], + ["/{id}", "get", ["200", "400", "404"]], + ["/{id}", "put", ["200", "400", "404", "409"]], + ["/{id}", "delete", ["200", "400", "404", "409"]], + ["/options/{field}", "get", ["200", "400"]], + ])("documents %s %s as %j", (path, method, statuses) => { + const operation = + document().paths[path][method as "delete" | "get" | "post" | "put"]; + + expect(Object.keys(operation?.responses ?? {}).sort()).toEqual(statuses); + }); + }); +}); diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts new file mode 100644 index 000000000..bcf4a345b --- /dev/null +++ b/packages/vitnode/src/content/server/routes.ts @@ -0,0 +1,312 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { + AnyContentTypeDefinition, + ContentFilterInput, + ContentReferenceFieldName, +} from "../types"; +import type { ContentModel } from "./model"; + +import { buildRoute } from "../../api/lib/route"; +import { + zodPaginationPageInfo, + zodPaginationQuery, +} from "../../api/lib/with-pagination"; +import { CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS } from "../const"; +import { orderableColumns } from "../registry"; +import { emitContentEvent } from "./emit"; +import { withHttpErrors } from "./http-errors"; + +const zodLabels = z.record(z.string(), z.string().nullable()); + +const zodOptions = z.object({ + items: z.array(z.object({ label: z.string(), value: z.number() })), +}); + +const notFound = (definition: AnyContentTypeDefinition): HTTPException => + new HTTPException(404, { + message: `${definition.admin.label.singular} not found.`, + }); + +const identifier = (c: Context): number => { + const value = Number(c.req.param("id")); + if (!Number.isInteger(value) || value <= 0) { + throw new HTTPException(400, { message: "Invalid identifier." }); + } + + return value; +}; + +/** + * The five CRUD routes plus the picker-options route for one content type. + * + * Every route carries an explicit `adminStaffPermission`, and every path sits + * under `/admin/` so the global admin session middleware runs - both are + * required for `assertStaffPermission` to have an admin to check. + */ +export const buildContentRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition, schemas } = model; + const module = definition.permissionModule; + const label = definition.admin.label; + + const listRow = schemas.selectObject.extend({ labels: zodLabels }); + + const referenceFieldNames = Object.entries(definition.fields) + .filter( + ([, fieldValue]) => + fieldValue.kind === "relation" || fieldValue.kind === "user", + ) + .map(([name]) => name); + + // A predicate rather than a cast: the picker route takes its field name from + // the URL, so membership has to be proven at runtime anyway. + const isReferenceField = ( + value: string, + ): value is ContentReferenceFieldName => + referenceFieldNames.includes(value); + + // `c.req.valid()` cannot infer through a generic route config, so each + // handler re-reads the validated payload through the very schema that + // produced it. That keeps the handlers cast-free and correctly typed. + const readJson = async ( + c: Context, + schema: z.ZodType, + ): Promise => schema.parse(await c.req.json()); + + // `orderBy` is an enum rather than a string so a column outside the allowlist + // is a 400 at validation time and shows up in the OpenAPI document. The + // service keeps its own allowlist check as defence in depth. + const orderable = orderableColumns(definition) as [string, ...string[]]; + const paginationQuery = zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(orderable).optional(), + search: z.string().optional(), + }); + const jsonBody = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, + }); + const jsonResponse = (schema: z.ZodType, description: string) => ({ + content: { "application/json": { schema } }, + description, + }); + + const invalidIdentifier = { description: "Invalid identifier" }; + const uniqueConflict = { + description: "A record with these values already exists", + }; + + const list = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/", + description: `List ${label.plural}`, + request: { query: paginationQuery.extend(schemas.filters.shape) }, + responses: { + 200: jsonResponse( + z.object({ + edges: z.array(listRow), + pageInfo: zodPaginationPageInfo, + }), + `${label.plural} retrieved successfully`, + ), + 400: { description: "Invalid query parameters" }, + }, + }, + handler: async c => { + // The whole query string goes through both schemas, each of which reads + // only the keys it owns: + // + // paginationQuery cursor, first, last, order, orderBy, search + // schemas.filters one entry per declared filterable field + // + // Neither is strict, so anything else - a stale bookmark, a tracking + // parameter - is ignored rather than turned into a 400. `orderBy` is the + // exception: it is a literal enum, so a *present* but unknown column is a + // 400 at validation time. + const raw = c.req.query(); + const { cursor, first, last, order, orderBy, search } = + paginationQuery.parse(raw); + // Every value is coerced here (query strings carry numbers and booleans as + // text), and an unsupported field cannot survive the parse - so this path + // never hands `buildFilterCondition` a kind it rejects. The service checks + // kind and nullability again for callers that did not come through here. + const filters = schemas.filters.parse(raw) as ContentFilterInput< + typeof definition + >; + + const data = await model.service(c).findMany({ + filters, + orderBy: { column: orderBy, order }, + query: { cursor, first, last, search }, + }); + + return c.json(data, 200); + }, + }); + + const options = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/options/{field}", + description: `Picker options for a ${label.singular} relation`, + request: { + params: z.object({ field: z.string() }), + query: z.object({ search: z.string().optional() }), + }, + responses: { + 200: jsonResponse(zodOptions, `Up to ${CONTENT_OPTIONS_LIMIT} options`), + 400: { description: "Not a relation or user field" }, + }, + }, + handler: async c => { + const field = c.req.param("field"); + if (!isReferenceField(field)) { + throw new HTTPException(400, { + message: "This field has no picker.", + }); + } + + const items = await model + .service(c) + .options(field, c.req.query("search")); + + return c.json({ items }, 200); + }, + }); + + const detail = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}", + description: `Get one ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse(schemas.selectObject, `${label.singular} found`), + 400: invalidIdentifier, + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + const row = await model.service(c).findById(identifier(c)); + if (!row) throw notFound(definition); + + return c.json(row, 200); + }, + }); + + const create = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.create }, + route: { + method: "post", + path: "/", + description: `Create a ${label.singular}`, + request: { body: jsonBody(schemas.create) }, + responses: { + 201: jsonResponse( + schemas.selectObject, + `${label.singular} created successfully`, + ), + 400: { description: "Invalid input data" }, + 409: uniqueConflict, + }, + }, + handler: async c => { + const values = await readJson(c, schemas.create); + + const row = await withHttpErrors("create", async () => + model.service(c).create(values), + ); + + // Emitted only once the write has returned, never inside a transaction. + await emitContentEvent(c, definition, "created", { contentId: row.id }); + + return c.json(row, 201); + }, + }); + + const update = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + // PUT, not PATCH: the Next.js API route handler exports no PATCH. + method: "put", + path: "/{id}", + description: `Update a ${label.singular}`, + request: { params: schemas.params, body: jsonBody(schemas.update) }, + responses: { + 200: jsonResponse( + schemas.selectObject, + `${label.singular} updated successfully`, + ), + 400: { description: "Invalid or empty payload" }, + 404: { description: `${label.singular} not found` }, + 409: uniqueConflict, + }, + }, + handler: async c => { + const values = await readJson(c, schemas.update); + + const result = await withHttpErrors("update", async () => + model.service(c).update(identifier(c), values), + ); + if (!result) throw notFound(definition); + + if (result.changedFields.length > 0) { + await emitContentEvent(c, definition, "updated", { + changedFields: result.changedFields, + contentId: result.row.id, + }); + } + + return c.json(result.row, 200); + }, + }); + + const remove = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, + route: { + method: "delete", + path: "/{id}", + description: `Delete a ${label.singular}`, + request: { params: schemas.params }, + responses: { + 200: jsonResponse( + schemas.selectObject, + `${label.singular} deleted successfully`, + ), + 400: invalidIdentifier, + 404: { description: `${label.singular} not found` }, + 409: { description: "Still referenced by other content" }, + }, + }, + handler: async c => { + const row = await withHttpErrors("delete", async () => + model.service(c).delete(identifier(c)), + ); + if (!row) throw notFound(definition); + + await emitContentEvent(c, definition, "deleted", { contentId: row.id }); + + return c.json(row, 200); + }, + }); + + return [list, options, detail, create, update, remove]; +}; diff --git a/packages/vitnode/src/content/server/service.test-d.ts b/packages/vitnode/src/content/server/service.test-d.ts new file mode 100644 index 000000000..5607e6fd2 --- /dev/null +++ b/packages/vitnode/src/content/server/service.test-d.ts @@ -0,0 +1,154 @@ +import type { Context } from "hono"; + +import { describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { + ContentFieldKind, + ContentFieldName, + FilterableContentFieldKind, +} from "../types"; +import type { ContentUpdateResult } from "./service"; + +import { createContentModel } from "./model"; + +const categories = createContentModel(testCategoryContentType); +const articles = createContentModel(testArticleContentType, { + references: { category: () => categories.table.id }, +}); + +type ArticleType = typeof testArticleContentType; + +// Never executed - the type checker is the whole point. +const service = articles.service({} as Context); + +describe("findMany filters", () => { + it("accepts every filterable field", () => { + void service.findMany({ + filters: { + author: 4, + category: 2, + featured: true, + status: "published", + title: "Hello", + views: 10, + }, + }); + }); + + it("rejects a field name that does not exist", () => { + void service.findMany({ + // @ts-expect-error - no such field + filters: { nope: 1 }, + }); + }); + + it("rejects fields the filter schema does not generate", () => { + void service.findMany({ + // @ts-expect-error - `textarea` is not filterable + filters: { excerpt: "prose" }, + }); + void service.findMany({ + // @ts-expect-error - `dateTime` is not filterable + filters: { publishedAt: "2026-08-02T10:00:00.000Z" }, + }); + }); + + it("keeps enum filter values literal", () => { + void service.findMany({ + // @ts-expect-error - "sideways" is not one of the declared values + filters: { status: "sideways" }, + }); + }); + + it("takes identifiers for relation and user filters", () => { + void service.findMany({ + // @ts-expect-error - a relation filter is an id, not a label + filters: { category: "News" }, + }); + }); + + it("accepts null for a nullable field only", () => { + void service.findMany({ filters: { author: null } }); + void service.findMany({ + // @ts-expect-error - `category` is required and NOT NULL + filters: { category: null }, + }); + }); + + it("only names kinds that exist", () => { + expectTypeOf().toExtend(); + }); +}); + +describe("findMany ordering", () => { + it("accepts system columns", () => { + void service.findMany({ orderBy: { column: "createdAt" } }); + void service.findMany({ orderBy: { column: "id", order: "asc" } }); + }); + + it("accepts declared fields", () => { + void service.findMany({ orderBy: { column: "title" } }); + }); + + it("rejects a column that is not part of the content type", () => { + // The exact `orderableFields` array is not recoverable from the resolved + // admin config, so the type is an approximation - anything outside the + // content type still fails here, and the runtime allowlist is stricter. + // @ts-expect-error - not a column of this content type + void service.findMany({ orderBy: { column: "somethingElse" } }); + }); +}); + +describe("options", () => { + it("accepts relation and user fields", () => { + void service.options("category"); + void service.options("author"); + }); + + it("rejects every other field kind", () => { + // @ts-expect-error - a text field has no picker to enumerate + void service.options("title"); + // @ts-expect-error - a textarea field has no picker to enumerate + void service.options("excerpt"); + // @ts-expect-error - a number field has no picker to enumerate + void service.options("views"); + // @ts-expect-error - a boolean field has no picker to enumerate + void service.options("featured"); + // @ts-expect-error - an enum renders its own values, not a picker + void service.options("status"); + // @ts-expect-error - a dateTime field has no picker to enumerate + void service.options("publishedAt"); + }); +}); + +describe("update result", () => { + it("narrows changedFields to the content type's field names", () => { + expectTypeOf< + ContentUpdateResult["changedFields"] + >().toEqualTypeOf[]>(); + + expectTypeOf< + ContentUpdateResult["changedFields"][number] + >().toEqualTypeOf< + | "author" + | "category" + | "excerpt" + | "featured" + | "publishedAt" + | "status" + | "title" + | "views" + >(); + }); + + it("keeps the row typed as the content type's select shape", () => { + expectTypeOf< + ContentUpdateResult["row"]["status"] + >().toEqualTypeOf<"archived" | "draft" | "published">(); + }); +}); diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts new file mode 100644 index 000000000..5c74280b2 --- /dev/null +++ b/packages/vitnode/src/content/server/service.test.ts @@ -0,0 +1,440 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { + ContentCreateInput, + ContentFilterInput, + ContentUpdateInput, +} from "../types"; + +import { ContentEngineError } from "../errors"; +import { createContentModel } from "./model"; + +type ArticleType = typeof testArticleContentType; + +const categories = createContentModel(testCategoryContentType); +const articles = createContentModel(testArticleContentType, { + references: { category: () => categories.table.id }, +}); + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** + * A chainable stand-in for the Drizzle client. Each top-level `select`, + * `insert`, `update` or `delete` shifts the next queued result, and every + * builder call is recorded so tests can assert on the shape of the query. + */ +const createDbMock = (results: unknown[][]) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + + const chain = (rows: unknown[]) => { + const record = (op: string, arg: unknown) => { + calls.push({ arg, op }); + + return builder; + }; + + const builder = { + $dynamic: () => builder, + from: (value: unknown) => record("from", value), + leftJoin: (value: unknown) => record("leftJoin", value), + limit: (value: unknown) => record("limit", value), + orderBy: (value: unknown) => record("orderBy", value), + returning: (value: unknown) => record("returning", value), + set: (value: unknown) => record("set", value), + then: async (resolve: (rows: unknown[]) => TResult) => + Promise.resolve(rows).then(resolve), + values: (value: unknown) => record("values", value), + where: (value: unknown) => record("where", value), + }; + + return builder; + }; + + const start = (op: string) => (arg: unknown) => { + calls.push({ arg, op }); + + return chain(queue.shift() ?? []); + }; + + const db = { + delete: start("delete"), + insert: start("insert"), + select: start("select"), + update: start("update"), + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as Context; + + return { c, calls }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +describe("content service", () => { + describe("create", () => { + it("inserts the values and returns the created row", async () => { + const { c, calls } = createDbMock([[{ id: 1, title: "Hello" }]]); + + const row = await articles.service(c).create({ + category: 2, + title: "Hello", + }); + + expect(row).toEqual({ id: 1, title: "Hello" }); + // The declared defaults come from `schemas.create`, and match the column + // defaults exactly - both are generated from the same descriptor. + expect(opsOf(calls, "values")[0]).toEqual({ + category: 2, + featured: false, + status: "draft", + title: "Hello", + views: 0, + }); + }); + + it("converts an ISO dateTime string into a Date column value", async () => { + const { c, calls } = createDbMock([[{ id: 1 }]]); + + await articles.service(c).create({ + category: 2, + publishedAt: "2026-08-02T10:00:00.000Z", + title: "Hello", + }); + + const values = opsOf(calls, "values")[0] as { publishedAt: Date }; + expect(values.publishedAt).toBeInstanceOf(Date); + expect(values.publishedAt.toISOString()).toBe("2026-08-02T10:00:00.000Z"); + }); + }); + + // The generated routes validate too, but the service is a public API: a + // plugin can call it straight from its own route, and the Content Engine's + // invariants have to survive that. + describe("create validation", () => { + const create = (values: Record) => { + const { c, calls } = createDbMock([[{ id: 1 }]]); + // Deliberately ill-typed input: these tests exist to prove the *runtime* + // guard holds for callers that reached the service some other way. + const run = articles + .service(c) + .create(values as unknown as ContentCreateInput); + + return { calls, run }; + }; + + const rejects = async (values: Record) => { + const { calls, run } = create(values); + + await expect(run).rejects.toBeInstanceOf(ZodError); + + return calls; + }; + + it("rejects text shorter than minLength", async () => { + await rejects({ category: 1, title: "no" }); + }); + + it("rejects text longer than maxLength", async () => { + await rejects({ category: 1, title: "x".repeat(201) }); + }); + + it("rejects a value outside the enum", async () => { + await rejects({ category: 1, status: "sideways", title: "Hello" }); + }); + + it("rejects a number below min", async () => { + await rejects({ category: 1, title: "Hello", views: -1 }); + }); + + it("rejects an unknown field", async () => { + await rejects({ category: 1, smuggled: true, title: "Hello" }); + }); + + it("rejects a system field", async () => { + await rejects({ category: 1, id: 99, title: "Hello" }); + }); + + it("rejects a relation id that is not a positive integer", async () => { + await rejects({ category: 0, title: "Hello" }); + await rejects({ category: 1.5, title: "Hello" }); + }); + + it("rejects a malformed ISO date", async () => { + await rejects({ + category: 1, + publishedAt: "the day before yesterday", + title: "Hello", + }); + }); + + it("never touches the database after a validation failure", async () => { + const calls = await rejects({ category: 1, title: "no" }); + + expect(calls).toHaveLength(0); + }); + + it("accepts a valid ISO date and stores it as a Date", async () => { + const { calls, run } = create({ + category: 1, + publishedAt: "2026-08-02T10:00:00.000Z", + title: "Hello", + }); + + await run; + + const values = opsOf(calls, "values")[0] as { publishedAt: Date }; + expect(values.publishedAt).toBeInstanceOf(Date); + }); + }); + + describe("update validation", () => { + const rejects = async (values: Record) => { + const { c, calls } = createDbMock([[{ id: 7, title: "Hello" }]]); + + await expect( + articles + .service(c) + .update(7, values as unknown as ContentUpdateInput), + ).rejects.toBeInstanceOf(ZodError); + + return calls; + }; + + it("rejects an empty patch", async () => { + await rejects({}); + }); + + it("rejects an unknown field", async () => { + await rejects({ smuggled: true }); + }); + + it("rejects an invalid value", async () => { + await rejects({ status: "sideways" }); + }); + + it("validates before it reads the row", async () => { + const calls = await rejects({ title: "no" }); + + expect(calls).toHaveLength(0); + }); + + it("does not re-apply create defaults", async () => { + const { c, calls } = createDbMock([ + [{ id: 7, status: "published", title: "Hello", views: 12 }], + [{ id: 7, title: "Changed" }], + ]); + + await articles.service(c).update(7, { title: "Changed" }); + + expect(opsOf(calls, "set")[0]).toEqual({ title: "Changed" }); + }); + }); + + describe("findById", () => { + it("returns the row when it exists", async () => { + const { c } = createDbMock([[{ id: 7, title: "Hello" }]]); + + await expect(articles.service(c).findById(7)).resolves.toEqual({ + id: 7, + title: "Hello", + }); + }); + + it("returns null rather than throwing when it does not", async () => { + const { c } = createDbMock([[]]); + + await expect(articles.service(c).findById(7)).resolves.toBeNull(); + }); + }); + + describe("update", () => { + it("returns null for a row that does not exist", async () => { + const { c, calls } = createDbMock([[]]); + + await expect( + articles.service(c).update(7, { title: "Changed" }), + ).resolves.toBeNull(); + expect(opsOf(calls, "update")).toHaveLength(0); + }); + + it("reports only the fields that actually changed", async () => { + const { c, calls } = createDbMock([ + [{ id: 7, status: "draft", title: "Hello" }], + [{ id: 7, status: "draft", title: "Changed" }], + ]); + + const result = await articles + .service(c) + .update(7, { status: "draft", title: "Changed" }); + + expect(result?.changedFields).toEqual(["title"]); + expect(opsOf(calls, "set")[0]).toEqual({ title: "Changed" }); + }); + + it("skips the write entirely when nothing moved", async () => { + const { c, calls } = createDbMock([[{ id: 7, title: "Hello" }]]); + + const result = await articles.service(c).update(7, { title: "Hello" }); + + expect(result?.changedFields).toEqual([]); + expect(opsOf(calls, "update")).toHaveLength(0); + }); + }); + + describe("delete", () => { + it("returns the deleted row", async () => { + const { c } = createDbMock([[{ id: 7, title: "Hello" }]]); + + await expect(articles.service(c).delete(7)).resolves.toEqual({ + id: 7, + title: "Hello", + }); + }); + + it("returns null when nothing was deleted", async () => { + const { c } = createDbMock([[]]); + + await expect(articles.service(c).delete(7)).resolves.toBeNull(); + }); + }); + + describe("findMany", () => { + const page = (rows: unknown[]) => [[{ count: rows.length }], rows]; + + it("joins once per reference field instead of querying per row", async () => { + const { c, calls } = createDbMock( + page([ + { id: 1, label__author: "Ada", label__category: "News" }, + { id: 2, label__author: null, label__category: "News" }, + ]), + ); + + await articles.service(c).findMany(); + + // `author` and `category` - one join each, and no extra round trips. + expect(opsOf(calls, "leftJoin")).toHaveLength(2); + expect(opsOf(calls, "select")).toHaveLength(2); // count + page + }); + + it("splits the joined labels out of the row", async () => { + const { c } = createDbMock( + page([{ id: 1, label__author: "Ada", label__category: "News" }]), + ); + + const { edges } = await articles.service(c).findMany(); + + expect(edges[0]).toEqual({ + id: 1, + labels: { author: "Ada", category: "News" }, + }); + }); + + it("reports a missing label as null", async () => { + const { c } = createDbMock( + page([{ id: 1, label__author: null, label__category: "News" }]), + ); + + const { edges } = await articles.service(c).findMany(); + + expect(edges[0].labels.author).toBeNull(); + }); + + it("rejects an order column outside the allowlist", async () => { + const { c } = createDbMock(page([])); + + await expect( + articles.service(c).findMany({ orderBy: { column: "views" } }), + ).rejects.toThrow(ContentEngineError); + }); + + it("rejects an unknown filter", async () => { + const { c } = createDbMock(page([])); + + await expect( + articles.service(c).findMany({ + // @ts-expect-error - the typed filter map has no `nope`; the runtime + // allowlist is what catches it when the keys come off a query string. + filters: { nope: 1 }, + }), + ).rejects.toThrow(ContentEngineError); + }); + + it.each([ + ["textarea", { excerpt: "prose" }], + ["dateTime", { publishedAt: "2026-08-03T10:00:00.000Z" }], + ])("rejects a %s filter forced past the type check", async (_kind, raw) => { + const { c, calls } = createDbMock(page([])); + + await expect( + articles + .service(c) + .findMany({ filters: raw as ContentFilterInput }), + ).rejects.toThrow(/cannot be used as a generated equality filter/); + expect(calls).toHaveLength(0); + }); + + it("filters a nullable field by null", async () => { + const { c } = createDbMock(page([])); + + await expect( + articles.service(c).findMany({ filters: { author: null } }), + ).resolves.toMatchObject({ edges: [] }); + }); + }); + + describe("options", () => { + it("returns picker options for a reference field", async () => { + const { c } = createDbMock([[{ label: "News", value: 3 }]]); + + await expect(articles.service(c).options("category")).resolves.toEqual([ + { label: "News", value: 3 }, + ]); + }); + + it("falls back to the identifier when the label is null", async () => { + const { c } = createDbMock([[{ label: null, value: 3 }]]); + + await expect(articles.service(c).options("category")).resolves.toEqual([ + { label: "3", value: 3 }, + ]); + }); + + it("rejects a field that is not a relation or user", async () => { + const { c } = createDbMock([[]]); + + await expect( + // @ts-expect-error - `title` has no picker; the runtime guard backs the + // type up for the route, which reads the field name out of the URL. + articles.service(c).options("title"), + ).rejects.toThrow(/not a relation or user field/); + }); + }); + + describe("transactions", () => { + it("uses the supplied transaction handle", async () => { + const { c } = createDbMock([]); + const outer = createDbMock([[{ id: 1 }]]); + const tx = outer.c.get("db"); + + await articles.service(c).create({ category: 1, title: "Hello" }, { tx }); + + expect(opsOf(outer.calls, "insert")).toHaveLength(1); + }); + }); +}); diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts new file mode 100644 index 000000000..3b368ec84 --- /dev/null +++ b/packages/vitnode/src/content/server/service.ts @@ -0,0 +1,426 @@ +import type { ColumnBaseConfig, SQL } from "drizzle-orm"; +import type { + PgColumn, + PgTable, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq } from "drizzle-orm"; +import { alias, getTableConfig } from "drizzle-orm/pg-core"; + +import type { ContentSchemas } from "../schemas"; +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentFieldName, + ContentFilterInput, + ContentOrderableFieldName, + ContentReferenceFieldName, + ContentSelect, + ContentUpdateInput, +} from "../types"; + +import { withPagination } from "../../api/lib/with-pagination"; +import { CONTENT_DEFAULT_PAGE_SIZE, CONTENT_OPTIONS_LIMIT } from "../const"; +import { ContentEngineError } from "../errors"; +import { orderableColumns } from "../registry"; +import { + buildFilterCondition, + buildOrderColumn, + buildSearchCondition, + diffChangedFields, + toColumnValues, +} from "./query"; + +/** Display labels for `user` and `relation` values, keyed by field name. */ +export type ContentLabels = Record; + +export type ContentListRow = ContentSelect & { + labels: ContentLabels; +}; + +export interface ContentPageInfo { + count: number; + endCursor: null | number; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: null | number; + totalCount: number; +} + +export interface ContentFindManyArgs { + /** Equality filters, keyed by filterable field name. */ + filters?: ContentFilterInput; + orderBy?: { + column?: ContentOrderableFieldName; + order?: "asc" | "desc"; + }; + /** Raw pagination query (`cursor`, `first`, `last`, `search`). */ + query?: { cursor?: string; first?: string; last?: string; search?: string }; + where?: SQL; +} + +/** The Drizzle client, or a transaction handle standing in for it. */ +export type ContentDatabase = Context["var"]["db"]; + +export interface ContentServiceOptions { + /** Run inside an existing transaction. */ + tx?: ContentDatabase; +} + +export interface ContentUpdateResult { + changedFields: ContentFieldName[]; + row: ContentSelect; +} + +export interface ContentService { + /** Throws a `ZodError` if `values` does not satisfy `schemas.create`. */ + create: ( + values: ContentCreateInput, + options?: ContentServiceOptions, + ) => Promise>; + delete: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; + findById: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; + findMany: (args?: ContentFindManyArgs) => Promise<{ + edges: ContentListRow[]; + pageInfo: ContentPageInfo; + }>; + /** Options for a `user` or `relation` picker, filtered by a search term. */ + options: ( + field: ContentReferenceFieldName, + search?: string, + ) => Promise<{ label: string; value: number }[]>; + /** Throws a `ZodError` if `values` does not satisfy `schemas.update`. */ + update: ( + id: number, + values: ContentUpdateInput, + options?: ContentServiceOptions, + ) => Promise | null>; +} + +interface ReferenceTarget { + /** Aliased, so two relations pointing at the same table can both be joined. */ + aliased: PgTable; + idColumn: PgColumn; + labelColumn: PgColumn; + owner: PgColumn; +} + +const LABEL_PREFIX = "label__"; + +/** + * Turns a joined label column value into display text. Only the shapes a title + * column can actually hold are handled - anything else becomes `null` rather + * than "[object Object]". + */ +const toLabel = (value: unknown): null | string => { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) return value.toISOString(); + + return null; +}; + +/** + * Works out which table and column supply the display label for each + * `user`/`relation` field. + * + * The target comes from the foreign keys Drizzle already resolved on the table, + * so the engine needs no separate table registry - and because the FK thunk is + * evaluated here, circular content type references stay safe. + */ +const resolveReferenceTargets = ( + definition: AnyContentTypeDefinition, + table: PgTableWithColumns, + columns: Record, +): Record => { + const fields = definition.fields; + const byOwnerColumn = new Map( + getTableConfig(table) + .foreignKeys.map(foreignKey => foreignKey.reference()) + .map(reference => [reference.columns[0]?.name, reference]), + ); + + const targets: Record = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "relation" && fieldValue.kind !== "user") continue; + + const reference = byOwnerColumn.get(name); + if (!reference) { + throw new ContentEngineError( + `Field "${name}" has no foreign key on "${definition.tableName}".`, + { contentTypeId: definition.id }, + ); + } + + // `user` labels come from the core users table; a relation uses the target + // content type's own `admin.titleField`. + const labelName = + fieldValue.kind === "user" + ? "name" + : (fieldValue.target().admin.titleField ?? "id"); + + const aliased = alias(reference.foreignTable, `${LABEL_PREFIX}${name}`); + const aliasedColumns = aliased as unknown as Record; + + targets[name] = { + aliased, + idColumn: aliasedColumns.id, + labelColumn: aliasedColumns[labelName] ?? aliasedColumns.id, + owner: columns[name], + }; + } + + return targets; +}; + +/** + * A typed repository bound to one request's database handle. + * + * Deliberately thin: it owns validation, column allowlisting, pagination and + * label joins, and leaves everything else to Drizzle. `model.table` stays + * public so advanced plugin code can drop down to the query builder at any + * point. + */ +export const createContentService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + columns, + definition, + schemas, + table, +}: { + c: Context; + columns: Record; + definition: TDefinition; + schemas: ContentSchemas; + table: PgTableWithColumns; +}): ContentService => { + const fields = definition.fields; + const contentTypeId = definition.id; + // `buildSystemColumns` always makes `id` a `serial`, which is what + // `withPagination` needs to type its cursor. + const primaryCursor = columns.id as PgColumn< + ColumnBaseConfig<"number", string> + >; + const orderable = orderableColumns(definition); + // `Object.keys` erases the key union that `ContentFieldName` recovers. The + // object is the very field map that type is derived from, so this restates + // what TypeScript already knows rather than asserting anything new. + const fieldNames = Object.keys(fields) as ContentFieldName[]; + const ownColumnNames = ["id", "createdAt", "updatedAt", ...fieldNames]; + const references = resolveReferenceTargets(definition, table, columns); + const searchColumns = definition.admin.list.searchableFields.map( + name => columns[name], + ); + + const db = (options?: ContentServiceOptions): ContentDatabase => + options?.tx ?? c.get("db"); + + const ownSelection = (): Record => + Object.fromEntries(ownColumnNames.map(name => [name, columns[name]])); + + const toRow = (row: Record): ContentSelect => + row as ContentSelect; + + const splitLabels = ( + row: Record, + ): ContentListRow => { + const labels: ContentLabels = {}; + const values: Record = {}; + + for (const [key, value] of Object.entries(row)) { + if (key.startsWith(LABEL_PREFIX)) { + labels[key.slice(LABEL_PREFIX.length)] = toLabel(value); + continue; + } + values[key] = value; + } + + return { ...values, labels } as ContentListRow; + }; + + const readOne = async ( + id: number, + database: ContentDatabase, + ): Promise> => { + const [row] = await database + .select(ownSelection()) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1); + + return row ?? null; + }; + + return { + create: async (values, options) => { + // Generated routes validate too, but a plugin can call the service + // directly - and then this is the only thing standing between an + // untrusted object and Drizzle. Only the parsed result is written. + const parsed = schemas.create.parse(values) as Record; + + const [row] = await db(options) + .insert(table) + .values(toColumnValues(fields, parsed)) + .returning(ownSelection()); + + return toRow(row); + }, + + delete: async (id, options) => { + const [row] = await db(options) + .delete(table) + .where(eq(primaryCursor, id)) + .returning(ownSelection()); + + return row ? toRow(row) : null; + }, + + findById: async (id, options) => { + const row = await readOne(id, db(options)); + + return row ? toRow(row) : null; + }, + + findMany: async ({ filters = {}, orderBy, query = {}, where } = {}) => { + const conditions = [ + where, + buildFilterCondition({ + columns, + contentTypeId, + fields, + // Typed per field for callers; the allowlist check inside stays as + // defence in depth for anything that arrives from a query string. + filters: filters, + }), + buildSearchCondition(searchColumns, query.search), + ].filter((item): item is SQL => item !== undefined); + + const combined = + conditions.length > 1 ? and(...conditions) : conditions[0]; + + const data = await withPagination({ + c, + // The search term is folded into `where` above so it can be escaped; + // handing it to `withPagination` would build an unescaped `ilike`. + params: { query: { ...query, search: undefined } }, + primaryCursor, + orderBy: { + column: buildOrderColumn({ + columns, + contentTypeId, + fallback: definition.admin.list.defaultOrderBy, + orderBy: orderBy?.column, + orderable, + }), + order: orderBy?.order ?? definition.admin.list.defaultOrder, + }, + table, + where: combined, + query: async ({ limit, orderBy: order, where: rowWhere }) => { + // One LEFT JOIN per reference field resolves every label in the same + // round trip - there is no per-row lookup anywhere. + const selection: Record = { + ...ownSelection(), + ...Object.fromEntries( + Object.entries(references).map(([name, target]) => [ + `${LABEL_PREFIX}${name}`, + target.labelColumn, + ]), + ), + }; + + let builder = c.get("db").select(selection).from(table).$dynamic(); + + for (const target of Object.values(references)) { + builder = builder.leftJoin( + target.aliased, + eq(target.owner, target.idColumn), + ); + } + + return await builder + .where(rowWhere) + .orderBy(order) + .limit( + typeof limit === "number" ? limit : CONTENT_DEFAULT_PAGE_SIZE, + ); + }, + }); + + return { + edges: data.edges.map(splitLabels), + pageInfo: data.pageInfo, + }; + }, + + options: async (fieldName, search) => { + const target = references[fieldName]; + if (!target) { + throw new ContentEngineError( + `Field "${fieldName}" is not a relation or user field.`, + { contentTypeId }, + ); + } + + const rows = await c + .get("db") + .select({ label: target.labelColumn, value: target.idColumn }) + .from(target.aliased) + .where(buildSearchCondition([target.labelColumn], search)) + .orderBy(target.labelColumn) + .limit(CONTENT_OPTIONS_LIMIT); + + return rows.map(row => { + const value = Number(row.value); + + return { label: toLabel(row.label) ?? String(value), value }; + }); + }, + + update: async (id, values, options) => { + // Parsed before the row is even read, so an invalid payload never costs a + // query - and never reaches Drizzle. + const patch = schemas.update.parse(values) as Record; + + const database = db(options); + const current = await readOne(id, database); + if (!current) return null; + + const changedFields = diffChangedFields(fieldNames, current, patch); + + // Nothing actually moved - skip the write so `updatedAt` and the + // `content.*.updated` event both stay honest. + if (changedFields.length === 0) { + return { changedFields, row: toRow(current) }; + } + + const [row] = await database + .update(table) + .set( + toColumnValues( + fields, + Object.fromEntries(changedFields.map(key => [key, patch[key]])), + ), + ) + .where(eq(primaryCursor, id)) + .returning(ownSelection()); + + return { changedFields, row: toRow(row) }; + }, + }; +}; diff --git a/packages/vitnode/src/content/server/table.test-d.ts b/packages/vitnode/src/content/server/table.test-d.ts new file mode 100644 index 000000000..10709e708 --- /dev/null +++ b/packages/vitnode/src/content/server/table.test-d.ts @@ -0,0 +1,96 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, +} from "@/tests/content-fixtures"; + +import type { ContentSelect } from "../types"; + +import { createContentTable } from "./table"; + +const categories = createContentTable(testCategoryContentType); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- read as a type +const articles = createContentTable(testArticleContentType, { + references: { category: () => categories.id }, +}); + +type Select = (typeof articles)["$inferSelect"]; +type Insert = (typeof articles)["$inferInsert"]; + +describe("createContentTable inference", () => { + describe("$inferSelect", () => { + it("matches the descriptor-derived row type", () => { + expectTypeOf

__idx`, or `_key` when unique. */ + name?: string; + on: [ + ContentSystemField | (keyof TFields & string), + ...(ContentSystemField | (keyof TFields & string))[], + ]; + unique?: boolean; +} + +/** + * Stored shape. Non-generic for the same reason as + * {@link ResolvedContentAdminConfig} - `keyof TFields` would make + * `ContentTypeDefinition` invariant. + */ +export interface ContentIndexConfig { + name?: string; + on: string[]; + unique?: boolean; +} + +/** + * An index after `defineContentType` has expanded the automatic ones, resolved + * every name and dropped the duplicates. This is what the table generator + * materialises, one to one. + */ +export interface ResolvedContentIndex { + name: string; + on: string[]; + unique: boolean; +} + +// --------------------------------------------------------------------------- +// Definition +// --------------------------------------------------------------------------- + +export interface ContentTypeDefinition< + TId extends string = string, + TFields = ContentFieldMap, +> { + admin: ResolvedContentAdminConfig; + fields: TFields; + id: TId; + /** Declared indexes plus the automatic ones, deduplicated and named. */ + indexes: ResolvedContentIndex[]; + /** Derived from `admin.permissionModule` or `admin.label.plural`. */ + permissionModule: string; + /** Zod schemas generated from `fields`. */ + schemas: ContentSchemas>; + tableName: string; +} + +/** Use in constraints where the concrete field map does not matter. */ +export type AnyContentTypeDefinition = ContentTypeDefinition; + +export type ContentFieldsOf = TDefinition extends { + fields: infer TFields; +} + ? TFields + : never; + +export type ContentSelect = Prettify< + { + [K in keyof ContentFieldsOf]: ContentFieldValue< + ContentFieldsOf[K] + >; + } & { createdAt: Date; id: number; updatedAt: Date } +>; + +export type ContentCreateInput = Prettify< + { + [ + K in Exclude< + keyof ContentFieldsOf, + RequiredFieldKeys> + > + ]?: ContentFieldInput[K]>; + } & { + [K in RequiredFieldKeys>]: ContentFieldInput< + ContentFieldsOf[K] + >; + } +>; + +export type ContentUpdateInput = Prettify< + Partial> +>; + +export type ContentFieldName = keyof ContentFieldsOf & + string; + +type FieldNamesOfKind = string & + { + [ + K in keyof ContentFieldsOf + ]: ContentFieldsOf[K] extends { + kind: TKind; + } + ? K + : never; + }[keyof ContentFieldsOf]; + +/** + * Kinds the generated filter schema understands, derived from the one runtime + * list in `const.ts` so the compile-time contract and the runtime guard are the + * same list. `service.test-d.ts` asserts it stays a subset of + * {@link ContentFieldKind}. + */ +export type FilterableContentFieldKind = + (typeof CONTENT_FILTERABLE_FIELD_KINDS)[number]; + +export type FilterableContentFieldName = FieldNamesOfKind< + TDefinition, + FilterableContentFieldKind +>; + +/** Equality filters accepted by `service.findMany`, one key per filterable field. */ +export type ContentFilterInput = Partial<{ + [K in FilterableContentFieldName]: ContentFieldInput< + ContentFieldsOf[K] + >; +}>; + +/** + * Columns `service.findMany` may order by. + * + * A compile-time approximation, and deliberately so: `admin.list.orderableFields` + * is stored on the *resolved* (non-generic) admin config, so the configured + * array is not recoverable as a type. Every field name is accepted here, and + * the narrower runtime allowlist rejects the ones that were not configured. + */ +export type ContentOrderableFieldName = + ContentFieldName | ContentSystemField; + +/** Fields with a picker - the only ones `service.options` can enumerate. */ +export type ContentReferenceFieldName = FieldNamesOfKind< + TDefinition, + "relation" | "user" +>; diff --git a/packages/vitnode/src/lib/fetcher/core.ts b/packages/vitnode/src/lib/fetcher/core.ts index 6423af4ad..8187930f3 100644 --- a/packages/vitnode/src/lib/fetcher/core.ts +++ b/packages/vitnode/src/lib/fetcher/core.ts @@ -12,8 +12,7 @@ import type { InferResponseType, } from "./types"; -import { CONFIG } from "../config"; -import { buildSearchParams } from "./helpers"; +import { rawApiFetch } from "./raw"; interface CoreFetcherOptions< M extends string, @@ -81,73 +80,27 @@ export async function coreFetcher< ): Promise< InferResponseType > { - let currentPath: string = path; - - // Replace path parameters - if (args && "params" in args && args.params) { - for (const [key, value] of Object.entries( - args.params as Record, - )) { - currentPath = currentPath.replaceAll(`{${key}}`, String(value)); - } - } - - // Ensure path starts with a slash - const formattedPath = currentPath.startsWith("/") - ? currentPath - : `/${currentPath}`; - - // Construct the base URL - const url = new URL( - `/api/${pluginId}${prefixPath}/${module}${formattedPath === "/" ? "" : formattedPath}`, - CONFIG.api.origin, - ); - - // Add query parameters if they exist - if (args && "query" in args && args.query) { - const queryParams = args.query as Record; - const searchParams = buildSearchParams({ - ...(args.query as Record), - ...(withPagination && { - first: queryParams.last ? undefined : (queryParams.first ?? "10"), - search: queryParams.search ?? "", - }), - }); - url.search = searchParams.toString(); - } - - // Build headers. For multipart uploads let the browser set the Content-Type - // (with its boundary) - forcing application/json would corrupt the body. - const headers = new Headers({ - ...(formData ? {} : { "Content-Type": "application/json" }), - ...additionalHeaders, - }); - - const response = await fetch(url, { - method: method.toUpperCase(), - headers, - body: - formData ?? - (args && "body" in args ? JSON.stringify(args.body) : undefined), - ...options, + const response = await rawApiFetch({ + additionalHeaders, + body: args && "body" in args ? args.body : undefined, + formData, + method, + module, + options, + params: + args && "params" in args + ? (args.params as Record) + : undefined, + path, + pluginId, + prefixPath, + query: + args && "query" in args + ? (args.query as Record) + : undefined, + withPagination, }); - if (response.status === 500) { - const errorText = await response.text(); - throw new Error( - `${response.status} - ${url.toString()}\n${response.statusText ?? errorText}`, - ); - } - - if (response.status >= 400) { - // Clone so the response body stays readable for the caller - const errorText = await response.clone().text(); - // eslint-disable-next-line no-console - console.error( - `\x1b[34m[VitNode - API]\x1b[0m \x1b[31m${response.status}\x1b[0m - \x1b[33m${url.toString()}\x1b[0m\n\x1b[36mError: ${errorText}\x1b[0m`, - ); - } - return response as InferResponseType< M, Routes, diff --git a/packages/vitnode/src/lib/fetcher/raw.ts b/packages/vitnode/src/lib/fetcher/raw.ts new file mode 100644 index 000000000..c3610564d --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/raw.ts @@ -0,0 +1,120 @@ +import { CONFIG } from "../config"; +import { buildSearchParams } from "./helpers"; + +export interface RawApiFetchArgs { + additionalHeaders?: HeadersInit; + body?: unknown; + /** + * Raw `multipart/form-data` body. When set the JSON `Content-Type` is + * omitted so the runtime can add the multipart boundary. + */ + formData?: FormData; + method: string; + /** Module path under the plugin, e.g. `admin/content/articles`. */ + module: string; + options?: Omit; + params?: Record; + /** Route path within the module, e.g. `/` or `/{id}`. */ + path: string; + pluginId: string; + prefixPath?: string; + query?: Record; + withPagination?: boolean; +} + +export const buildApiUrl = ({ + module, + params, + path, + pluginId, + prefixPath = "", + query, + withPagination = false, +}: Pick< + RawApiFetchArgs, + | "module" + | "params" + | "path" + | "pluginId" + | "prefixPath" + | "query" + | "withPagination" +>): URL => { + let currentPath = path; + + if (params) { + for (const [key, value] of Object.entries(params)) { + currentPath = currentPath.replaceAll(`{${key}}`, String(value)); + } + } + + const formattedPath = currentPath.startsWith("/") + ? currentPath + : `/${currentPath}`; + + const url = new URL( + `/api/${pluginId}${prefixPath}/${module}${formattedPath === "/" ? "" : formattedPath}`, + CONFIG.api.origin, + ); + + if (query) { + url.search = buildSearchParams({ + ...query, + ...(withPagination && { + first: query.last ? undefined : (query.first ?? "10"), + search: query.search ?? "", + }), + }).toString(); + } + + return url; +}; + +/** + * The untyped core of the fetcher: URL building, headers, and the error + * logging every VitNode API call shares. + * + * `coreFetcher` wraps this with the route-literal type inference, and the + * Content Engine wraps it with content-type schemas - both get the same + * request behaviour without a second implementation. + */ +export const rawApiFetch = async ({ + additionalHeaders = {}, + body, + formData, + method, + options, + ...urlArgs +}: RawApiFetchArgs): Promise => { + const url = buildApiUrl(urlArgs); + + const headers = new Headers({ + ...(formData ? {} : { "Content-Type": "application/json" }), + ...additionalHeaders, + }); + + const response = await fetch(url, { + method: method.toUpperCase(), + headers, + body: formData ?? (body === undefined ? undefined : JSON.stringify(body)), + ...options, + }); + + if (response.status === 500) { + const errorText = await response.text(); + throw new Error( + `${response.status} - ${url.toString()}\n${response.statusText ?? errorText}`, + ); + } + + if (response.status >= 400) { + // Clone so the response body stays readable for the caller + const errorText = await response.clone().text(); + // eslint-disable-next-line no-console + console.error( + `\x1b[34m[VitNode - API]\x1b[0m \x1b[31m${response.status}\x1b[0m - \x1b[33m${url.toString()}\x1b[0m\n\x1b[36mError: ${errorText}\x1b[0m`, + ); + } + + return response; +}; diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 19af390b3..445eaf677 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -1,4 +1,10 @@ import type { PermissionsStaffArgs } from "../api/lib/permission-staff"; +import type { ItemAutoFormComponentProps } from "../components/form/auto-form"; +import type { + AnyContentTypeDefinition, + ContentSelect, + ContentSystemField, +} from "../content/types"; import type { ItemNavAdmin } from "../views/admin/layouts/sidebar/nav/item"; import type { LocaleMessagesMap } from "./i18n/types"; @@ -35,6 +41,70 @@ export interface AdminDashboardWidget { settingsComponent?: React.ComponentType; } +export interface ContentCellProps< + TDefinition extends AnyContentTypeDefinition = AnyContentTypeDefinition, +> { + row: ContentSelect; +} + +/** + * A content type registration once its definition generic has been erased, so + * one plugin can list content types with different field maps in one array. + */ +export interface ContentTypeFrontendRegistration { + columns?: Record< + string, + { cell: (props: ContentCellProps) => React.ReactNode } + >; + definition: AnyContentTypeDefinition; + fields?: Record< + string, + { component: (props: ItemAutoFormComponentProps) => React.ReactNode } + >; + icon?: React.ReactNode; +} + +interface TypedContentTypeRegistration< + TDefinition extends AnyContentTypeDefinition, +> { + /** Replace the generated DataTable cell for a column. */ + columns?: Partial< + Record< + ContentSystemField | (keyof TDefinition["fields"] & string), + { cell: (props: ContentCellProps) => React.ReactNode } + > + >; + definition: TDefinition; + /** Replace the generated AutoForm component for a field. */ + fields?: Partial< + Record< + keyof TDefinition["fields"] & string, + { component: (props: ItemAutoFormComponentProps) => React.ReactNode } + > + >; + /** Sidebar icon. Defaults to a generic document icon. */ + icon?: React.ReactNode; +} + +/** + * Registers a content type with the AdminCP. + * + * The `definition` is the *same object* the API plugin registers - it is + * client-safe by construction (zod and plain data, no Drizzle), so the two + * sides cannot drift. Component overrides live here rather than on the + * definition, because the definition is also imported by `src/database/*.ts`, + * which Drizzle Kit executes. + * + * The wrapper exists to type-check `fields` and `columns` against the + * definition's own field names before erasing the generic - the same shape as + * `buildEventListener`. + */ +export function contentTypeAdmin( + registration: TypedContentTypeRegistration, +): ContentTypeFrontendRegistration { + return registration as ContentTypeFrontendRegistration; +} + export interface BuildPluginReturn

{ admin?: { dashboard?: { @@ -44,6 +114,7 @@ export interface BuildPluginReturn

{ items?: Omit[]; })[]; }; + contentTypes?: ContentTypeFrontendRegistration[]; messages?: LocaleMessagesMap; pluginId: P; } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index b99b1795e..8946ada3d 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -387,6 +387,60 @@ } } } + }, + "content": { + "empty": { + "title": "Nothing here yet", + "desc": "Create your first entry to get started." + }, + "table": { + "id": "ID", + "created_at": "Created", + "updated_at": "Updated", + "actions": "Actions", + "empty_value": "—" + }, + "create": { + "title": "Create {name}", + "desc": "Add a new {name}.", + "submit": "Create", + "success": "{name} has been created." + }, + "edit": { + "title": "Edit {name}", + "desc": "Change the details of this {name}.", + "submit": "Save changes", + "success": "{name} has been updated." + }, + "delete": { + "title": "Delete {name}", + "desc": "Are you sure you want to delete ? This action cannot be undone.", + "confirm": "Yes, delete it", + "success": "{name} has been deleted." + }, + "form": { + "relation": { + "placeholder": "Select…", + "search_placeholder": "Search…", + "empty": "No matches." + }, + "boolean": { + "on": "Yes", + "off": "No" + } + }, + "permissions": { + "can_view": "View list", + "can_create": "Create", + "can_edit": "Edit", + "can_delete": "Delete" + }, + "errors": { + "not_found": "This record no longer exists. Refresh the list and try again.", + "validation": "Some of these values are not valid. Check the form and try again.", + "conflict": "This record is still referenced by other content.", + "forbidden": "You do not have permission to do this." + } } }, "admin": { diff --git a/packages/vitnode/src/routes/admin/content/[...slug]/page.tsx b/packages/vitnode/src/routes/admin/content/[...slug]/page.tsx new file mode 100644 index 000000000..8ea87adff --- /dev/null +++ b/packages/vitnode/src/routes/admin/content/[...slug]/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next/dist/types"; + +import { + ContentAdminView, + type ContentAdminViewProps, + getContentLabels, + resolveContentType, +} from "@/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/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx new file mode 100644 index 000000000..37262ff44 --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx @@ -0,0 +1,22 @@ +import { BreadcrumbAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-admin"; +import { + getContentLabels, + resolveContentType, +} from "@/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/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts new file mode 100644 index 000000000..2a993cedc --- /dev/null +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -0,0 +1,52 @@ +import { defineContentType } from "@/content/define"; +import { field } from "@/content/fields"; + +/** + * Shared content types for Content Engine tests. Kept under `src/tests` so + * Vitest does not pick them up as a suite, and deliberately close to the + * `plugins/example` reference definitions. + */ +export const testCategoryContentType = defineContentType({ + id: "test.category", + tableName: "test_categories", + fields: { + title: field.text({ required: true, minLength: 1, maxLength: 100 }), + }, + admin: { + label: { plural: "Test Categories", singular: "Test Category" }, + }, +}); + +export const testArticleContentType = defineContentType({ + id: "test.article", + tableName: "test_articles", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + views: field.number({ integer: true, min: 0, defaultValue: 0 }), + featured: field.boolean({ defaultValue: false }), + status: field.enum({ + values: ["draft", "published", "archived"], + defaultValue: "draft", + }), + publishedAt: field.dateTime({ nullable: true }), + author: field.user({ nullable: true, onDelete: "set null" }), + category: field.relation({ + required: true, + onDelete: "restrict", + target: () => testCategoryContentType, + }), + }, + indexes: [{ on: ["status", "createdAt"] }], + admin: { + label: { plural: "Test Articles", singular: "Test Article" }, + titleField: "title", + list: { + columns: ["title", "status", "author", "updatedAt"], + searchableFields: ["title", "excerpt"], + orderableFields: ["title", "status"], + defaultOrderBy: "updatedAt", + defaultOrder: "desc", + }, + }, +}); diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx index b52a782e2..a439df856 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx @@ -1,4 +1,5 @@ import { + FileTextIcon, LayoutDashboardIcon, ServerIcon, ShieldUserIcon, @@ -15,6 +16,9 @@ import type { VitNodeConfig } from "@/vitnode.config"; import { hasStaffPermission } from "@/api/lib/staff-permission"; import { CONFIG_PLUGIN } from "@/config"; +import { contentI18nKeys } from "@/content/admin/labels"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { contentAdminHref } from "@/content/registry"; import { getSessionAdminApi } from "@/lib/api/get-session-admin-api"; import { getVitNodeConfig } from "@/vitnode.config"; @@ -235,37 +239,65 @@ export const getAdminNav = async ({ ], }; - const pluginNav: NavGroupConfig[] = vitNodeConfig.plugins - .filter(plugin => plugin.admin?.nav) - .map(plugin => ({ - id: plugin.pluginId, + // Content types get a nav item for free. `admin.navigation.enabled: false` + // opts out, and the usual permission filter hides anything the admin cannot + // view. + const contentNavItems = ( + plugin: (typeof vitNodeConfig.plugins)[number], + ): NavItemConfig[] => + (plugin.contentTypes ?? []) + .filter(({ definition }) => definition.admin.navigation.enabled) + .map(({ definition, icon }) => { + const titleKey = contentI18nKeys(definition, plugin.pluginId).title; + + return { + href: contentAdminHref(definition.id), + icon: icon ?? , + permission: { + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: plugin.pluginId, + }, + title: t.has(titleKey as Parameters[0]) + ? t(titleKey as Parameters[0]) + : definition.admin.label.plural, + }; + }); + + const declaredNavItems = ( + plugin: (typeof vitNodeConfig.plugins)[number], + ): NavItemConfig[] => + (plugin.admin?.nav ?? []).map(item => ({ + href: item.href, + icon: item.icon, + isOpenInNewTab: item.isOpenInNewTab, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - title: t(`${plugin.pluginId}.title`), - items: (plugin.admin?.nav ?? []).map(item => ({ - href: item.href, - icon: item.icon, - isOpenInNewTab: item.isOpenInNewTab, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - title: t(`${plugin.pluginId}.admin.nav.${item.id}`), - permission: item.permission - ? { plugin: plugin.pluginId, ...item.permission } - : undefined, - items: - item.items?.map(subItem => ({ - href: subItem.href, - isOpenInNewTab: subItem.isOpenInNewTab, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - title: t(`${plugin.pluginId}.admin.nav.${item.id}.${subItem.id}`), - permission: subItem.permission - ? { plugin: plugin.pluginId, ...subItem.permission } - : undefined, - })) ?? [], - })), + title: t(`${plugin.pluginId}.admin.nav.${item.id}`), + permission: item.permission + ? { plugin: plugin.pluginId, ...item.permission } + : undefined, + items: + item.items?.map(subItem => ({ + href: subItem.href, + isOpenInNewTab: subItem.isOpenInNewTab, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + title: t(`${plugin.pluginId}.admin.nav.${item.id}.${subItem.id}`), + permission: subItem.permission + ? { plugin: plugin.pluginId, ...subItem.permission } + : undefined, + })) ?? [], })); + const pluginNav: NavGroupConfig[] = vitNodeConfig.plugins.map(plugin => ({ + id: plugin.pluginId, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + title: t(`${plugin.pluginId}.title`), + items: [...contentNavItems(plugin), ...declaredNavItems(plugin)], + })); + return [core, ...pluginNav] .map(group => ({ id: group.id, diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx new file mode 100644 index 000000000..3a9aaf9e7 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -0,0 +1,138 @@ +// No "use client" here on purpose: this module is only reached from +// `create-action`/`edit-action`, which are already client entries. Declaring +// it again would make this a nested client entry, and `next/dynamic` cannot +// resolve one from inside a published package - the dialog spins forever. +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormSpec } from "@/content/admin/spec"; + +import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; +import { useDialog } from "@/components/ui/dialog"; +import { + buildFormSchemaFromSpec, + contentFormValuesToPayload, + contentTitleFromValues, +} from "@/content/admin/spec"; +import { usePathname, useRouter } from "@/lib/navigation"; + +import { ContentField } from "../lib/field-component"; +import { contentErrorKey } from "../lib/mutation-feedback"; +import { + createContentAction, + editContentAction, + loadContentOptionsAction, +} from "./mutation-api.server"; + +export interface ContentFormProps { + /** Existing values when editing; absent when creating. */ + data?: Record & { id: number }; + /** Per-field component overrides declared in `buildPlugin`. */ + fieldOverrides?: Record< + string, + (props: ItemAutoFormComponentProps) => React.ReactNode + >; + /** The content type's singular label, used in the success toast. */ + singular: string; + spec: ContentFormSpec; + /** Resolved title of the row, shown as the toast description. */ + title?: string; +} + +export const ContentForm = ({ + data, + fieldOverrides = {}, + singular, + spec, + title, +}: ContentFormProps) => { + const t = useTranslations("core.content"); + const tErrors = useTranslations("core.global.errors"); + const tContentErrors = useTranslations("core.content.errors"); + const { setOpen } = useDialog(); + const { push } = useRouter(); + const pathname = usePathname(); + + const formSchema = React.useMemo( + () => buildFormSchemaFromSpec(spec, data), + [spec, data], + ); + + const onSubmit: AutoFormOnSubmit = async values => { + // Relation and user fields hold the whole combobox option; the API wants + // the identifier. + const payload = contentFormValuesToPayload(spec, values); + + const mutation = data + ? await editContentAction(spec.contentTypeId, data.id, payload) + : await createContentAction(spec.contentTypeId, payload); + + if (mutation.error !== undefined) { + // A validation failure, a conflicting row and a server fault all need + // different words - and none of them may quote the database. + const errorKey = contentErrorKey(mutation.status); + + toast.error(tErrors("title"), { + description: errorKey + ? tContentErrors(errorKey) + : tErrors("internal_server_error"), + }); + + return; + } + + toast.success( + t(data ? "edit.success" : "create.success", { name: singular }), + { + // On create there is no row yet, so the toast names what was typed. + description: + title ?? + contentTitleFromValues(spec, values) ?? + t("create.desc", { name: singular }), + }, + ); + + // Close first, then navigate: a refresh fired while the dialog is still + // animating out leaves its overlay stranded over the page. + setOpen?.(false); + push(pathname); + }; + + return ( + ({ + id: fieldSpec.name, + + // MUST NOT be async: `AutoForm` calls this to get an element, and an + // async function hands it a fresh Promise every render - React 19 + // suspends on promise children, so the dialog spins forever. + // eslint-disable-next-line @typescript-eslint/promise-function-async -- see above + component: props => { + const override = fieldOverrides[fieldSpec.name]; + if (override) return override(props); + + return ( + + await loadContentOptionsAction( + spec.contentTypeId, + field, + search, + ) + } + spec={fieldSpec} + {...props} + /> + ); + }, + }))} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + children: t(data ? "edit.submit" : "create.submit"), + }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx new file mode 100644 index 000000000..bd4930e02 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/create-action.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; + +import type { ContentFormProps } from "./content-form"; + +// The form pulls in AutoForm and every field component, so it only loads once +// the dialog is actually opened. +const ContentForm = dynamic(async () => + import("./content-form").then(mod => ({ default: mod.ContentForm })), +); + +export const CreateContentAction = ({ + singular, + ...props +}: Omit) => { + const t = useTranslations("core.content.create"); + + return ( +

+ }> + + {t("title", { name: singular })} + + + + + {t("title", { name: singular })} + {t("desc", { name: singular })} + + + }> + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx new file mode 100644 index 000000000..8565d62da --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/delete-action.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Trash2Icon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +import { contentErrorKey } from "../lib/mutation-feedback"; +import { deleteContentAction } from "./mutation-api.server"; + +export const DeleteContentAction = ({ + contentTypeId, + id, + permissionModule, + pluginId, + singular, + title, +}: { + contentTypeId: string; + id: number; + permissionModule: string; + pluginId: string; + singular: string; + title: string; +}) => { + const t = useTranslations("core.content.delete"); + const tErrors = useTranslations("core.global.errors"); + const tContentErrors = useTranslations("core.content.errors"); + const canDelete = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.delete, + plugin: pluginId, + }); + + if (!canDelete) return null; + + const label = t("title", { name: singular }); + + return ( + + + ( + {title} + ), + })} + onSubmit={async ({ onClose }) => { + const mutation = await deleteContentAction(contentTypeId, id); + + if (mutation.error !== undefined) { + // A restricted delete (409) is a normal, explainable outcome; an + // unrecognised status is a server fault and reads as one. + const errorKey = contentErrorKey(mutation.status); + + toast.error(tErrors("title"), { + description: errorKey + ? tContentErrors(errorKey) + : tErrors("internal_server_error"), + }); + + return; + } + + toast.success(t("success", { name: singular }), { + description: title, + }); + onClose(); + }} + textSubmit={t("confirm")} + title={label} + > + + + + } + /> + + + {label} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx new file mode 100644 index 000000000..942590c2d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { PencilIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +import type { ContentFormProps } from "./content-form"; + +const ContentForm = dynamic(async () => + import("./content-form").then(mod => ({ default: mod.ContentForm })), +); + +export const EditContentAction = ({ + permissionModule, + pluginId, + singular, + ...props +}: ContentFormProps & { + permissionModule: string; + pluginId: string; +}) => { + const t = useTranslations("core.content.edit"); + const canEdit = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: pluginId, + }); + + if (!canEdit) return null; + + return ( + + + + + + + } + /> + } + /> + + + + {t("title", { name: singular })} + + {t("desc", { name: singular })} + + + + }> + + + + + + {t("title", { name: singular })} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts new file mode 100644 index 000000000..f9868cf6b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts @@ -0,0 +1,130 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { findFrontendContentType } from "@/content/admin/config"; +import { contentApiFetch } from "@/content/admin/fetch.server"; +import { CONTENT_OPTIONS_LIMIT } from "@/content/const"; + +/** + * The generic content screen ships from core, so its cached page path is the + * catch-all route copied into every web app. + */ +const CONTENT_PAGE_PATH = + "/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]"; + +interface MutationResult { + error?: string; + /** Lets the UI tell a restricted delete (409) from a generic failure. */ + status?: number; +} + +const resolve = (contentTypeId: string) => { + const entry = findFrontendContentType(contentTypeId); + if (!entry) { + throw new Error(`Unknown content type "${contentTypeId}".`); + } + + return entry; +}; + +export const createContentAction = async ( + contentTypeId: string, + values: Record, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: values, + definition, + method: "post", + pluginId, + }); + + if (result.status !== 201) { + return { error: result.error ?? "", status: result.status }; + } + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +export const editContentAction = async ( + contentTypeId: string, + id: number, + values: Record, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: values, + definition, + method: "put", + path: `/${id}`, + pluginId, + }); + + if (result.status !== 200) { + return { error: result.error ?? "", status: result.status }; + } + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +export const deleteContentAction = async ( + contentTypeId: string, + id: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "delete", + path: `/${id}`, + pluginId, + }); + + if (result.status !== 200) { + return { error: result.error ?? "", status: result.status }; + } + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +const zodOptions = z.object({ + items: z.array(z.object({ label: z.string(), value: z.number() })), +}); + +/** + * Backs the `relation` and `user` pickers. + * + * 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` + * instead of a separate permission on the target table. + */ +export const loadContentOptionsAction = async ( + contentTypeId: string, + field: string, + search: string, +): Promise<{ label: string; value: string }[]> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/options/${field}`, + pluginId, + query: { search }, + schema: zodOptions, + }); + + return (result.data?.items ?? []) + .slice(0, CONTENT_OPTIONS_LIMIT) + .map(item => ({ label: item.label, value: String(item.value) })); +}; diff --git a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx new file mode 100644 index 000000000..daa426c31 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx @@ -0,0 +1,140 @@ +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import React from "react"; + +import type { RegisteredFrontendContentType } from "@/content/admin/config"; + +import { I18nProvider } from "@/components/i18n-provider"; +import { DataTableSkeleton } from "@/components/table/data-table"; +import { HeaderContent } from "@/components/ui/header-content"; +import { findFrontendContentType } from "@/content/admin/config"; +import { contentI18nKeys, humanizeFieldName } from "@/content/admin/labels"; +import { + buildContentColumnSpec, + buildContentFormSpec, +} from "@/content/admin/spec"; +import { CONTENT_PERMISSIONS } from "@/content/const"; +import { pathToContentTypeId } from "@/content/registry"; +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; + +import { CreateContentAction } from "./actions/create-action"; +import { ContentTableView } from "./table/content-table-view"; + +export interface ContentAdminViewProps { + params: Promise<{ slug: string[] }>; + searchParams: Promise>; +} + +/** + * Resolves a registered content type from the catch-all slug, or `undefined`. + * Shared with `generateMetadata` and the breadcrumb slot. + */ +export const resolveContentType = async ( + params: ContentAdminViewProps["params"], +): Promise => { + const { slug } = await params; + + return findFrontendContentType(pathToContentTypeId(slug)); +}; + +/** + * Resolves the display strings for a content type. + * + * Every key is optional: a plugin that translates nothing still gets readable + * labels from the definition itself and from humanised field names. + */ +export const getContentLabels = async ( + entry: RegisteredFrontendContentType, +) => { + const { definition, pluginId } = entry; + const keys = contentI18nKeys(definition, pluginId); + const t = await getTranslations(); + const has = (key: string): boolean => + t.has(key as Parameters[0]); + const read = (key: string): string => t(key as Parameters[0]); + + return { + desc: has(keys.desc) ? read(keys.desc) : undefined, + labelEnum: (field: string, value: string) => { + const key = keys.enumValue(field, value); + + return has(key) ? read(key) : humanizeFieldName(value); + }, + labelField: (name: string) => { + const key = keys.field(name); + + return has(key) ? read(key) : humanizeFieldName(name); + }, + title: has(keys.title) ? read(keys.title) : definition.admin.label.plural, + }; +}; + +export const ContentAdminView = async ({ + params, + searchParams, +}: ContentAdminViewProps) => { + const entry = await resolveContentType(params); + if (!entry) notFound(); + + const { definition, pluginId, registration } = entry; + + const [labels, canView, canCreate, query] = await Promise.all([ + getContentLabels(entry), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }), + checkAdminPermissionApi({ + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.create, + plugin: pluginId, + }), + searchParams, + ]); + + if (!canView) notFound(); + + const formSpec = buildContentFormSpec({ + definition, + labelEnum: labels.labelEnum, + labelField: labels.labelField, + pluginId, + }); + const columnSpecs = buildContentColumnSpec({ + definition, + labelEnum: labels.labelEnum, + labelField: labels.labelField, + }); + + return ( + +
+ + {canCreate && ( + [name, override.component], + ), + )} + singular={definition.admin.label.singular} + spec={formSpec} + /> + )} + + + } + > + + +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.test.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.test.tsx new file mode 100644 index 000000000..dc9a736fb --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from "@testing-library/react"; +import { type FieldValues, useForm } from "react-hook-form"; +import { describe, expect, it, vi } from "vitest"; + +import type { ContentFormFieldSpec } from "@/content/admin/spec"; + +import { Form, FormField } from "@/components/ui/form"; + +import { ContentField } from "./field-component"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +const Harness = ({ spec }: { spec: ContentFormFieldSpec }) => { + const form = useForm({ defaultValues: { value: undefined } as FieldValues }); + + return ( +
+ ( + await Promise.resolve([])} + otherProps={{ + enum: spec.options?.map(option => option.value), + isOptional: !spec.required, + }} + spec={spec} + /> + )} + /> + + ); +}; + +const renderField = (overrides: Partial) => + render( + , + ); + +describe("ContentField", () => { + it("renders a text field as a text input", () => { + const { container } = renderField({ kind: "text" }); + + expect(container.querySelector('input[type="text"]')).not.toBeNull(); + }); + + it("renders a textarea field as a textarea", () => { + const { container } = renderField({ kind: "textarea" }); + + expect(container.querySelector("textarea")).not.toBeNull(); + }); + + it("renders a number field as a number input", () => { + const { container } = renderField({ integer: true, kind: "number" }); + + expect(container.querySelector('input[type="number"]')).not.toBeNull(); + }); + + it("renders a nullable number with its clear toggle", () => { + renderField({ integer: true, kind: "number", nullable: true }); + + expect(screen.getByRole("checkbox")).toBeTruthy(); + }); + + it("renders a boolean field as a switch", () => { + renderField({ kind: "boolean" }); + + expect(screen.getByRole("switch")).toBeTruthy(); + }); + + it("renders a dateTime field as a datetime-local input", () => { + const { container } = renderField({ kind: "dateTime" }); + + expect( + container.querySelector('input[type="datetime-local"]'), + ).not.toBeNull(); + }); + + it("renders an enum field as a select by default", () => { + renderField({ + kind: "enum", + options: [{ label: "Draft", value: "draft" }], + }); + + expect(screen.getByRole("combobox")).toBeTruthy(); + }); + + it("renders an enum field as a radio group when asked", () => { + renderField({ + display: "radio", + kind: "enum", + options: [ + { label: "Draft", value: "draft" }, + { label: "Published", value: "published" }, + ], + }); + + expect(screen.getAllByRole("radio").length).toBe(2); + }); + + it("shows the field label", () => { + renderField({ kind: "text", label: "Published at" }); + + expect(screen.getByText("Published at")).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx new file mode 100644 index 000000000..c0ba1f0bf --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -0,0 +1,104 @@ +import { useTranslations } from "next-intl"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; + +import { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { AutoFormDateTime } from "@/components/form/fields/date-time"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormRadioGroup } from "@/components/form/fields/radio-group"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; + +export type ContentOptionsLoader = (args: { + field: string; + search: string; +}) => Promise<{ label: string; value: string }[]>; + +export interface ContentFieldProps extends ItemAutoFormComponentProps { + loadOptions: ContentOptionsLoader; + spec: ContentFormFieldSpec; +} + +/** + * Maps a field descriptor onto the AdminCP input that already exists for it. + * + * Nothing new is invented here - `relation` and `user` both reuse the async + * combobox, and the loader behind them is a server action so the picker never + * needs the API origin or a second permission. + */ +export const ContentField = ({ + loadOptions, + spec, + ...props +}: ContentFieldProps) => { + const t = useTranslations("core.content.form"); + + switch (spec.kind) { + case "boolean": + return ; + + case "dateTime": + return ; + + case "enum": { + const labels = spec.options ?? []; + + return spec.display === "radio" ? ( + + ) : ( + + ); + } + + case "number": + // A nullable number needs the "no value" toggle; a plain one does not. + return spec.nullable ? ( + + ) : ( + + ); + + case "relation": + case "user": + return ( + + await loadOptions({ field: spec.name, search }) + } + id={`content-${spec.name}`} + label={spec.label} + placeholder={t("relation.placeholder")} + searchPlaceholder={t("relation.search_placeholder")} + showClear={spec.nullable} + {...props} + /> + ); + + case "textarea": + return ; + + default: + return ; + } +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.test.ts b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.test.ts new file mode 100644 index 000000000..b04c73932 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { contentErrorKey } from "./mutation-feedback"; + +describe("contentErrorKey", () => { + it.each([ + [400, "validation"], + [403, "forbidden"], + [404, "not_found"], + [409, "conflict"], + ])("maps %i to the %s message", (status, key) => { + expect(contentErrorKey(status)).toBe(key); + }); + + it("tells a restricted delete apart from a server fault", () => { + // The whole point: a 409 is explainable, a 500 is not, and they must never + // read the same. + expect(contentErrorKey(409)).not.toBe(contentErrorKey(500)); + }); + + it("falls through to the generic message for anything unrecognised", () => { + expect(contentErrorKey(500)).toBeNull(); + expect(contentErrorKey(502)).toBeNull(); + expect(contentErrorKey(undefined)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts new file mode 100644 index 000000000..d14c0a946 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts @@ -0,0 +1,29 @@ +/** Keys under `core.content.errors` that a mutation status maps onto. */ +export type ContentErrorKey = + "conflict" | "forbidden" | "not_found" | "validation"; + +/** + * Turns a generated route's status into something a person can act on. + * + * The generated routes answer with a status and a generic sentence - never a + * driver message - so the AdminCP can tell "you typed something invalid" from + * "this row is still referenced" from "the server fell over" without ever + * echoing what Postgres said. Anything unrecognised falls through to `null`, + * which the caller renders as the global server-error message. + */ +export const contentErrorKey = ( + status: number | undefined, +): ContentErrorKey | null => { + switch (status) { + case 400: + return "validation"; + case 403: + return "forbidden"; + case 404: + return "not_found"; + case 409: + return "conflict"; + default: + return null; + } +}; diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx new file mode 100644 index 000000000..8756bdfcd --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -0,0 +1,90 @@ +import { CheckIcon, MinusIcon } from "lucide-react"; + +import type { ContentColumnSpec } from "@/content/admin/spec"; +import type { ContentLabels } from "@/content/server/service"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; + +export interface ContentRowData extends Record { + id: number; + labels: ContentLabels; +} + +const Empty = ({ label }: { label: string }) => ( + {label} +); + +/** Only the shapes a column can actually hold - never "[object Object]". */ +const asText = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "boolean") return String(value); + + return ""; +}; + +/** + * Renders one list cell for a field kind. + * + * Deliberately plain: a plugin that wants more supplies `columns..cell` + * in `buildPlugin`, and that override is used instead of this. + */ +export const ContentCell = ({ + emptyLabel, + row, + spec, +}: { + emptyLabel: string; + row: ContentRowData; + spec: ContentColumnSpec; +}) => { + const value = row[spec.name]; + + if (spec.kind === "relation" || spec.kind === "user") { + const label = row.labels[spec.name]; + + return label === null || label === undefined ? ( + + ) : ( + {label} + ); + } + + if (value === null || value === undefined || value === "") { + return ; + } + + switch (spec.kind) { + case "boolean": + return value === true ? ( + + ) : ( + + ); + + case "dateTime": + return ; + + case "enum": { + const key = asText(value); + + return {spec.options?.[key] ?? key}; + } + + case "number": + return {asText(value)}; + + case "system": + return spec.name === "id" ? ( + {asText(value)} + ) : ( + + ); + + default: + return {asText(value)}; + } +}; diff --git a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx new file mode 100644 index 000000000..7de1403e6 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx @@ -0,0 +1,151 @@ +import { getTranslations } from "next-intl/server"; +import { z } from "zod"; + +import type { ColumnDef } from "@/components/table/data-table"; +import type { RegisteredFrontendContentType } from "@/content/admin/config"; +import type { ContentColumnSpec, ContentFormSpec } from "@/content/admin/spec"; + +import { zodPaginationPageInfo } from "@/api/lib/with-pagination"; +import { DataTable } from "@/components/table/data-table"; +import { contentApiFetch } from "@/content/admin/fetch.server"; + +import type { ContentRowData } from "./cells"; + +import { DeleteContentAction } from "../actions/delete-action"; +import { EditContentAction } from "../actions/edit-action"; +import { ContentCell } from "./cells"; + +const zodList = z.object({ + edges: z.array( + z + .object({ + id: z.number(), + labels: z.record(z.string(), z.string().nullable()), + }) + .loose(), + ), + pageInfo: zodPaginationPageInfo, +}); + +export const ContentTableView = async ({ + columnSpecs, + entry, + formSpec, + searchParams, +}: { + columnSpecs: ContentColumnSpec[]; + entry: RegisteredFrontendContentType; + formSpec: ContentFormSpec; + searchParams: Record; +}) => { + const t = await getTranslations("core.content"); + const { definition, pluginId, registration } = entry; + + const result = await contentApiFetch({ + definition, + method: "get", + pluginId, + query: searchParams, + schema: zodList, + }); + + const data = result.data ?? { + edges: [], + pageInfo: { + count: 0, + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + totalCount: 0, + }, + }; + + const emptyLabel = t("table.empty_value"); + const titleField = definition.admin.titleField; + + const columns: ColumnDef[] = [ + ...columnSpecs.map((spec): ColumnDef => { + const override = registration.columns?.[spec.name]; + + return { + accessorKey: spec.name, + header: spec.label, + + cell: ({ row }) => { + if (!override) { + return ( + + ); + } + + // Rendered as an element, not called as a function: an override is a + // client component, and invoking one directly from this server + // component would run its hooks on the server. + const Cell = override.cell; + + return ; + }, + }; + }), + { + id: "actions", + header: "", + align: "right", + className: "w-20", + cell: ({ row }) => { + const title = + titleField && typeof row[titleField] === "string" + ? row[titleField] + : `#${row.id}`; + + return ( + <> + [name, override.component], + ), + )} + permissionModule={definition.permissionModule} + pluginId={pluginId} + singular={definition.admin.label.singular} + spec={formSpec} + title={title} + /> + + + ); + }, + }, + ]; + + return ( + 0} + /> + ); +}; diff --git a/packages/vitnode/src/ws/manager.test.ts b/packages/vitnode/src/ws/manager.test.ts index c7c62192f..478bdad0e 100644 --- a/packages/vitnode/src/ws/manager.test.ts +++ b/packages/vitnode/src/ws/manager.test.ts @@ -3,22 +3,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createWebSocketManager } from "./manager"; class FakeWebSocket { - constructor(public url: string) { + constructor(url: string) { + this.url = url; wsInstances.push(this); } + static readonly CLOSED = 3; static readonly CLOSING = 2; static readonly CONNECTING = 0; - static readonly OPEN = 1; + onclose: (() => void) | null = null; onerror: (() => void) | null = null; onmessage: ((event: { data: unknown }) => void) | null = null; onopen: (() => void) | null = null; readyState = FakeWebSocket.CONNECTING; - sent: string[] = []; + // Written out rather than declared as a constructor parameter property: + // `erasableSyntaxOnly` rules those out, and `pnpm test:types` type-checks + // this file. + readonly url: string; + // Real browsers deliver the close event asynchronously, so `close()` // deliberately does not invoke `onclose` here. close() { @@ -42,10 +48,13 @@ class FakeWebSocket { } class FakeBroadcastChannel { - constructor(public name: string) { + constructor(name: string) { + this.name = name; bcInstances.push(this); } + closed = false; + readonly name: string; onmessage: ((event: { data: unknown }) => void) | null = null; posted: unknown[] = []; diff --git a/packages/vitnode/tsconfig.build.json b/packages/vitnode/tsconfig.build.json index 997f1529d..2272fc7d1 100644 --- a/packages/vitnode/tsconfig.build.json +++ b/packages/vitnode/tsconfig.build.json @@ -1,5 +1,11 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] + "exclude": [ + "node_modules", + "src/tests", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-d.ts" + ] } diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index 49b17fa9e..82c64249a 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -8,6 +8,12 @@ export default defineConfig({ globals: true, environment: "jsdom", setupFiles: ["./src/tests/setup.ts"], + // `*.test-d.ts` files assert types only. They run under `pnpm test:types` + // (`vitest --typecheck`) and are skipped by the normal runtime suite. + typecheck: { + tsconfig: "./tsconfig.json", + include: ["**/*.test-d.ts"], + }, exclude: [ "**/node_modules/**", "**/dist/**", diff --git a/plugins/blog/.swcrc b/plugins/blog/.swcrc index 8f099dc7a..91fc9a4b0 100644 --- a/plugins/blog/.swcrc +++ b/plugins/blog/.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/plugins/blog/tsconfig.build.json b/plugins/blog/tsconfig.build.json index 997f1529d..bdd7ab7ab 100644 --- a/plugins/blog/tsconfig.build.json +++ b/plugins/blog/tsconfig.build.json @@ -1,5 +1,10 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] + "exclude": [ + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-d.ts" + ] } diff --git a/plugins/example/.npmignore b/plugins/example/.npmignore new file mode 100644 index 000000000..76da93b80 --- /dev/null +++ b/plugins/example/.npmignore @@ -0,0 +1,17 @@ +/src/* +!/src/routes +!/src/routes/** +!/src/locales +!/src/locales/** + +/node_modules +/.turbo +/tsconfig.json +/.swcrc +/components.json +/global.d.ts +/tsup.config.ts +/vitest.config.ts +/tsconfig.json +/scripts +/config \ No newline at end of file diff --git a/plugins/example/.swcrc b/plugins/example/.swcrc new file mode 100644 index 000000000..91fc9a4b0 --- /dev/null +++ b/plugins/example/.swcrc @@ -0,0 +1,26 @@ +{ + "$schema": "https://swc.rs/schema.json", + "exclude": ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"], + "minify": true, + "jsc": { + "baseUrl": "./", + "target": "esnext", + "paths": { + "@/*": ["./src/*"] + }, + "parser": { + "syntax": "typescript", + "tsx": true + }, + "transform": { + "react": { + "runtime": "automatic" + } + } + }, + "module": { + "type": "nodenext", + "strict": true, + "resolveFully": true + } +} diff --git a/plugins/example/eslint.config.mjs b/plugins/example/eslint.config.mjs new file mode 100644 index 000000000..b1ce26beb --- /dev/null +++ b/plugins/example/eslint.config.mjs @@ -0,0 +1,19 @@ +import eslintVitNode from "@vitnode/config/eslint"; +import eslintVitNodeReact from "@vitnode/config/eslint.react"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...eslintVitNode, + ...eslintVitNodeReact, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + }, + }, +]; diff --git a/plugins/example/global.d.ts b/plugins/example/global.d.ts new file mode 100644 index 000000000..a57ec4757 --- /dev/null +++ b/plugins/example/global.d.ts @@ -0,0 +1,10 @@ +/// + +import core from "@vitnode/core/locales/en.json" with { type: "json" }; +import plugin from "./src/locales/en.json" with { type: "json" }; + +declare module "next-intl" { + interface AppConfig { + Messages: typeof plugin & typeof core; + } +} diff --git a/plugins/example/package.json b/plugins/example/package.json new file mode 100644 index 000000000..cbc59ed03 --- /dev/null +++ b/plugins/example/package.json @@ -0,0 +1,51 @@ +{ + "name": "@vitnode/example", + "version": "0.0.0", + "description": "Reference plugin exercising the VitNode Content Engine end to end.", + "license": "MIT", + "private": true, + "type": "module", + "exports": { + "./locales/*.json": "./src/locales/*.json", + "./*": { + "import": "./dist/src/*.js", + "types": "./dist/src/*.d.ts", + "default": "./dist/src/*.js" + } + }, + "scripts": { + "build:plugins": "vitnode build", + "dev": "vitnode dev", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@hono/zod-openapi": "^1.5.1", + "@vitnode/core": "workspace:*", + "drizzle-kit": "^0.31.10", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.31", + "lucide-react": "^1.25.0", + "next": "16.3.0-preview.9", + "next-intl": "^4.13.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-hook-form": "^7.82.0", + "sonner": "^2.0.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@swc/cli": "^0.8.1", + "@swc/core": "^1.15.46", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitnode/config": "workspace:*", + "eslint": "^10.7.0", + "postgres": "^3.4.9", + "tsc-alias": "^1.9.1", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } +} diff --git a/plugins/example/src/api/lib/events.ts b/plugins/example/src/api/lib/events.ts new file mode 100644 index 000000000..433742ab6 --- /dev/null +++ b/plugins/example/src/api/lib/events.ts @@ -0,0 +1,18 @@ +import type { ContentEventsFor } from "@vitnode/core/content"; + +import type { articleContentType } from "@/content/article"; +import type { categoryContentType } from "@/content/category"; + +/** + * Grafts the generated content events onto the global event map. + * + * One line per content type is all it takes: `ContentEventsFor` expands to + * `content.example.article.created | .updated | .deleted` with typed payloads, + * so `changedFields` narrows to this content type's own field names. + */ +declare module "@vitnode/core/api/models/events" { + interface VitNodeEvents + extends + ContentEventsFor, + ContentEventsFor {} +} diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts new file mode 100644 index 000000000..d59a57541 --- /dev/null +++ b/plugins/example/src/api/modules/admin/admin.module.ts @@ -0,0 +1,25 @@ +import { buildModule } from "@vitnode/core/api/lib/module"; +import { buildContentAdminModule } from "@vitnode/core/content/server"; + +import { CONFIG_PLUGIN } from "@/const"; +import { articleContent } from "@/database/articles"; +import { categoryContent } from "@/database/categories"; + +/** + * The generated content module is nested here rather than mounted by the + * engine: Hono serves only the last sub-app mounted at a prefix, so a second + * top-level `/admin` would silently shadow this one. + * + * Routes land at `/api/@vitnode/example/admin/content/{module}`. + */ +export const adminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "admin", + routes: [], + modules: [ + buildContentAdminModule({ + pluginId: CONFIG_PLUGIN.pluginId, + contentTypes: [articleContent, categoryContent], + }), + ], +}); diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts new file mode 100644 index 000000000..e459b908c --- /dev/null +++ b/plugins/example/src/config.api.ts @@ -0,0 +1,16 @@ +import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { CONFIG_PLUGIN } from "@/const"; +import "@/api/lib/events"; + +/** + * No `contentTypes` here: `buildApiPlugin` walks the module tree, so the + * content types declared in `admin.module.ts` also drive the registry and the + * derived `can_view` / `can_create` / `can_edit` / `can_delete` permissions. + */ +export const exampleApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [adminModule], + }); diff --git a/plugins/example/src/config.tsx b/plugins/example/src/config.tsx new file mode 100644 index 000000000..1c3809dce --- /dev/null +++ b/plugins/example/src/config.tsx @@ -0,0 +1,28 @@ +import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; +import { buildPlugin } from "@vitnode/core/lib/plugin"; +import { FolderIcon, NotebookPenIcon } from "lucide-react"; + +import { articleContentType } from "@/content/article"; +import { categoryContentType } from "@/content/category"; + +import messages from "./locales"; + +/** + * Registering the content types is the whole frontend integration: the AdminCP + * screens, the nav items and the breadcrumbs are all generated from here. + */ +export const examplePlugin = () => + buildPlugin({ + pluginId: "@vitnode/example", + messages, + contentTypes: [ + contentTypeAdmin({ + definition: articleContentType, + icon: , + }), + contentTypeAdmin({ + definition: categoryContentType, + icon: , + }), + ], + }); diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts new file mode 100644 index 000000000..61c82134f --- /dev/null +++ b/plugins/example/src/const.ts @@ -0,0 +1 @@ +export const CONFIG_PLUGIN = { pluginId: "@vitnode/example" as const }; diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts new file mode 100644 index 000000000..eb8bd06a2 --- /dev/null +++ b/plugins/example/src/content/article.ts @@ -0,0 +1,49 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "./category"; + +/** + * Exercises every field kind the Content Engine supports in MVP 1. + * + * Client-safe by construction - zod and plain objects only - so the same object + * is imported by `config.tsx` (the AdminCP), by `config.api.ts` (the routes and + * permissions) and by `src/database/articles.ts` (the Drizzle table). + */ +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + // `unique: true` is all it takes to get a unique index in the migration. + code: field.text({ required: true, maxLength: 100, unique: true }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + 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: "Example Articles", singular: "Example Article" }, + titleField: "title", + list: { + columns: ["title", "code", "status", "category", "author", "updatedAt"], + searchableFields: ["title", "code", "excerpt"], + orderableFields: ["title", "code", "status"], + defaultOrderBy: "updatedAt", + defaultOrder: "desc", + }, + }, +}); diff --git a/plugins/example/src/content/category.ts b/plugins/example/src/content/category.ts new file mode 100644 index 000000000..2d5c6e6fd --- /dev/null +++ b/plugins/example/src/content/category.ts @@ -0,0 +1,24 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +/** + * The simplest possible content type: one text field. + * + * It exists mostly so `example.article` has something to relate to, which is + * what proves the `relation` field end to end. + */ +export const categoryContentType = defineContentType({ + id: "example.category", + tableName: "example_categories", + + fields: { + name: field.text({ required: true, minLength: 1, maxLength: 100 }), + }, + + admin: { + label: { plural: "Example Categories", singular: "Example Category" }, + list: { + columns: ["name", "createdAt"], + orderableFields: ["name"], + }, + }, +}); diff --git a/plugins/example/src/database/articles.ts b/plugins/example/src/database/articles.ts new file mode 100644 index 000000000..ce6897476 --- /dev/null +++ b/plugins/example/src/database/articles.ts @@ -0,0 +1,13 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { articleContentType } from "@/content/article"; + +import { example_categories } from "./categories"; + +export const articleContent = createContentModel(articleContentType, { + // One thunk per `relation` field - a missing or extra key is a compile error, + // and the thunk keeps circular content type references safe. + references: { category: () => example_categories.id }, +}); + +export const example_articles = articleContent.table; diff --git a/plugins/example/src/database/categories.ts b/plugins/example/src/database/categories.ts new file mode 100644 index 000000000..e1884d7f2 --- /dev/null +++ b/plugins/example/src/database/categories.ts @@ -0,0 +1,9 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { categoryContentType } from "@/content/category"; + +export const categoryContent = createContentModel(categoryContentType); + +// Drizzle Kit discovers the table from this export when it globs the built +// `dist/src/database/*.js`, so migrations stay generated and committed. +export const example_categories = categoryContent.table; diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts new file mode 100644 index 000000000..b5bc3a14a --- /dev/null +++ b/plugins/example/src/database/postgres.test.ts @@ -0,0 +1,240 @@ +import type { Context } from "hono"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import postgres from "postgres"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { articleContent } from "./articles"; +import { categoryContent } from "./categories"; + +/** + * A real Postgres smoke test for the Content Engine. + * + * It only runs when `DATABASE_TEST_URL` is set, and it *wipes* the database it + * points at - so the URL has to name a database with "test" in it. CI provides + * a throwaway Postgres service; locally you opt in the same way: + * + * ```bash + * DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \ + * pnpm --filter @vitnode/example test + * ``` + */ +const url = process.env.DATABASE_TEST_URL; + +const databaseName = (() => { + if (!url) return ""; + try { + return new URL(url).pathname.replace(/^\//, ""); + } catch { + return ""; + } +})(); + +const here = dirname(fileURLToPath(import.meta.url)); + +/** The committed migration - the exact DDL a fresh database would run. */ +const migrationSql = readFileSync( + resolve( + here, + "../../../../apps/docs/migrations/0022_add_example_content.sql", + ), + "utf8", +); + +/** + * Stands in for `core_users`, which the `author` field references. + * + * Core's own migrations are not replayed here: one of them builds a full-text + * column from per-language text-search configurations that a stock Postgres + * image does not ship, and none of that has anything to do with the Content + * Engine. Only the columns the foreign key and the label join actually touch + * are needed. + */ +const CORE_USERS_STUB = ` + CREATE TABLE "core_users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL + ); +`; + +let sql: ReturnType; +let context: Context; + +const pgErrorCode = async (run: () => Promise) => { + try { + await run(); + } catch (error) { + const cause = (error as { cause?: { code?: string } }).cause; + + return cause?.code ?? (error as { code?: string }).code; + } + + return undefined; +}; + +describe.skipIf(!url)("Content Engine against Postgres", () => { + beforeAll(async () => { + // The suite drops and recreates the whole schema, so refuse anything that + // does not obviously name a scratch database. + if (!/test/i.test(databaseName)) { + throw new Error( + `DATABASE_TEST_URL points at "${databaseName || url}". This suite wipes the database it runs against, so its name must contain "test".`, + ); + } + + sql = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + + await sql.unsafe(` + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + `); + await sql.unsafe(CORE_USERS_STUB); + + for (const statement of migrationSql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + + context = { + get: (key: string) => + key === "db" ? drizzle(sql, { casing: "camelCase" }) : undefined, + } as unknown as Context; + }, 60_000); + + afterAll(async () => { + await sql?.end(); + }); + + it("applies the unique index the descriptor asked for", async () => { + const indexes = await sql` + SELECT indexdef FROM pg_indexes + WHERE tablename = 'example_articles' + AND indexname = 'example_articles_code_key' + `; + + expect(indexes).toHaveLength(1); + expect(indexes[0].indexdef).toContain("CREATE UNIQUE INDEX"); + }); + + it("enables row level security on both generated tables", async () => { + const rows = await sql` + SELECT relname, relrowsecurity FROM pg_class + WHERE relname IN ('example_articles', 'example_categories') + ORDER BY relname + `; + + expect(rows).toEqual([ + { relname: "example_articles", relrowsecurity: true }, + { relname: "example_categories", relrowsecurity: true }, + ]); + }); + + it("runs the whole CRUD lifecycle", async () => { + const categories = categoryContent.service(context); + const articles = articleContent.service(context); + + const [user] = await sql<{ id: number }[]>` + INSERT INTO "core_users" ("name") VALUES ('Ada') RETURNING "id" + `; + + const category = await categories.create({ name: "Guides" }); + expect(category.id).toBeGreaterThan(0); + + const article = await articles.create({ + author: user.id, + category: category.id, + code: "guide-001", + publishedAt: "2026-08-02T10:00:00.000Z", + title: "Getting started", + }); + + // Declared defaults reach the row exactly once, from the create schema. + expect(article).toMatchObject({ + featured: false, + status: "draft", + views: 0, + }); + expect(article.publishedAt).toBeInstanceOf(Date); + + await expect(articles.findById(article.id)).resolves.toMatchObject({ + code: "guide-001", + title: "Getting started", + }); + + const { edges, pageInfo } = await articles.findMany(); + expect(pageInfo.totalCount).toBe(1); + // One LEFT JOIN per reference resolved both display labels. + expect(edges[0].labels).toEqual({ author: "Ada", category: "Guides" }); + + const updated = await articles.update(article.id, { + status: "published", + title: "Getting started, properly", + }); + expect([...(updated?.changedFields ?? [])].sort()).toEqual([ + "status", + "title", + ]); + expect(updated?.row.title).toBe("Getting started, properly"); + + // A unique text field is enforced by Postgres, not just by the descriptor. + await expect( + pgErrorCode(async () => + articles.create({ + category: category.id, + code: "guide-001", + title: "A duplicate", + }), + ), + ).resolves.toBe("23505"); + + // The relation is a real foreign key. + await expect( + pgErrorCode(async () => + articles.create({ + category: 999_999, + code: "guide-002", + title: "Orphan", + }), + ), + ).resolves.toBe("23503"); + + // `onDelete: "restrict"` is what a 409 upstream is actually made of. + await expect( + pgErrorCode(async () => categories.delete(category.id)), + ).resolves.toBe("23503"); + + // `onDelete: "set null"` on a nullable user field keeps the article. + await sql`DELETE FROM "core_users" WHERE "id" = ${user.id}`; + await expect(articles.findById(article.id)).resolves.toMatchObject({ + author: null, + }); + + // A null filter has to become `IS NULL`; equality against a null parameter + // would match nothing and Postgres would not complain about it. + await expect( + articles.findMany({ filters: { author: null } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 1 } }); + await expect( + articles.findMany({ filters: { author: 999_999 } }), + ).resolves.toMatchObject({ pageInfo: { totalCount: 0 } }); + + await expect(articles.delete(article.id)).resolves.toMatchObject({ + id: article.id, + }); + await expect(categories.delete(category.id)).resolves.toMatchObject({ + id: category.id, + }); + await expect(articles.findById(article.id)).resolves.toBeNull(); + }, 60_000); + + it("rejects invalid input before it reaches Postgres", async () => { + await expect( + articleContent + .service(context) + .create({ category: 1, code: "x", title: "no" }), + ).rejects.toThrow(); + }); +}); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts new file mode 100644 index 000000000..02635306b --- /dev/null +++ b/plugins/example/src/database/tables.test.ts @@ -0,0 +1,155 @@ +import { getTableName } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { example_articles } from "./articles"; +import { example_categories } from "./categories"; + +const articles = getTableConfig(example_articles); +const categories = getTableConfig(example_categories); + +const indexNames = (config: typeof articles) => + config.indexes.map(item => item.config.name); + +// Drizzle types an index name as optional; the engine always sets one. +const byName = (a: string | undefined, b: string | undefined) => + (a ?? "").localeCompare(b ?? ""); + +const uniqueIndexNames = (config: typeof articles) => + config.indexes + .filter(item => item.config.unique) + .map(item => item.config.name); + +/** + * The committed migration for the docs app, which is the one CI applies. It is + * read as text on purpose: this is the artefact a fresh database actually runs, + * so asserting on the Drizzle objects alone would not prove the DDL landed. + */ +const migration = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../apps/docs/migrations/0022_add_example_content.sql", + ), + "utf8", +); + +describe("example_articles", () => { + it("is a real table with the expected name", () => { + expect(getTableName(example_articles)).toBe("example_articles"); + }); + + it("enables row level security", () => { + expect(articles.enableRLS).toBe(true); + }); + + it("exercises every MVP 1 field kind", () => { + const types = Object.fromEntries( + articles.columns.map(column => [column.name, column.getSQLType()]), + ); + + expect(types).toMatchObject({ + author: "integer", // user + category: "integer", // relation + code: "varchar(100)", // text, unique + excerpt: "text", // textarea + featured: "boolean", + publishedAt: "timestamp", // dateTime + status: "varchar(64)", // enum + title: "varchar(200)", // text + views: "integer", // number + }); + }); + + it("gives the unique text field a unique index, and nothing else one", () => { + expect(uniqueIndexNames(articles)).toEqual(["example_articles_code_key"]); + }); + + it("indexes the foreign keys, the timestamps and the declared composite", () => { + expect([...indexNames(articles)].sort(byName)).toEqual([ + "example_articles_author_idx", + "example_articles_category_idx", + "example_articles_code_key", + "example_articles_created_at_idx", + "example_articles_status_created_at_idx", + "example_articles_updated_at_idx", + ]); + }); + + it("points its references at the right tables", () => { + const references = articles.foreignKeys.map(key => { + const reference = key.reference(); + + return { + column: reference.columns[0].name, + onDelete: key.onDelete, + table: getTableName(reference.foreignTable), + }; + }); + + expect(references).toContainEqual({ + column: "author", + onDelete: "set null", + table: "core_users", + }); + expect(references).toContainEqual({ + column: "category", + onDelete: "restrict", + table: "example_categories", + }); + }); +}); + +describe("example_categories", () => { + it("is a real table with row level security", () => { + expect(getTableName(example_categories)).toBe("example_categories"); + expect(categories.enableRLS).toBe(true); + }); + + it("has no unique indexes of its own", () => { + expect(uniqueIndexNames(categories)).toEqual([]); + }); +}); + +describe("the generated migration", () => { + it("creates both tables with row level security", () => { + expect(migration).toContain('CREATE TABLE "example_articles"'); + expect(migration).toContain('CREATE TABLE "example_categories"'); + expect(migration).toContain( + 'ALTER TABLE "example_articles" ENABLE ROW LEVEL SECURITY', + ); + }); + + it("creates the unique index for `field.text({ unique: true })`", () => { + expect(migration).toContain( + 'CREATE UNIQUE INDEX "example_articles_code_key" ON "example_articles" USING btree ("code")', + ); + }); + + it("creates exactly one index per resolved definition entry", () => { + const created = [ + ...migration.matchAll(/CREATE (?:UNIQUE )?INDEX "([^"]+)"/g), + ] + .map(match => match[1]) + .filter(name => name.startsWith("example_")); + + expect([...created].sort(byName)).toEqual( + [...indexNames(articles), ...indexNames(categories)].sort(byName), + ); + }); + + it("names the composite index in snake_case", () => { + expect(migration).toContain('"example_articles_status_created_at_idx"'); + }); + + it("wires the foreign keys with the declared onDelete behaviour", () => { + expect(migration).toContain( + 'REFERENCES "public"."core_users"("id") ON DELETE set null', + ); + expect(migration).toContain( + 'REFERENCES "public"."example_categories"("id") ON DELETE restrict', + ); + }); +}); diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json new file mode 100644 index 000000000..930e8ff44 --- /dev/null +++ b/plugins/example/src/locales/en.json @@ -0,0 +1,48 @@ +{ + "@vitnode/example": { + "title": "Example", + "content": { + "article": { + "title": "Articles", + "desc": "Everything the Content Engine generates, from one definition.", + "fields": { + "title": "Title", + "code": "Reference code", + "excerpt": "Excerpt", + "views": "Views", + "featured": "Featured", + "status": "Status", + "publishedAt": "Published at", + "author": "Author", + "category": "Category", + "updatedAt": "Updated" + }, + "enums": { + "status": { + "draft": "Draft", + "published": "Published", + "archived": "Archived" + } + } + }, + "category": { + "title": "Categories", + "desc": "Group articles together.", + "fields": { + "name": "Name", + "createdAt": "Created" + } + } + } + }, + "@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", + "@vitnode/example:example_categories": "Categories", + "@vitnode/example:example_categories:can_view": "View categories", + "@vitnode/example:example_categories:can_create": "Create categories", + "@vitnode/example:example_categories:can_edit": "Edit categories", + "@vitnode/example:example_categories:can_delete": "Delete categories" +} diff --git a/plugins/example/src/locales/index.ts b/plugins/example/src/locales/index.ts new file mode 100644 index 000000000..6e61a4882 --- /dev/null +++ b/plugins/example/src/locales/index.ts @@ -0,0 +1,11 @@ +import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; + +/** + * Every language this plugin ships. Add a file next to this one and a line + * here to add another; apps pick it up with no copy step. + */ +const messages: LocaleMessagesMap = { + en: async () => await import("./en.json", { with: { type: "json" } }), +}; + +export default messages; diff --git a/plugins/example/tsconfig.build.json b/plugins/example/tsconfig.build.json new file mode 100644 index 000000000..37c87582a --- /dev/null +++ b/plugins/example/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "exclude": [ + "node_modules", + "vitest.config.ts", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-d.ts" + ] +} diff --git a/plugins/example/tsconfig.json b/plugins/example/tsconfig.json new file mode 100644 index 000000000..aae5c4271 --- /dev/null +++ b/plugins/example/tsconfig.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vitnode/config/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "rootDir": "./", + "outDir": "./dist", + "incremental": false, + "jsx": "react-jsx", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules"], + "include": ["src", "global.d.ts", "vitest.config.ts"] +} diff --git a/plugins/example/vitest.config.ts b/plugins/example/vitest.config.ts new file mode 100644 index 000000000..a6624ef5b --- /dev/null +++ b/plugins/example/vitest.config.ts @@ -0,0 +1,14 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/node_modules/**", "**/dist/**"], + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d4ffa4e2..7c394030b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@vitnode/config': specifier: workspace:* version: link:../../packages/config + '@vitnode/example': + specifier: workspace:* + version: link:../../plugins/example '@vitnode/nodemailer': specifier: workspace:* version: link:../../packages/nodemailer @@ -141,6 +144,9 @@ importers: '@vitnode/core': specifier: workspace:* version: link:../../packages/vitnode + '@vitnode/example': + specifier: workspace:* + version: link:../../plugins/example drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -149,13 +155,13 @@ importers: version: 0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9) fumadocs-core: specifier: ^16.11.5 - version: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + version: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) fumadocs-mdx: specifier: ^15.2.0 - version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) fumadocs-ui: specifier: ^16.11.5 - version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) hono: specifier: ^4.12.31 version: 4.12.31 @@ -831,6 +837,79 @@ importers: specifier: ^6.0.3 version: 6.0.3 + plugins/example: + dependencies: + '@hono/zod-openapi': + specifier: ^1.5.1 + version: 1.5.1(hono@4.12.31)(zod@4.4.3) + '@vitnode/core': + specifier: workspace:* + version: link:../../packages/vitnode + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.1)(postgres@3.4.9) + hono: + specifier: ^4.12.31 + version: 4.12.31 + lucide-react: + specifier: ^1.25.0 + version: 1.25.0(react@19.2.8) + next: + specifier: 16.3.0-preview.9 + version: 16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-intl: + specifier: ^4.13.3 + version: 4.13.3(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-hook-form: + specifier: ^7.82.0 + version: 7.82.0(react@19.2.8) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@swc/cli': + specifier: ^0.8.1 + version: 0.8.1(@swc/core@1.15.46)(chokidar@5.0.0) + '@swc/core': + specifier: ^1.15.46 + version: 1.15.46 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitnode/config': + specifier: workspace:* + version: link:../../packages/config + eslint: + specifier: ^10.7.0 + version: 10.7.0(jiti@2.7.0) + postgres: + specifier: ^3.4.9 + version: 3.4.9 + tsc-alias: + specifier: ^1.9.1 + version: 1.9.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages: '@ai-sdk/anthropic@4.0.20': @@ -900,6 +979,9 @@ packages: peerDependencies: zod: ^4.0.0 + '@astrojs/compiler@4.0.0': + resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==} + '@aws-sdk/checksums@3.1000.19': resolution: {integrity: sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==} engines: {node: '>=20.0.0'} @@ -2849,129 +2931,129 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} - '@oxc-parser/binding-android-arm-eabi@0.141.0': - resolution: {integrity: sha512-jk7086MFvR/T4DG9IY7MKBVt1PMxvSZoz/TvnifodvS0pjghVwJHRttnAExhlwdMOgHv1TmLdENnbNpYk2zjvA==} + '@oxc-parser/binding-android-arm-eabi@0.142.0': + resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.141.0': - resolution: {integrity: sha512-a4XDQ27ZT7e7zwAlxJDTiCA7IBGWDuy2+MhFq85Of7XlBSmpkfcBFml11q0Zx6f7RMuI0B4xCtt2ytBS4yOptg==} + '@oxc-parser/binding-android-arm64@0.142.0': + resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.141.0': - resolution: {integrity: sha512-m/kVk6rzYmBeHYnz+1Y5fod00AVTTxMbC71azFfm/zjx1j9XxwKtA0+VfkKuVMC8rbghb9TtfevnuWZa9OuPEg==} + '@oxc-parser/binding-darwin-arm64@0.142.0': + resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.141.0': - resolution: {integrity: sha512-o0X+6KZlfucWU/v5oKRQPwdFXsXAjW8jmpo/Gpw/qyKsbKtlfkHoeH9Bjp/m13TwjewvJnCkwF0DWzgpC4HjTQ==} + '@oxc-parser/binding-darwin-x64@0.142.0': + resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.141.0': - resolution: {integrity: sha512-W5KbTnNkTMMMylqj6dYqnsXvkmESVPodPKYLJ5zdzIPdl9fUJtolkpUeSzYEbGGYB4a4A4avl3EePnZ/wLIdJg==} + '@oxc-parser/binding-freebsd-x64@0.142.0': + resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.141.0': - resolution: {integrity: sha512-g3dtbJa8zeOGK36Sr9cQavsdi5H/ie2hVjrSjIxsNAR1qZA40ZYVXnfdfoMAlq8CmB9qFL1yhsSCUHeNmdmt8w==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.141.0': - resolution: {integrity: sha512-e6hwQqd+3lvP13G2jxvFpoA7dzHcFLN+Mq47JCVMtdNHbbyBRo756JCtbbJH6ca8inTfyqZoqBmS3vhQlzAK2w==} + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.141.0': - resolution: {integrity: sha512-vXz2BLAuypA+4MLyBg94pzEo6THVnzYnCtAjXoihIIQo0t2pnp/AmW+SH1EI+4VbuJnC//KplIJ5yyaCGua4jA==} + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.141.0': - resolution: {integrity: sha512-jMkS/EztNW34HKsXIaT/SoHcmtocq/vWhwFOVduF9kduuuRIVwfwQ6uxzIO+qPKSXdd2TXt54of0BJ2zFMXnmw==} + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.141.0': - resolution: {integrity: sha512-vo+MR+n3zQJ6Mq92hiP084NZcgDv5iJlVR02gMf28neMvVT1tKVm7VeiW/DxhdqOi3QLeaXIk9cUcLL1qrkngw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.141.0': - resolution: {integrity: sha512-oh80w+7RuiO5gBp9Jnoa/H8Qlt3JsHL2MkW+0dwEdlDMdslVZX/YsekSK6EeyEenY66/mhCfypsNATQ7Ph3qlQ==} + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.141.0': - resolution: {integrity: sha512-LOyEmFA8sCnYbEXP1+iQvCC/P1YXHMA/t6x1Ksp0Y9VwhLFsiBJFzV1zIxrOIE2LKaGGhDjQ29xq9cbq6omDXA==} + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.141.0': - resolution: {integrity: sha512-3wnwk/l1CvszVE5TJR1wSl/zSEfydRqrNhn6s7Vr9IzSJpUQIroqVsIoPARHRFA+FQwkxAFDAHDAasa7v8OobQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.141.0': - resolution: {integrity: sha512-qtyQVAAebFq57B2tifTlel3TgGqUtsYNI/e+p6aya9rN9lOZVTDvr215fGYSA9XWooxzMxDiVxkBLk2jQHbsOQ==} + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.141.0': - resolution: {integrity: sha512-SkGV1nKw40roEc94pv5EaaeH2ay14G6+roe8Q0wIUC1LcEKxzKW921h7+ZuZX0D3q2Mb/7aSFmxEVqnko3lPRw==} + '@oxc-parser/binding-linux-x64-musl@0.142.0': + resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.141.0': - resolution: {integrity: sha512-cVgDM7n8QziQqOaP5hNgUYfMG7S/ZeuPxFWXnnHRv7rh025COk0rfQ6eEdKG3j/GaUuyvNZN4ifF1J8KmuXLLA==} + '@oxc-parser/binding-openharmony-arm64@0.142.0': + resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.141.0': - resolution: {integrity: sha512-HggH++Fkn3OilBn+bs3jpgIFQa34oMAyUUHy0vpGum+gt1Eb5nyLc8dNU/RAPSw6lsLrx7ncKtHSZE+3Sp0l2g==} + '@oxc-parser/binding-wasm32-wasi@0.142.0': + resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.141.0': - resolution: {integrity: sha512-KLSEH9GwgbrqbJOjtGHt9STw96s+78yDzp7IDN8Lno+7Ut9sNBfZ4jYZIz4mD50qmWUjoOI7i9I6UENbhNbMZQ==} + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.141.0': - resolution: {integrity: sha512-9UVWUOOCI/1YkiSSNjg2zyBJYM9E/t1A/8GNobd48JDn/fQ6mzxcVO3H08jb3rAaW/B1VBf8eCORTvSsO9T08g==} + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.141.0': - resolution: {integrity: sha512-HI/wsvbWT5RHHw5c37D0fEgeTd8/1Q4OJs5jUmEBc17VZFG6SsCIe4barq7NsAPPks/JW+3ayi3Rp+PQI5h4Kg==} + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2979,8 +3061,8 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxc-project/types@0.141.0': - resolution: {integrity: sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} @@ -3085,124 +3167,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.74.0': - resolution: {integrity: sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==} + '@oxlint/binding-android-arm-eabi@1.76.0': + resolution: {integrity: sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.74.0': - resolution: {integrity: sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==} + '@oxlint/binding-android-arm64@1.76.0': + resolution: {integrity: sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.74.0': - resolution: {integrity: sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==} + '@oxlint/binding-darwin-arm64@1.76.0': + resolution: {integrity: sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.74.0': - resolution: {integrity: sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==} + '@oxlint/binding-darwin-x64@1.76.0': + resolution: {integrity: sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.74.0': - resolution: {integrity: sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==} + '@oxlint/binding-freebsd-x64@1.76.0': + resolution: {integrity: sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.74.0': - resolution: {integrity: sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==} + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + resolution: {integrity: sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.74.0': - resolution: {integrity: sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==} + '@oxlint/binding-linux-arm-musleabihf@1.76.0': + resolution: {integrity: sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.74.0': - resolution: {integrity: sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==} + '@oxlint/binding-linux-arm64-gnu@1.76.0': + resolution: {integrity: sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.74.0': - resolution: {integrity: sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==} + '@oxlint/binding-linux-arm64-musl@1.76.0': + resolution: {integrity: sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.74.0': - resolution: {integrity: sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==} + '@oxlint/binding-linux-ppc64-gnu@1.76.0': + resolution: {integrity: sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.74.0': - resolution: {integrity: sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==} + '@oxlint/binding-linux-riscv64-gnu@1.76.0': + resolution: {integrity: sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.74.0': - resolution: {integrity: sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==} + '@oxlint/binding-linux-riscv64-musl@1.76.0': + resolution: {integrity: sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.74.0': - resolution: {integrity: sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==} + '@oxlint/binding-linux-s390x-gnu@1.76.0': + resolution: {integrity: sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.74.0': - resolution: {integrity: sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==} + '@oxlint/binding-linux-x64-gnu@1.76.0': + resolution: {integrity: sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.74.0': - resolution: {integrity: sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==} + '@oxlint/binding-linux-x64-musl@1.76.0': + resolution: {integrity: sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.74.0': - resolution: {integrity: sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==} + '@oxlint/binding-openharmony-arm64@1.76.0': + resolution: {integrity: sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.74.0': - resolution: {integrity: sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==} + '@oxlint/binding-win32-arm64-msvc@1.76.0': + resolution: {integrity: sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.74.0': - resolution: {integrity: sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==} + '@oxlint/binding-win32-ia32-msvc@1.76.0': + resolution: {integrity: sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.74.0': - resolution: {integrity: sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==} + '@oxlint/binding-win32-x64-msvc@1.76.0': + resolution: {integrity: sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5394,6 +5476,7 @@ packages: cron-parser@5.6.2: resolution: {integrity: sha512-yJ/G1LVir6CnlkLI40CsampPLKl1SprGGlUagWtGJxewytRVYezv5xVyzzbT+Pvzx+VIRvZVF7/a0eEMTxdnTA==} engines: {node: '>=18'} + deprecated: 'Published tarball was built from a stale dist/ and is missing the fixes from #414 and #415 — its contents are effectively v5.6.1. Upgrade to v5.7.0.' cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -5572,8 +5655,8 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - deslop-js@0.9.2: - resolution: {integrity: sha512-rGhQ17gHnmsjG5KFJM4+oN4bOxktHiPGqzJxKqWt0Qw/NLZIsEgLH1wIo1tTXRyN/MNwhchKbfUieFiWyAh0pQ==} + deslop-js@0.9.3: + resolution: {integrity: sha512-sJcR5LLnEG+w58Oy5CdZfwAfm8XiERbXp9c941Aoeb8JmBHk/56TbZQlLJseJtClg0dkn88wpmnI82+iF0jagg==} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} @@ -7776,23 +7859,23 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} - oxc-parser@0.141.0: - resolution: {integrity: sha512-uFkGGr1KMWd6aWv9UAqooYrN78trw8MWWmoPvgWokfBEUq1+eiIQ+qfj3wokhy0fxtZWZk+0dHoS7/yRTJtd6w==} + oxc-parser@0.142.0: + resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - oxlint-plugin-react-doctor@0.9.2: - resolution: {integrity: sha512-fTciSOgAGe/KAgvFDKLz9crjxHyAuu0BvT5sXfSdo2B5QmY5Y/j3nliqX6FaGr7Wud1SYvkAky+/m+58b6K6fQ==} + oxlint-plugin-react-doctor@0.9.3: + resolution: {integrity: sha512-7XDOw+zjVquh0yqX3zvGQC2zzWIp2rXyMjLDqFzzQ8t7TZXfIQ6ttQhBwckQSk4c7kD7Cy9If0F4LI4XXI4Gsg==} engines: {node: ^20.19.0 || >=22.13.0} - oxlint@1.74.0: - resolution: {integrity: sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==} + oxlint@1.76.0: + resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.24.0' + oxlint-tsgolint: '>=7.0.2001' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -8189,8 +8272,8 @@ packages: '@types/react': optional: true - react-doctor@0.9.2: - resolution: {integrity: sha512-A/e21t0y3j7zUTS8lJyNI2pKMfYLDH+zZwqAC7+MQW1vCD3xqWc2x8XCCWKnrQQwtFd6dwSZw2017x1iSLnGTA==} + react-doctor@0.9.3: + resolution: {integrity: sha512-s8kWwfFKZA3e9AT5wwFilQNjxKFP30ceFIXOZDrmLPstFodbHOpE0Mv+fEY0/04uZbhRdVYKoaHYb+yaAIEwPw==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -9640,6 +9723,8 @@ snapshots: openapi3-ts: 4.6.0 zod: 4.4.3 + '@astrojs/compiler@4.0.0': {} + '@aws-sdk/checksums@3.1000.19': dependencies: '@aws-sdk/core': 3.976.0 @@ -11288,55 +11373,55 @@ snapshots: '@orama/orama@3.1.18': {} - '@oxc-parser/binding-android-arm-eabi@0.141.0': + '@oxc-parser/binding-android-arm-eabi@0.142.0': optional: true - '@oxc-parser/binding-android-arm64@0.141.0': + '@oxc-parser/binding-android-arm64@0.142.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.141.0': + '@oxc-parser/binding-darwin-arm64@0.142.0': optional: true - '@oxc-parser/binding-darwin-x64@0.141.0': + '@oxc-parser/binding-darwin-x64@0.142.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.141.0': + '@oxc-parser/binding-freebsd-x64@0.142.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.141.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.141.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.141.0': + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.141.0': + '@oxc-parser/binding-linux-arm64-musl@0.142.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.141.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.141.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.141.0': + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.141.0': + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.141.0': + '@oxc-parser/binding-linux-x64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.141.0': + '@oxc-parser/binding-linux-x64-musl@0.142.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.141.0': + '@oxc-parser/binding-openharmony-arm64@0.142.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.141.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@oxc-parser/binding-wasm32-wasi@0.142.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: @@ -11344,18 +11429,18 @@ snapshots: - '@emnapi/runtime' optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.141.0': + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.141.0': + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.141.0': + '@oxc-parser/binding-win32-x64-msvc@0.142.0': optional: true '@oxc-project/types@0.139.0': {} - '@oxc-project/types@0.141.0': {} + '@oxc-project/types@0.142.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -11419,61 +11504,61 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true - '@oxlint/binding-android-arm-eabi@1.74.0': + '@oxlint/binding-android-arm-eabi@1.76.0': optional: true - '@oxlint/binding-android-arm64@1.74.0': + '@oxlint/binding-android-arm64@1.76.0': optional: true - '@oxlint/binding-darwin-arm64@1.74.0': + '@oxlint/binding-darwin-arm64@1.76.0': optional: true - '@oxlint/binding-darwin-x64@1.74.0': + '@oxlint/binding-darwin-x64@1.76.0': optional: true - '@oxlint/binding-freebsd-x64@1.74.0': + '@oxlint/binding-freebsd-x64@1.76.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.74.0': + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.74.0': + '@oxlint/binding-linux-arm-musleabihf@1.76.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.74.0': + '@oxlint/binding-linux-arm64-gnu@1.76.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.74.0': + '@oxlint/binding-linux-arm64-musl@1.76.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.74.0': + '@oxlint/binding-linux-ppc64-gnu@1.76.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.74.0': + '@oxlint/binding-linux-riscv64-gnu@1.76.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.74.0': + '@oxlint/binding-linux-riscv64-musl@1.76.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.74.0': + '@oxlint/binding-linux-s390x-gnu@1.76.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.74.0': + '@oxlint/binding-linux-x64-gnu@1.76.0': optional: true - '@oxlint/binding-linux-x64-musl@1.74.0': + '@oxlint/binding-linux-x64-musl@1.76.0': optional: true - '@oxlint/binding-openharmony-arm64@1.74.0': + '@oxlint/binding-openharmony-arm64@1.76.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.74.0': + '@oxlint/binding-win32-arm64-msvc@1.76.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.74.0': + '@oxlint/binding-win32-ia32-msvc@1.76.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.74.0': + '@oxlint/binding-win32-x64-msvc@1.76.0': optional: true '@parcel/watcher-android-arm64@2.6.0': @@ -12844,7 +12929,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -13716,12 +13801,12 @@ snapshots: dequal@2.0.3: {} - deslop-js@0.9.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + deslop-js@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: - '@oxc-project/types': 0.141.0 + '@oxc-project/types': 0.142.0 fast-glob: 3.3.3 minimatch: 10.2.5 - oxc-parser: 0.141.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxc-parser: 0.142.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) oxc-resolver: 11.24.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) typescript: 5.9.3 transitivePeerDependencies: @@ -14607,7 +14692,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -14640,14 +14725,14 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) github-slugger: 2.0.0 magic-string: 0.30.21 mdast-util-mdx: 3.0.0 @@ -14673,7 +14758,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -14689,7 +14774,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.3.0-preview.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) lucide-react: 1.25.0(react@19.2.8) motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -16397,30 +16482,30 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.141.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + oxc-parser@0.142.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: - '@oxc-project/types': 0.141.0 + '@oxc-project/types': 0.142.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.141.0 - '@oxc-parser/binding-android-arm64': 0.141.0 - '@oxc-parser/binding-darwin-arm64': 0.141.0 - '@oxc-parser/binding-darwin-x64': 0.141.0 - '@oxc-parser/binding-freebsd-x64': 0.141.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.141.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.141.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.141.0 - '@oxc-parser/binding-linux-arm64-musl': 0.141.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.141.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.141.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.141.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.141.0 - '@oxc-parser/binding-linux-x64-gnu': 0.141.0 - '@oxc-parser/binding-linux-x64-musl': 0.141.0 - '@oxc-parser/binding-openharmony-arm64': 0.141.0 - '@oxc-parser/binding-wasm32-wasi': 0.141.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - '@oxc-parser/binding-win32-arm64-msvc': 0.141.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.141.0 - '@oxc-parser/binding-win32-x64-msvc': 0.141.0 + '@oxc-parser/binding-android-arm-eabi': 0.142.0 + '@oxc-parser/binding-android-arm64': 0.142.0 + '@oxc-parser/binding-darwin-arm64': 0.142.0 + '@oxc-parser/binding-darwin-x64': 0.142.0 + '@oxc-parser/binding-freebsd-x64': 0.142.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 + '@oxc-parser/binding-linux-arm64-musl': 0.142.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-musl': 0.142.0 + '@oxc-parser/binding-openharmony-arm64': 0.142.0 + '@oxc-parser/binding-wasm32-wasi': 0.142.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 + '@oxc-parser/binding-win32-x64-msvc': 0.142.0 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -16450,38 +16535,38 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxlint-plugin-react-doctor@0.9.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + oxlint-plugin-react-doctor@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@shaderfrog/glsl-parser': 7.0.1 '@typescript-eslint/types': 8.65.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 - oxc-parser: 0.141.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxc-parser: 0.142.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - oxlint@1.74.0: + oxlint@1.76.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.74.0 - '@oxlint/binding-android-arm64': 1.74.0 - '@oxlint/binding-darwin-arm64': 1.74.0 - '@oxlint/binding-darwin-x64': 1.74.0 - '@oxlint/binding-freebsd-x64': 1.74.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.74.0 - '@oxlint/binding-linux-arm-musleabihf': 1.74.0 - '@oxlint/binding-linux-arm64-gnu': 1.74.0 - '@oxlint/binding-linux-arm64-musl': 1.74.0 - '@oxlint/binding-linux-ppc64-gnu': 1.74.0 - '@oxlint/binding-linux-riscv64-gnu': 1.74.0 - '@oxlint/binding-linux-riscv64-musl': 1.74.0 - '@oxlint/binding-linux-s390x-gnu': 1.74.0 - '@oxlint/binding-linux-x64-gnu': 1.74.0 - '@oxlint/binding-linux-x64-musl': 1.74.0 - '@oxlint/binding-openharmony-arm64': 1.74.0 - '@oxlint/binding-win32-arm64-msvc': 1.74.0 - '@oxlint/binding-win32-ia32-msvc': 1.74.0 - '@oxlint/binding-win32-x64-msvc': 1.74.0 + '@oxlint/binding-android-arm-eabi': 1.76.0 + '@oxlint/binding-android-arm64': 1.76.0 + '@oxlint/binding-darwin-arm64': 1.76.0 + '@oxlint/binding-darwin-x64': 1.76.0 + '@oxlint/binding-freebsd-x64': 1.76.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.76.0 + '@oxlint/binding-linux-arm-musleabihf': 1.76.0 + '@oxlint/binding-linux-arm64-gnu': 1.76.0 + '@oxlint/binding-linux-arm64-musl': 1.76.0 + '@oxlint/binding-linux-ppc64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-musl': 1.76.0 + '@oxlint/binding-linux-s390x-gnu': 1.76.0 + '@oxlint/binding-linux-x64-gnu': 1.76.0 + '@oxlint/binding-linux-x64-musl': 1.76.0 + '@oxlint/binding-openharmony-arm64': 1.76.0 + '@oxlint/binding-win32-arm64-msvc': 1.76.0 + '@oxlint/binding-win32-ia32-msvc': 1.76.0 + '@oxlint/binding-win32-x64-msvc': 1.76.0 p-cancelable@4.0.1: {} @@ -16805,14 +16890,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-doctor@0.9.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)): + react-doctor@0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)): dependencies: + '@astrojs/compiler': 4.0.0 '@babel/code-frame': 7.29.7 '@sentry/node': 10.67.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.9.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + deslop-js: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) eslint-plugin-react-hooks: 7.1.1(eslint@10.7.0(jiti@2.7.0)) figures: 6.1.0 ink: 7.1.1(@types/react@19.2.17)(react@19.2.5) @@ -16820,8 +16906,8 @@ snapshots: jiti: 2.7.0 magicast: 0.5.3 oxc-resolver: 11.24.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - oxlint: 1.74.0 - oxlint-plugin-react-doctor: 0.9.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxlint: 1.76.0 + oxlint-plugin-react-doctor: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) prompts: 2.4.2 react: 19.2.5 typescript: 5.9.3 @@ -16942,7 +17028,7 @@ snapshots: preact: 10.29.7 prompts: 2.4.2 react: 19.2.8 - react-doctor: 0.9.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)) + react-doctor: 0.9.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)) react-dom: 19.2.8(react@19.2.8) react-grab: 0.1.50(react@19.2.8) optionalDependencies: diff --git a/turbo.json b/turbo.json index bc1858bc1..0ff2733ca 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,11 @@ "tasks": { "test": { "dependsOn": ["^test"], + "cache": false, + "env": ["DATABASE_TEST_URL"] + }, + "test:types": { + "dependsOn": ["^test:types"], "cache": false }, "docker:dev": {