Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
title: Advanced modeling limitations
description: What Stage 6 refuses, why each refusal is deliberate, and what to do instead.
icon: Ban
---

Every entry here is a definition-time error with a message that says what to do.
None of them is a runtime surprise, and none is silent.

## Localized repeatable fields

```ts
faq: field.repeatable({ localized: true, fields: { ... } }), // ✗
faq: field.repeatable({ fields: { q: field.text({ localized: true }) } }), // ✗
```

Repeatable fields are shared. Every language sees the same list.

A per-language list of *different lengths* has no defensible answer to the two
questions the feature would immediately raise: what does "restore the Polish
version" restore when Polish has four entries and English has six, and what does
"move entry 3 up" mean in a language that has two? The lists do not correspond,
so neither operation has a meaning to implement - and guessing one is worse than
saying no.

**Instead:** model the per-language part as a localized group, or as a second
content type with its own localization.

## Mixed localization inside one group

```ts
seo: field.group({
fields: {
title: field.text({ localized: true }), // ✗ leaves do not carry `localized`
indexable: field.boolean(),
},
}),
```

Localization is a property of the whole group. Half a logical value on each
table would mean two revision histories and two permissions for one box an
editor sees as one thing - somebody with `can_translate` could rewrite a leaf
only `can_edit` should touch.

**Instead:** two groups, which also makes which-is-which a fact about the
declaration:

```ts
seo: field.group({ localized: true, fields: { title, description } }),
syndication: field.group({ fields: { indexable, priority } }),
```

## Nested groups and repeatables

```ts
outer: field.group({ fields: { inner: field.group({ ... }) } }), // ✗
faq: field.repeatable({ fields: { steps: field.repeatable({ ... }) } }), // ✗
```

A leaf is a scalar. Nesting a group would need a second level of column naming
and of partial-update merging for nothing a second group beside the first does
not already give; a child table of a child table is a tree, with its own
ordering, cascade and restore semantics.

## Slugs, relations and users inside a group or repeatable

```ts
seo: field.group({ fields: { url: field.slug() } }), // ✗
meta: field.group({ fields: { owner: field.relation({ ... }) } }), // ✗
```

A slug is a URL segment with a uniqueness scope: inside a group that scope is the
row, inside a repeatable it is the parent - which is not a URL. One name, two
meanings.

A relation or `user` field inside a group would be a foreign key the relation
services do not look at, which is a foreign key nothing maintains.

**Instead:** declare the relation on the content type, as a to-one or a to-many.

## Indexes over collections

```ts
indexes: [{ on: ["faq.answer"] }] // ✗ repeatable leaf: a column on a child table
indexes: [{ on: ["categories"] }] // ✗ to-many relation: no column at all
indexes: [{ on: ["seo"] }] // ✗ a group is several columns; name a leaf
```

Refused loudly rather than dropped silently. `{ on: ["faq.answer"] }` looks like
it works, and an index that was quietly not created is a performance bug nobody
can see. Both generated tables already carry the indexes they need.

## Relation expansion in the Public API

An exposed to-many relation comes back as `number[]`. There is no
`publicApi.relations` block and no depth limit, because there is no expansion to
limit.

A related row belongs to another content type with its own allowlist, its own
permissions and its own publication state - it may be a draft, and it may expose
nothing publicly at all. Publishing its data because two records are related is
not a decision one content type's allowlist gets to make on another's behalf.

Deep nesting and arbitrary population are the point at which a REST projection
turns into GraphQL, and a hand-written route is the better answer.

## Relation query language

`{ contains: <id> }` and nothing else. No `containsAll`, no `containsAny`, no
`relation.relation.relation` traversal.

## Field-level permissions

A collection is part of the record's editable state, so `can_edit` governs it.
There is no `can_edit_categories`, and Stage 6 does not add one - field-level
permissions are a different feature with a different data model.

## Collections in an admin list

`admin.list.columns` accepts scalar columns only. A group is several columns; a
to-many relation and a repeatable are on other tables, and a list that loaded
them would issue a query per row.

They belong on the form - `admin.form.fields` accepts all three.

## Bounds

| | Default | Ceiling |
| --- | --- | --- |
| Repeatable children | 100 | 1000 |
| To-many relation targets | - | 500 |

Every write replaces the whole collection in one statement and every read loads
it whole. These are the shapes a person edits in one form; anything larger is a
content type.

## Child versions

Repeatable children have no version of their own. The source record is the
optimistic-lock boundary for every advanced field, because the record is what an
editor saves - and two version systems for one editable thing is two things to
keep in step and two ways to be wrong about which protects what.
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
---
title: Advanced modeling migrations
description: What Stage 6 generates, and how to move existing data onto it without a destructive step nobody reviewed.
icon: Database
---

Everything Stage 6 generates is an ordinary Drizzle table, discovered the way
every other one is - so migrations stay generated by `drizzle-kit` and committed
to the repository. Nothing creates or alters schema at runtime.

## Export the tables

Drizzle Kit finds a table from its export when it globs the built
`dist/src/database/*.js`. A junction or child table without one is simply
missing from the migration:

```ts
export const advancedArticleContent = createContentModel(
advancedArticleContentType,
{
references: {
// A to-many relation needs a reference thunk exactly as a to-one does.
categories: () => example_categories.id,
// A self-relation points at the table being declared. The thunk is what
// makes that legal - Drizzle leaves it unevaluated until serialization.
relatedArticles: () => advancedArticleContent.table.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;
```

