Documentation

Database

The Postgres tables, columns and indexes the VitNode Content Engine generates for a content type, and how to generate, apply and evolve their migrations.

Every content type becomes ordinary Postgres tables managed by Drizzle. createContentModel from @vitnode/core/content/server builds them from the definition, and the usual VitNode migration workflow creates them. Values are stored in typed columns. Only field.blocks() and field.richText() keep a JSON document, in a jsonb column.

Generate and apply a migration

After you add or change a content type, run this from the workspace root:

Migrate the database
bun run db:migrate
  1. db:migrate reads each plugin's built dist/ folder. pnpm dev rebuilds a plugin every time you save, so while it runs the output is always current. Without pnpm dev, such as in CI or right after a checkout, run pnpm build:plugins first, or the migration sees the old schema.
  2. db:migrate compares the exported tables of every plugin in your API config with the last snapshot and writes a new folder to apps/api/migrations/.
  3. It applies pending migrations, then makes sure the initial data exists.

Commit the generated migration together with the content type. Production never migrates on startup, so run migrations as a deploy step. Database CLI covers vitnode db generate --name <name> for a named migration, vitnode db status and the rest.

Export every table

Drizzle Kit finds tables by reading the exports of node_modules/<pluginId>/dist/src/database/*.js for each plugin. A table without an export is left out of the migration, without a warning. A content type with relations, translations and repeatable rows has several tables:

plugins/example/src/database/advanced-articles.ts
import { createContentModel } from "@vitnode/core/content/server";

import { advancedArticleContentType } from "@/content/advanced-article";

import { example_categories } from "./categories";

export const advancedArticleContent = createContentModel(
  advancedArticleContentType,
  {
    references: {
      categories: () => example_categories.id,
    },
  },
);

export const example_advanced_articles = advancedArticleContent.table;
export const example_advanced_articles_translations =
  advancedArticleContent.translationTable;
export const example_advanced_articles_categories =
  advancedArticleContent.advancedTables.junctions.categories;
export const example_advanced_articles_related_articles =
  advancedArticleContent.advancedTables.junctions.relatedArticles;
export const example_advanced_articles_faq =
  advancedArticleContent.advancedTables.repeatables.faq;
Model memberExists when
tableAlways
translationTablelocalization is enabled; otherwise null
advancedTables.junctions.<field>A relation, user or file field has multiple: true
advancedTables.repeatables.<field>The content type has a field.repeatable()

references maps each relation field to the column it points at. user and file fields need no entry, because the engine knows core_users and core_files. See Relations.

Generated columns

You never declare these. A field with the same name throws an error.

ColumnTypeAdded when
idPrimary key, typed by idStrategyAlways
createdAttimestamp, defaults to now()Always
updatedAttimestamp, defaults to now(), refreshed by every updateAlways
statusvarchar(32), draft or published, defaults to draftpublication
publishedAttimestamp, nullablepublication
versioninteger, defaults to 1editorial
hiddenAttimestamp, nullablevisibility
hiddenByinteger, nullable, references core_users.id with ON DELETE SET NULLvisibility

Column names are camelCase in SQL, exactly as in TypeScript. Every generated table has row-level security enabled. A field group adds one column per leaf to the main table, so the syndication group's indexable field becomes syndicationIndexable. Column types per field are listed in Fields.

Translations table

With localization, {tableName}_translations holds the localized fields:

ColumnNotes
itemIdReferences the main table, with the same type as its id. Deleting the record deletes its translations
languageIdReferences core_languages.id. A language with content cannot be deleted
versionPer-language version, always present
createdAt, updatedAtSame as the main table
status, publishedAtWith publication. Each language is published on its own

The primary key is (itemId, languageId), so a record has at most one translation per language.

Junction and child tables

Field names become snake_case in table names, so relatedArticles on example_advanced_articles becomes example_advanced_articles_related_articles.

  • A to-many field gets {tableName}_{field} with itemId, relatedItemId, position and createdAt. The primary key is (itemId, relatedItemId), so a record links to each target once.
  • A repeatable field gets {tableName}_{field} with id, itemId, position, createdAt, updatedAt and one column per subfield.

Deleting a record deletes its junction and repeatable rows. Both kinds keep position unique per record. That column holds the order an editor picks in the AdminCP.

Every column that points at a record has the type of that record's key. In a junction table, itemId follows the owner's idStrategy and relatedItemId follows the target's. A repeatable's own id is always serial, while its itemId follows the owner. User and file references are always integer.

IDs and shared tables

The id strategy sets the key column:

idStrategyid column
"serial" (default)serial PRIMARY KEY
"uuid"uuid PRIMARY KEY DEFAULT gen_random_uuid()
"bigint"bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY

Core tables that hold records of every content type store the id as text: core_content_revisions, core_content_schedules, core_content_slug_history, core_search_index, the live editing tables core_content_drafts, core_content_field_locks and core_content_documents, and core_ai_translation_sources. Their itemId is a varchar(64) holding contentIdKey(id): the digits of a serial or bigint id, or the UUID. Migration 20261011093549_content_visibility_id_strategies converted them in place from integer, so existing serial keys keep their digits:

apps/api/migrations/20261011093549_content_visibility_id_strategies/migration.sql (excerpt)
ALTER TABLE "core_content_revisions" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);

Custom SQL that joins these tables to a content table needs a cast, such as "core_content_revisions"."itemId" = "example_articles"."id"::text.

Generated indexes

The engine creates these indexes without being asked:

  • A unique index on every slug, and on every field.text({ unique: true }).
  • An index on every single relation, user and file column, and on hiddenBy with visibility.
  • Indexes on createdAt and updatedAt.
  • (status, publishedAt) with publication.
  • On a translations table: (languageId, status) with publication or (languageId) without it, plus a unique (languageId, slug) for each localized slug.
  • On a junction table: relatedItemId, and a unique (itemId, position). On a repeatable table: a unique (itemId, position).

Generated names follow {table}_{columns}_idx, or _key for unique ones, such as example_categories_created_at_idx. Add your own with the indexes option. An index on the same columns as a generated one replaces it instead of adding a second one:

plugins/example/src/content/article.ts
indexes: [{ on: ["status", "createdAt"] }],

Change a content type later

Changing a definition is a schema change like any other. Build, migrate and read the generated SQL before you commit it.

  • Adding a nullable field, or one with a defaultValue, is safe on a table that already has rows.
  • Adding a required field without a default fails on a table with rows. Add it as nullable, fill it, then make it required in a second migration.
  • Renaming a field renames its column. Check that the generated SQL says RENAME COLUMN, not a drop followed by an add, or the old values are lost.
  • Turning on localization moves localized fields to a new translations table. The generated SQL does not copy existing values, so add the data move to the migration yourself.
  • Turning on visibility adds two nullable columns, so existing records stay visible.
  • Changing idStrategy on a table with data is not something the generated SQL can do. It needs a hand-written data migration that converts the key, every column that references it and the keys in the shared tables. See Change the strategy later.
  • Never edit or regenerate a migration that has already been applied. Write a new one instead.