Then:

```bash
pnpm build:plugins
cd apps/docs && npx drizzle-kit generate --name=add_advanced_articles
```

## What comes out

The committed
[`0031_add_example_advanced_articles.sql`](https://github.com/aXenDeveloper/vitnode)
is the reference. In outline:

```sql
-- A shared group, flattened onto the base table.
ALTER TABLE "example_advanced_articles"
ADD COLUMN "syndicationIndexable" boolean DEFAULT true NOT NULL,
ADD COLUMN "syndicationPriority" integer DEFAULT 5 NOT NULL;

-- A localized group, flattened onto the translation table.
ALTER TABLE "example_advanced_articles_translations"
ADD COLUMN "seoTitle" varchar(200),
ADD COLUMN "seoDescription" text;

-- A to-many relation.
CREATE TABLE "example_advanced_articles_categories" (
"itemId" integer NOT NULL,
"relatedItemId" integer NOT NULL,
"position" integer NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "example_advanced_articles_categories_pk"
PRIMARY KEY("itemId","relatedItemId")
);
CREATE UNIQUE INDEX "example_advanced_articles_categories_position_key"
ON "example_advanced_articles_categories" ("itemId","position");
CREATE INDEX "example_advanced_articles_categories_related_item_id_idx"
ON "example_advanced_articles_categories" ("relatedItemId");

-- A repeatable.
CREATE TABLE "example_advanced_articles_faq" (
"id" serial PRIMARY KEY NOT NULL,
"itemId" integer NOT NULL,
"position" integer NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"question" varchar(200) NOT NULL,
"answer" text NOT NULL
);
CREATE UNIQUE INDEX "example_advanced_articles_faq_position_key"
ON "example_advanced_articles_faq" ("itemId","position");
```

Names are deterministic: `<table>_<snake_case field>` for the table,
`_pk` / `_position_key` / `_related_item_id_idx` for the constraints, each
clamped to Postgres' 63-character limit with a fingerprint. Two fields that
would generate the same name are a definition-time error rather than two
migrations quietly sharing a table.

## Adding a group to an existing table

Additive and safe **when every leaf is nullable or defaulted**, which
`defineContentType` already requires of any group that may be omitted:

```sql
ALTER TABLE "example_articles"
ADD COLUMN "seoTitle" varchar(255),
ADD COLUMN "seoDescription" text;
```

Every existing row gets `NULL`, and a nullable group reads back as `null` -
which is the honest answer for a record written before the group existed.

### Regrouping fields you already have

Moving `seoTitle` and `seoDescription` from two top-level fields into a
`seo` group needs **no data migration at all** when the column names match:
`field.text({ ... })` named `seoTitle` and `seo.title` compile to the same
column. Check the generated migration is empty before you believe it, and rename
in SQL if it is not.

## To-one → to-many

Never one migration. The old column is a foreign key with a `NOT NULL` or a
`RESTRICT` on it, and dropping it is the step you cannot undo.

```sql
-- 1. The junction table, with nothing in it.
CREATE TABLE "example_articles_categories" ( ... );

-- 2. Copy, preserving the single existing reference at position 0.
INSERT INTO "example_articles_categories" ("itemId", "relatedItemId", "position")
SELECT "id", "category", 0 FROM "example_articles" WHERE "category" IS NOT NULL;

-- 3. Verify, in its own migration or by hand.
SELECT
(SELECT count(*) FROM "example_articles" WHERE "category" IS NOT NULL) AS before,
(SELECT count(*) FROM "example_articles_categories") AS after;
```

**Only then**, in a *later* migration, drop the column:

```sql
ALTER TABLE "example_articles" DROP COLUMN "category";
```

Two migrations rather than one, because between them somebody has to look at the
counts. An automated destructive step is a product decision made by a script.

## JSON array → repeatable

The same shape, and the same pause in the middle:

```sql
-- 1. The child table.
CREATE TABLE "example_articles_faq" ( ... );

-- 2. Backfill, preserving order. `WITH ORDINALITY` is what carries it.
INSERT INTO "example_articles_faq" ("itemId", "position", "question", "answer")
SELECT
a."id",
entry.ordinality - 1,
entry.value ->> 'question',
entry.value ->> 'answer'
FROM "example_articles" a,
jsonb_array_elements(a."faqJson") WITH ORDINALITY AS entry(value, ordinality)
WHERE a."faqJson" IS NOT NULL;

-- 3. Verify the counts match, per record and in total.
SELECT count(*) FROM "example_articles_faq";
SELECT sum(jsonb_array_length("faqJson")) FROM "example_articles";
```

Then, separately:

```sql
ALTER TABLE "example_articles" DROP COLUMN "faqJson";
```

<Callout type="warn" title="Verify before you drop">
Both migrations above are written so the destructive statement is in a different
file from the copy. That is the whole point: a backfill that silently dropped
three rows and then deleted its source is not something you can notice
afterwards.
</Callout>

## Regenerating an applied migration

Don't. Regenerating in place bumps the journal's `when`, and the migrator
replays the whole file - which fails on `relation already exists` at best. Add a
new migration instead.

## Testing against a real database

The example plugin replays every committed migration against a throwaway
Postgres and then asserts the constraints, the cascades and the concurrency:

```bash
DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \
pnpm --filter @vitnode/example test
```

The suite **wipes** the database it points at, and refuses to run unless the
name contains "test".
Loading
Loading