diff --git a/apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx b/apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx new file mode 100644 index 000000000..8a5e5e0c5 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx @@ -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: }` 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. diff --git a/apps/docs/content/docs/dev/content-engine/advanced-modeling-migrations.mdx b/apps/docs/content/docs/dev/content-engine/advanced-modeling-migrations.mdx new file mode 100644 index 000000000..f24cceee5 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/advanced-modeling-migrations.mdx @@ -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: `_` 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"; +``` + + +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. + + +## 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". diff --git a/apps/docs/content/docs/dev/content-engine/advanced-modeling-public-api.mdx b/apps/docs/content/docs/dev/content-engine/advanced-modeling-public-api.mdx new file mode 100644 index 000000000..9b913ed35 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/advanced-modeling-public-api.mdx @@ -0,0 +1,132 @@ +--- +title: Advanced modeling in the Public API +description: Leaf-level allowlisting, relations as identifiers, repeatables as arrays - and why nothing here ever expands into another content type's data. +icon: Globe +--- + +The public allowlist has no wildcard, and Stage 6 does not add one. What changes +is the *granularity*: a group and a repeatable are exposed one leaf at a time. + +```ts +publicApi: { + enabled: true, + path: "articles", + fields: [ + "title", + "slug", + "categories", // a to-many relation, whole + "seo.title", // one leaf of a group + "seo.description", // another + "faq.question", // one leaf of a repeatable + "faq.answer", + "publishedAt", + ], +}, +``` + +```json +{ + "title": "Hello", + "slug": "hello", + "categories": [2, 5, 9], + "seo": { "title": "Hello - Example", "description": "A short summary." }, + "faq": [{ "id": 11, "question": "What?", "answer": "This." }], + "publishedAt": "2026-08-08T10:00:00.000Z" +} +``` + +## Leaf-level privacy + +Naming a group whole is a **definition-time error**: + +```ts +fields: ["seo"] +// ✗ "A group is exposed one leaf at a time, so a leaf added later stays +// private until somebody says otherwise: list "seo.title", "seo.description" +// - or only the ones you mean." +``` + +That is the accident the rule exists to prevent. Exposing `seo` because you +wanted `seo.title` publishes `seo.indexable` too - and, worse, publishes +whatever leaf somebody adds to the group next year without anyone deciding it +should be public. + +A private leaf is absent from the response type **and** from the generated +`SELECT`, so it never leaves Postgres. + +## Relations are identifiers + +An exposed to-many relation comes back as `number[]`. Deliberately not the +related rows: + +- a category has its own public API, its own allowlist and its own publication + state; +- the row you would expand may itself be a draft; +- publishing another content type's data because two records are related is not + a decision *this* allowlist gets to make. + +An identifier is enough to fetch the related row through its own public API, +which is the layer that decides what it is willing to say. + + +There is no `publicApi.relations` block, and no depth limit to configure - +because there is no expansion to limit. 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 to that. A to-one relation still comes back as +`{ id }`, exactly as it did in Stage 5. + + +## Filtering by membership + +```ts +publicApi: { filterableFields: ["categories"] } +``` + +```text +GET /api/@vitnode/example/content/articles?categories=7 +``` + +Compiles to an indexed `EXISTS` over the junction table. A private relation can +never be filtered on - the allowlist check runs first, which is what stops a +filter being used to probe a field the response omits. + +A **repeatable leaf** cannot be filtered or ordered by, and cannot be a list +`searchableFields` entry. All three are refused at definition time rather than +silently dropped: they live on a child table, and "does any child match" is a +different question from equality. + +## Ordering + +`orderableFields` accepts a group leaf, because a leaf is a column: + +```ts +orderableFields: ["publishedAt", "syndication.priority"], +``` + +It refuses a to-many relation and a repeatable leaf, because a list cannot be +ordered by a set. It also refuses a leaf of a *localized* group, for the same +reason it refuses any localized field: a list ordered by one would reshuffle +itself per language, and a cursor would mean two different positions across a +fallback. + +## Localization + +A localized group behaves exactly like a localized scalar. The public read joins +the translation it is serving, so `seo.title` comes back in the language the +response says it is in: + +```json +{ "locale": "pl", "title": "Witaj", "seo": { "title": "Witaj - Przykład" } } +``` + +With `fallback: "default"`, a locale with no translation of its own is served +the default one and says so in `locale`. A **collection** is shared, so it is the +same in every language - which is also why loading it costs one query per page +rather than one per locale. + +## Performance + +Collections are loaded only when the allowlist names one, and then in **one +batch per collection field per page** - keyed by the identifiers the page has +already fetched. A public list never issues a query per row, and a content type +that exposes no collection joins no junction and no child table at all. diff --git a/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx b/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx new file mode 100644 index 000000000..6613dacb8 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx @@ -0,0 +1,165 @@ +--- +title: Advanced modeling +description: To-many relations, structured groups and repeatable fields - three shapes that turn a flat record into a real domain model, without a single JSONB column. +icon: Boxes +--- + +Stages 1-5 gave you a record: scalar fields, a draft/published lifecycle, a +history, and a copy per language. Stage 6 gives you a *shape*. + +Three declarations, and everything else in this section is a consequence of one +of them: + +```ts +fields: { + // Many targets instead of one. + categories: field.relation({ multiple: true, target: () => categoryContentType }), + + // Several related leaves under one name. + seo: field.group({ + fields: { + title: field.text({ nullable: true }), + description: field.textarea({ nullable: true }), + }, + }), + + // Zero or more ordered child rows. + faq: field.repeatable({ + fields: { + question: field.text({ required: true }), + answer: field.textarea({ required: true }), + }, + }), +} +``` + +## What you get + +```ts +const article = await service.findDetail(7); + +article.title; // "Hello" +article.seo.title; // "Hello - Example" ← nested, always +article.categories; // [2, 5, 9] ← identifiers +article.faq[0].id; // 11 ← stable identity +article.faq[0].question; // "What is this?" +``` + +And in the database, real relational storage: + +```text +example_articles seoTitle, seoDescription ← flattened columns +example_articles_categories itemId, relatedItemId, position +example_articles_faq id, itemId, position, question, answer +``` + +No JSONB. No comma-separated identifier list. No property/value table. Every +value is a column you can index, constrain, join and query, and every migration +is generated by `drizzle-kit` and committed like any other. + + +A content type that declares none of these three resolves to empty arrays +everywhere and behaves *exactly* as it did in Stage 5 - same columns, same +queries, same cache tags, same search document ids, same route contracts. The +regression suite asserts it. + + +## One vocabulary: canonical paths + +A group has two representations, and only one of them is yours: + +| | | +| --- | --- | +| **`seo.title`** | The canonical path. What you write, and what the engine says back | +| `seoTitle` | The generated column. An internal mapping | + +The path is what appears in `changedFields`, in validation errors, in +`indexes`, in `publicApi.fields`, in `search.contentFields` and in revision +diagnostics. The column name appears in the migration and nowhere else. + +```ts +const result = await service.update(7, { seo: { description: "New" } }); + +result.changedFields; // ["seo.description"] ← never ["seo"], never ["seoDescription"] +``` + +That precision is load-bearing rather than tidy: the cache, the search +synchronizer and the event payload all decide what to do from these strings, and +`seo` alone would not tell them whether the public description moved. + +## The three shapes at a glance + +| | Stored in | Value | In a list row? | Ordered? | +| --- | --- | --- | --- | --- | +| **Group** | Flattened columns on the row | Nested object, or `null` | Yes | n/a | +| **To-many relation** | Junction table | `number[]` | No | Optional | +| **Repeatable** | Child table | Array of identified rows | No | Always | + +A group is a column, so it comes back with every read. The other two are extra +queries, so they come back only when you ask - `service.findDetail(id)`, or a +public projection that names them. That is why an admin list of 25 rows does not +turn into 50 round trips. + +## Writing them + +Everything goes through the ordinary `update`, which is what makes a category +swap and a title change the same kind of edit: + +```ts +await editorialService.update( + 7, + { + title: "Hello", + seo: { description: "New description" }, // one leaf, not the whole group + categories: [2, 5, 9], // the whole set + faq: [ + { id: 11, question: "Kept?", answer: "Yes" }, // existing child, updated + { question: "New?", answer: "Also yes" }, // no id → created + ], + }, + { actor, expectedVersion: 4 }, +); +``` + +One request, one version guard, one revision, one event. + +There are typed shortcuts too, on both services: + +```ts +// Merges under a row lock. No version guard, no revision, no event. +await model.service(c).relations.categories.add(7, 12); + +// Arbitrates. Requires the version, writes the revision, emits the event. +await model.editorialService?.(c, { pluginId }).repeatable.faq.reorder( + 7, + [15, 11], + { actor, expectedVersion: 4 }, +); +``` + +Every one of them locks the source row **before** reading the collection it is +about to modify, and then goes through the same `update` above - so none of them +can lose a concurrent write, and none can drift from the rules that call follows. + +## Where to go next + + + + To-many, ordered, self-referential, and what `onDelete` actually does. + + + Groups, nullability, localization, and the one leaf-path mapping. + + + Child tables, stable identity, ordering, and the atomic form save. + + + Leaf-level allowlisting, relation identifiers, and the membership filter. + + + What is generated, and how to move existing data onto it. + + + What Stage 6 refuses, and why each refusal is deliberate. + + diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx index 19cfa3206..bf5be419f 100644 --- a/apps/docs/content/docs/dev/content-engine/fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -1,6 +1,6 @@ --- title: Supported fields -description: The nine field kinds, and exactly what each one becomes in Postgres, in the API and in the AdminCP. +description: The eleven field kinds, and exactly what each one becomes in Postgres, in the API and in the AdminCP. icon: ListChecks --- @@ -18,6 +18,14 @@ things: a column, a Zod rule, an AdminCP input and a table cell. | `dateTime` | `timestamp` | ISO string | `AutoFormDateTime` | ✓ | ✗ | ✗ | | `user` | `integer` → `core_users.id` | `number` | async combobox | ✓ | ✓ | ✗ | | `relation` | `integer` → target `id` | `number` | async combobox | ✓ | ✓ | ✗ | +| `relation` (`multiple`) | junction table | `number[]` | multi picker | ✗ | membership | ✗ | +| `group` | one column per leaf | nested object | labelled section | per leaf | per leaf | per leaf | +| `repeatable` | child table | array of rows | list editor | ✗ | ✗ | opt-in | + +The last three are [Advanced +modeling](/docs/dev/content-engine/advanced-modeling), and every one of them is +opt-in: a content type that declares none behaves exactly as it did before they +existed. "Sortable" means the column *can* be allowlisted in `admin.list.orderableFields` - nothing is orderable until you list it. System columns always are. @@ -212,10 +220,35 @@ that reference each other still load fine. row. `defineContentType` rejects the combination up front instead. +## group and repeatable + +Two structured kinds, each with a page of its own: + +```ts +// Several leaves under one name, flattened into real columns. +seo: field.group({ + fields: { + title: field.text({ nullable: true }), + description: field.textarea({ nullable: true }), + }, +}), + +// Zero or more ordered child rows, in a generated child table. +faq: field.repeatable({ + fields: { + question: field.text({ required: true }), + answer: field.textarea({ required: true }), + }, +}), +``` + +See [Structured fields](/docs/dev/content-engine/structured-fields) and +[Repeatable fields](/docs/dev/content-engine/repeatable-fields). + ## localized -`text`, `textarea` and `slug` also accept `localized: true`, which moves the value -into a generated per-language table instead of onto the base table: +`text`, `textarea`, `slug` and `group` also accept `localized: true`, which moves +the value into a generated per-language table instead of onto the base table: ```ts title: field.text({ localized: true, required: true }), @@ -223,8 +256,8 @@ title: field.text({ localized: true, required: true }), It needs `localization: { enabled: true, defaultLocale }` on the content type, and the other builders do not take the argument at all - so a localized `boolean` is a -compile error. See [Localized -fields](/docs/dev/content-engine/localized-fields). +compile error. A `group` moves **whole**, and a `repeatable` never moves at all. +See [Localized fields](/docs/dev/content-engine/localized-fields). ## Adding a field kind later diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index eccb55f5f..604d40ee6 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -29,6 +29,13 @@ "localized-public-api", "localized-search", "localization-migrations", + "advanced-modeling", + "relations", + "structured-fields", + "repeatable-fields", + "advanced-modeling-public-api", + "advanced-modeling-migrations", + "advanced-modeling-limitations", "admincp", "permissions", "events", diff --git a/apps/docs/content/docs/dev/content-engine/relations.mdx b/apps/docs/content/docs/dev/content-engine/relations.mdx new file mode 100644 index 000000000..d079f6a11 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/relations.mdx @@ -0,0 +1,231 @@ +--- +title: Relations +description: One target or many, in the author's order or the database's, pointing at another content type or at this one - and what happens when the target is deleted. +icon: Link +--- + +A `relation` field points at rows of another content type. Stage 1 gave you one +target; Stage 6 gives you a set, an order, and a way to point at yourself. + +## To-one + +Unchanged, and still a foreign-key column on the row: + +```ts +category: field.relation({ + required: true, + onDelete: "restrict", + target: () => categoryContentType, +}), +``` + +```text +example_articles.category integer NOT NULL REFERENCES example_categories(id) ON DELETE RESTRICT +``` + +`target` is a thunk so two content types can point at each other without a +circular import. + +## To-many + +```ts +categories: field.relation({ + multiple: true, + target: () => categoryContentType, +}), +``` + +The value is `number[]`, and it lives in a generated junction table rather than +in a column - because a column cannot hold a set: + +```text +example_articles_categories + itemId integer NOT NULL → example_articles.id ON DELETE CASCADE + relatedItemId integer NOT NULL → example_categories.id ON DELETE RESTRICT + position integer NOT NULL + createdAt timestamp NOT NULL DEFAULT now() + + PRIMARY KEY (itemId, relatedItemId) + UNIQUE (itemId, position) + INDEX (relatedItemId) +``` + +The primary key is why one target cannot be related twice: it is a constraint +rather than a check somebody has to remember. `itemId` always cascades, because +the references *belong to* the record - deleting it takes them along instead of +leaving rows pointing at nothing. + +A to-many relation is never `required` and never `nullable`. The empty set is +what "no targets" looks like, and `defineContentType` refuses both arguments. + +## Ordered + +```ts +relatedArticles: field.relation({ + multiple: true, + ordered: true, + self: true, +}), +``` + +`references` takes no entry for it - the engine resolves the foreign key from the +table it is building: + +```ts +export const articleContent = createContentModel(articleContentType, { + references: { categories: () => example_categories.id }, + // `relatedArticles` is absent, and must be: `() => articleContent.table.id` + // names the model inside its own initializer, which widens it to `any`. +}); +``` + +`ordered: true` keeps the order you wrote: + +```ts +await service.relations.relatedArticles.set(7, [12, 2, 9]); +await service.relations.relatedArticles.get(7); // [12, 2, 9] +``` + +Without it the set is stored in ascending target-id order. That is still +deterministic - it is simply not something anybody chose - and it is why +`set([9, 2])` and `set([2, 9])` are the same state rather than two writes that +differ in a column nobody declared. + +`UNIQUE (itemId, position)` is what makes the order a fact. A reorder never +violates it, even for an instant: every surviving row is first parked at a +negative slot, then one final statement maps the whole set back to `0..n-1`. + +## Self-relations + +Use `self: true`, **not** `target: () => thisContentType`: + +```ts +export const articleContentType = defineContentType({ + fields: { + related: field.relation({ multiple: true, ordered: true, self: true }), + }, + // ... +}); +``` + + +A definition whose own field map mentions its own inferred type is circular, and +TypeScript resolves that by widening the whole definition to `any` - **silently**. +Every nested value type, every leaf-path allowlist check and every compile-time +guarantee in this section would disappear with no error to say so. `self: true` +carries no reference at all, and `defineContentType` rebinds the thunk once the +definition exists. + + +Everything else about a self-relation is ordinary. The junction table's two +foreign keys point at the same table, the position rules are the same, and the +generated names cannot collide with another field's. + +## Deletion semantics + +`onDelete` describes what happens to *this* reference when the **target** row is +deleted. Postgres enforces it, so a direct `DELETE` obeys it too - which a check +in service code would not. + +| | To-one | To-many | +| --- | --- | --- | +| `"restrict"` | The delete is refused while the reference exists | Same | +| `"cascade"` | The referencing **record** is deleted | The junction **row** is deleted | +| `"set null"` | The column becomes `NULL`. Needs `nullable: true` | **Rejected** | + +`"set null"` is refused on a to-many relation because a junction row has no +nullable column to set. The honest analogue of "forget this reference" is to +delete the row, which is `"cascade"`. + +```ts +// Postgres refuses to delete a category that is still in use. +categories: field.relation({ + multiple: true, + onDelete: "restrict", + target: () => categoryContentType, +}), +``` + +## Reading and writing + +There are **two** collection APIs, and the difference is deliberate. + +### `service.relations` - merges + +```ts +const service = model.service(c); + +await service.relations.categories.get(7); // [2, 5, 9] +await service.relations.categories.set(7, [2, 5]); +await service.relations.categories.add(7, 12); +await service.relations.categories.remove(7, 5); +await service.relations.categories.reorder(7, [9, 2]); +``` + +No `expectedVersion`, because this service has no version column to guard on and +would have had to ignore one. Concurrency is handled by a **row lock**: each +mutation opens a transaction, takes `SELECT ... FOR UPDATE` on the source record, +*then* reads the collection, computes the next state and writes it. + +That ordering is the whole guarantee. Two concurrent `add` calls serialise on the +row and both survive: + +```text +A: add(1) ─ lock ─ read [] ─ write [1] ─ commit +B: add(2) ─────── wait ──── lock ─ read [1] ─ write [1,2] ─ commit +``` + +No revision and no event - the plain service has never written either, for a +field edit or a collection one. + +### `editorialService.relations` - arbitrates + +```ts +const editorial = model.editorialService?.(c, { pluginId }); + +await editorial.relations.categories.add(7, 12, { + actor, + expectedVersion: 4, +}); +``` + +`actor` and `expectedVersion` are **required** - a collection mutation is an edit +of the record, and an edit that could not lose a race would be the only one in +the engine that cannot. Same lock, plus the version guard, so two writers holding +version 4 produce exactly one winner and one `ContentVersionConflict`. + +One real mutation is one version increment, one revision, one event, one cache +invalidation. + +### Rules both share + +- **No-op detection.** A `set` to the current set, an `add` of a target already + there, a `reorder` to the current order - none of them bumps the version, + writes a revision, emits an event or touches the cache. It falls out of + computing the whole next state rather than issuing a targeted `INSERT`. +- **Validation.** A target that does not exist is a + `ContentAdvancedInputError` naming the identifiers, checked before anything is + written. A duplicate in the payload is a schema error rather than something + silently deduplicated. +- **`reorder` must be a permutation.** Checked *inside* the lock, against the + list the write is about to replace. A list that added or dropped a target is + refused, because that is a `set` wearing a different name and the caller would + never find out which it got. + +## Filtering + +```ts +await publicService.findMany({ filters: { categories: { contains: 7 } } }); +``` + +Compiles to an indexed `EXISTS` over the junction table, correlated by `itemId`, +so a record matches once however many junction rows it has - and the outer query +needs no `DISTINCT`. Over HTTP it is the flat form you would expect: + +```text +GET /api/@vitnode/example/content/articles?categories=7 +``` + +There is deliberately no `containsAll`, no `containsAny` and no +`relation.relation.relation` traversal. That is a query language, and a +hand-written route is the better answer to it. diff --git a/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx b/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx new file mode 100644 index 000000000..c792d90cb --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx @@ -0,0 +1,183 @@ +--- +title: Repeatable fields +description: Zero or more ordered child rows with identities of their own - stored in a generated child table, saved in one request, and restorable row by row. +icon: List +--- + +A repeatable field is a list of structured rows: FAQ entries, specification +lines, gallery captions. + +```ts +faq: field.repeatable({ + max: 20, + fields: { + question: field.text({ required: true }), + answer: field.textarea({ required: true }), + }, +}), +``` + +```ts +row.faq; // [{ id: 11, question: "What?", answer: "This." }, ...] +``` + +## The child table + +```text +example_articles_faq + id serial PRIMARY KEY + itemId integer NOT NULL → example_articles.id ON DELETE CASCADE + position integer NOT NULL + createdAt timestamp NOT NULL DEFAULT now() + updatedAt timestamp NOT NULL DEFAULT now() + question varchar(255) NOT NULL + answer text NOT NULL + + UNIQUE (itemId, position) +``` + +Real columns with their declared types and their declared nullability. Not a +JSONB array: `answer` is `text NOT NULL` because you said `required: true`, and +Postgres is what enforces it. + +## Identity is not position + +`id` is a `serial` of its own, deliberately **not** `(itemId, position)`. + +Position is where a child currently sits. Identity is what an edit addresses and +what a revision restore matches an historical row against. Conflating them would +make "update the third FAQ entry" mean a different row after every reorder, and +would make a restore recreate rows instead of putting values back. + +That is also the whole write protocol: + +```ts +faq: [ + { id: 11, question: "Kept", answer: "Updated" }, // existing child, updated in place + { question: "New", answer: "Created" }, // no id → created +] +// anything absent → removed +``` + +## Ordering + +The array order *is* the order. Positions are contiguous from zero, and +`UNIQUE (itemId, position)` makes a duplicate slot impossible rather than merely +unlikely. + +A reorder never violates that constraint, even for an instant. Moving row A from +slot 0 to slot 1 while row B still sits in slot 1 would break it *during* the +statement, so the writer works in two passes: every surviving row is parked at a +negative slot, then one final `UPDATE` maps the whole set back to `0..n-1` at +once. No deferrable constraint, no delete-and-recreate, and identity survives. + +## Saving + +One call, not one per row: + +```ts +await model.editorialService?.(c, { pluginId }).repeatable.faq.set( + 7, + [ + { id: 11, question: "Kept?", answer: "Yes" }, + { question: "New?", answer: "Also yes" }, + ], + { actor, expectedVersion: 4 }, +); +``` + +An AdminCP form save is one HTTP request with one `expectedVersion` - not five +mutations racing each other's version. The granular operations exist too: + +```ts +await service.repeatable.faq.list(7); +await service.repeatable.faq.create(7, { question: "…", answer: "…" }); +await service.repeatable.faq.update(7, 11, { answer: "…" }); +await service.repeatable.faq.delete(7, 11); +await service.repeatable.faq.reorder(7, [15, 11]); +``` + +Each is a read-modify-write that locks the source row **before** it reads, so two +concurrent `create` calls both survive rather than one overwriting the other. The +values are typed from the repeatable's own leaves, so `{ unknownField }` is a +compile error. + +As with relations there are two APIs: +`service.repeatable` merges under a row lock and writes no revision; +`editorialService.repeatable` additionally requires `{ actor, expectedVersion }` +and gives you the version bump, the revision and the event. See +[Relations](/docs/dev/content-engine/relations#reading-and-writing). + +`reorder` refuses a list that is not a permutation of what is stored: a reorder +that silently dropped an entry would look like a successful drag. + +## Versioning + +**The source record's version is the only lock.** A real change to `faq` bumps +it, writes one revision, emits one `content.*.updated` event with +`changedFields: ["faq"]`, and invalidates the cache once. + +Children carry no version of their own. Two version systems for one editable +thing is two things to keep in step and two ways to be wrong about which +protects what; the record is the thing an editor saves, so the record is the +thing that locks. + +### What counts as no change + +None of these bumps anything: + +- the same values, in the same order, under the same identities; +- a reorder to the order that is already stored; +- `set` with the list `list` just returned. + +A no-op is still a *successful* write - an editor who pressed save twice has not +created two versions of anything. + +## Revisions and restore + +A snapshot records the logical rows, identities included: + +```json +{ "faq": [{ "id": 11, "question": "What?", "answer": "This." }] } +``` + +A restore is all-or-nothing: + +- a child that still exists is **updated in place** and keeps its `id`; +- a child that was deleted since is **recreated** - all its values are in the + snapshot, so nothing is lost but the original identifier, and refusing instead + would mean a record could never be restored past a delete; +- a child absent from the restored state is **removed**; +- order is restored; +- the source version moves once, and one restore revision is written. + +## Localization + +Repeatable fields are **shared** in Stage 6. `field.repeatable({ localized: true })` +and a `localized: true` leaf inside one are both definition-time errors. + +A per-language list of *different lengths* has no defensible answer to "restore +the Polish version" or "move entry 3 up" - the lists do not correspond. Guessing +one and shipping it would be worse than saying no. See +[Limitations](/docs/dev/content-engine/advanced-modeling-limitations). + +## AdminCP + +Add, Edit, Remove, Move up, Move down - as labelled buttons. + +Drag-and-drop can be layered on later, but it can never be the *only* way to +reorder: a keyboard user and a screen-reader user both need a control they can +reach and a name that says what it does, and "drag the third item above the +second" is neither. Every button carries its position in its accessible name +("Move entry 2 up"), and the ones that would do nothing are disabled. + +React keys come from a client-side key that is stable for a row's whole life in +the editor, never from `id`: an unsaved row has no `id`, and keying off it would +make React reuse one row's DOM state for another row's value. + +## Bounds + +`max` defaults to 100 and is capped at 1000. A ceiling exists because every write +replaces the whole list in one statement and every read loads it whole - a +repeatable is a handful of rows a person edits in one form. Anything larger is a +content type. diff --git a/apps/docs/content/docs/dev/content-engine/structured-fields.mdx b/apps/docs/content/docs/dev/content-engine/structured-fields.mdx new file mode 100644 index 000000000..d06a2cba5 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/structured-fields.mdx @@ -0,0 +1,185 @@ +--- +title: Structured fields +description: Reusable groups of related leaves that stay nested in your code and stay relational in your database - with four nullability states that are each a different thing. +icon: Group +--- + +A group bundles related leaves under one name. The value stays nested; the +storage stays relational. + +```ts +const seoGroup = field.group({ + fields: { + title: field.text({ nullable: true }), + description: field.textarea({ nullable: true }), + indexable: field.boolean({ defaultValue: true }), + }, +}); + +// Reusable across as many content types as you like: +fields: { + title: field.text({ required: true }), + seo: seoGroup, +} +``` + +```ts +row.seo.title; // string | null +row.seo.indexable; // boolean +``` + +```text +example_articles + seoTitle varchar(255) + seoDescription text + seoIndexable boolean NOT NULL DEFAULT true +``` + +Three ordinary columns. Indexable, constrainable, queryable, and visible in a +migration you can read. + +## Leaves + +A leaf is a scalar: `text`, `textarea`, `number`, `boolean`, `enum`, `dateTime`. +The four exclusions are decisions rather than gaps: + +| Refused | Why | +| --- | --- | +| `group` | Nesting 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 | +| `repeatable` | A child table of a child table is a tree, with its own ordering, cascade and restore semantics | +| `slug` | 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 | +| `relation` / `user` | A foreign key the relation services do not look at is a foreign key nothing maintains. Model it as a to-many relation instead | + +## Canonical paths + +```ts +indexes: [{ on: ["seo.title"] }, { unique: true, on: ["seo.code"] }], +publicApi: { fields: ["seo.title", "seo.description"] }, +search: { contentFields: ["seo.description"] }, +``` + +All three compile against the generated columns. You never write `seoTitle`, and +the engine never says it back to you - the mapping lives in exactly one place +(`contentLeafColumnName`) and every subsystem asks that one place for it. + +An index over a path the engine cannot build is a **definition-time error**, not +a silent no-op: + +```ts +indexes: [{ on: ["faq.answer"] }] // ✗ repeatable leaves are on a child table +indexes: [{ on: ["categories"] }] // ✗ a to-many relation has no column +indexes: [{ on: ["seo"] }] // ✗ a group is several columns; name a leaf +``` + +An index that was quietly not created is a performance bug nobody can see. + +## Partial updates + +An update names the leaves it moves, and leaves the rest exactly where they are: + +```ts +await service.update(7, { seo: { description: "New" } }); +// → UPDATE ... SET "seoDescription" = $1 ← "seoTitle" is not in the statement +// → changedFields: ["seo.description"] +``` + +This is what lets two editors change different leaves of the same group without +overwriting each other's work. `{ seo: {} }` is refused rather than treated as a +successful write that did nothing. + +## Nullability: four states, four meanings + +| State | How you write it | What is stored | +| --- | --- | --- | +| **Absent** | omit `seo` from a create payload | each leaf takes its own default | +| **Null** | `seo: null` | `NULL` in every leaf column | +| **Present, leaf missing** | `seo: {}` with a `required: true` leaf | rejected, error keyed `seo.title` | +| **Leaf null** | `seo: { title: null }` | `NULL` in that one column | + +Two rules make those four distinguishable rather than ambiguous, and both are +checked at definition time: + +1. **A `nullable: true` group needs every leaf nullable.** `seo: null` has to be + able to blank every leaf, and it cannot do that to a `NOT NULL` column. A + group reads back as `null` when - and only when - every leaf column is `NULL`, + which is the exact inverse of what writing `null` does. + +2. **A group that may be omitted needs every leaf writable without input** - + nullable, or defaulted. A `required: true` non-nullable leaf inside an + optional group describes a row that can never be inserted, so it is refused + with a message telling you to add `required: true` to the group. + +```ts +// ✗ "Group "seo" is `nullable: true`, so setting it to null has to blank every +// leaf - but "seo.title" is not nullable." +seo: field.group({ nullable: true, fields: { title: field.text({ required: true }) } }), + +// ✓ Either make the leaf nullable... +seo: field.group({ nullable: true, fields: { title: field.text({ nullable: true }) } }), + +// ✓ ...or make the group required. +seo: field.group({ required: true, fields: { title: field.text({ required: true }) } }), +``` + +## Localization + +Localization is a property of the **group**, never of a leaf: + +```ts +seo: field.group({ + localized: true, + nullable: true, + fields: { + title: field.text({ nullable: true }), + description: field.textarea({ nullable: true }), + }, +}), +``` + +The whole group moves to the translation table - `seoTitle` and `seoDescription` +become columns there, one row per language - and the value stays nested in every +language. + +A `localized: true` leaf inside a group is a definition-time error. 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. + + +If some of the values are per-language and some are not, that is two groups: + +```ts +seo: field.group({ localized: true, fields: { title, description } }), +syndication: field.group({ fields: { indexable, priority } }), +``` + +Which is which is then a fact about the declaration rather than something a +reader has to work out leaf by leaf. + + +## Revisions + +A snapshot records the **logical** shape: + +```json +{ "seo": { "title": "Hello", "description": null } } +``` + +Never `seoTitle`. The column names are an internal mapping, and a mapping is +allowed to change; a history that recorded it would be invalidated by a rename +that changed nothing anybody wrote. + +A restore projects the historical shape through the current schema. A leaf the +group has since dropped is ignored - the past is allowed to mention things that +no longer exist - and a leaf added since is simply absent, so the record keeps +what it has. + +## AdminCP + +A group renders as a `fieldset` with a `legend`, so a screen reader announces +"SEO" alongside every leaf inside it. That is the difference between "Title" +appearing twice on a form and `SEO / Title` and `Article / Title` being told +apart. + +A nullable group gets a switch: turning it off stores `null`, which is a +different state from clearing each box by hand. diff --git a/apps/docs/migrations/0031_add_example_advanced_articles.sql b/apps/docs/migrations/0031_add_example_advanced_articles.sql new file mode 100644 index 000000000..a337dadff --- /dev/null +++ b/apps/docs/migrations/0031_add_example_advanced_articles.sql @@ -0,0 +1,75 @@ +CREATE TABLE "example_advanced_articles" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "syndicationIndexable" boolean DEFAULT true NOT NULL, + "syndicationPriority" integer DEFAULT 5 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "example_advanced_articles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +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") +); +--> statement-breakpoint +ALTER TABLE "example_advanced_articles_categories" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +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 +); +--> statement-breakpoint +ALTER TABLE "example_advanced_articles_faq" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_advanced_articles_related_articles" ( + "itemId" integer NOT NULL, + "relatedItemId" integer NOT NULL, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "example_advanced_articles_related_articles_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "example_advanced_articles_related_articles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_advanced_articles_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "seoTitle" varchar(200), + "seoDescription" text, + CONSTRAINT "example_advanced_articles_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "example_advanced_articles_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_categories" ADD CONSTRAINT "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."example_advanced_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_categories" ADD CONSTRAINT "example_advanced_articles_categories_relatedItemId_example_categories_id_fk" FOREIGN KEY ("relatedItemId") REFERENCES "public"."example_categories"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_faq" ADD CONSTRAINT "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."example_advanced_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_related_articles" ADD CONSTRAINT "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."example_advanced_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_related_articles" ADD CONSTRAINT "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk" FOREIGN KEY ("relatedItemId") REFERENCES "public"."example_advanced_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_translations" ADD CONSTRAINT "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."example_advanced_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_translations" ADD CONSTRAINT "example_advanced_articles_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "example_advanced_articles_syndication_priority_idx" ON "example_advanced_articles" USING btree ("syndicationPriority");--> statement-breakpoint +CREATE INDEX "example_advanced_articles_created_at_idx" ON "example_advanced_articles" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "example_advanced_articles_updated_at_idx" ON "example_advanced_articles" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "example_advanced_articles_status_published_at_idx" ON "example_advanced_articles" USING btree ("status","publishedAt");--> statement-breakpoint +CREATE UNIQUE INDEX "example_advanced_articles_categories_position_key" ON "example_advanced_articles_categories" USING btree ("itemId","position");--> statement-breakpoint +CREATE INDEX "example_advanced_articles_categories_related_item_id_idx" ON "example_advanced_articles_categories" USING btree ("relatedItemId");--> statement-breakpoint +CREATE UNIQUE INDEX "example_advanced_articles_faq_position_key" ON "example_advanced_articles_faq" USING btree ("itemId","position");--> statement-breakpoint +CREATE UNIQUE INDEX "example_advanced_articles_related_articles_position_key" ON "example_advanced_articles_related_articles" USING btree ("itemId","position");--> statement-breakpoint +CREATE INDEX "example_advanced_articles_related_articles_related_item_id_idx" ON "example_advanced_articles_related_articles" USING btree ("relatedItemId");--> statement-breakpoint +CREATE INDEX "example_advanced_articles_translations_language_id_status_idx" ON "example_advanced_articles_translations" USING btree ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "example_advanced_articles_translations_language_id_slug_key" ON "example_advanced_articles_translations" USING btree ("languageId","slug"); \ No newline at end of file diff --git a/apps/docs/migrations/meta/0031_snapshot.json b/apps/docs/migrations/meta/0031_snapshot.json new file mode 100644 index 000000000..d44f07de2 --- /dev/null +++ b/apps/docs/migrations/meta/0031_snapshot.json @@ -0,0 +1,3868 @@ +{ + "id": "c3a84fce-ca99-43a8-8b83-a8be82faeed9", + "prevId": "5bc8c627-98d3-4dc7-992e-3958232a8780", + "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_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "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()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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_advanced_articles": { + "name": "example_advanced_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()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "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()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "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 + }, + "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_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "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 + }, + "public.example_localized_articles": { + "name": "example_localized_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()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "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 43bca7082..230eb9f15 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1786044755458, "tag": "0030_add_translation_editorial", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1786181800826, + "tag": "0031_add_example_advanced_articles", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/elasticsearch/src/index.test.ts b/packages/elasticsearch/src/index.test.ts index 6c4f426a2..864f74194 100644 --- a/packages/elasticsearch/src/index.test.ts +++ b/packages/elasticsearch/src/index.test.ts @@ -278,6 +278,37 @@ describe("ElasticsearchSearchAdapter.delete", () => { { ignore: [404] }, ); }); + + /** + * The behaviour `capabilities.languageScopedDelete` promises, asserted rather + * than trusted: an install pairing a localized searchable content type with a + * provider that dropped this argument would take every language out of the + * index on a single translation's unpublish, with no error to notice. + */ + it("narrows the query to one language when given a locale", async () => { + await ElasticsearchSearchAdapter(config).delete(c, "blog_post", 1, "pl"); + + expect(deleteByQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: { + bool: { + filter: [ + { term: { itemType: "blog_post" } }, + { term: { itemId: 1 } }, + { term: { languageCode: "pl" } }, + ], + }, + }, + }), + { ignore: [404] }, + ); + }); + + it("declares the capability its delete actually honours", () => { + expect( + ElasticsearchSearchAdapter(config).capabilities?.languageScopedDelete, + ).toBe(true); + }); }); describe("ElasticsearchSearchAdapter.clear", () => { diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index 63ca6d573..4f3fd53d4 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -6,7 +6,9 @@ import type { ContentFieldKind, } from "../types"; +import { contentRepeatableMax, contentRepeatableMin } from "../advanced"; import { partitionContentFields } from "../localization"; +import { contentFieldPath, contentInnerFields } from "../paths"; /** * A single form field, reduced to plain JSON. @@ -21,17 +23,33 @@ export interface ContentFormFieldSpec { defaultValue?: boolean | null | number | string; description?: string; display?: "radio" | "select"; + /** + * The leaves of a `group` or a `repeatable`, in declaration order. + * + * Recursive in the type and one level deep in practice: a leaf is always a + * scalar, which is what lets a group render as a section of ordinary inputs + * and a repeatable row render as the same section repeated. + */ + fields?: ContentFormFieldSpec[]; integer?: boolean; kind: ContentFieldKind; label: string; max?: number; + /** Upper bound on a repeatable's rows. */ + maxItems?: number; maxLength?: number; min?: number; + /** Lower bound on a repeatable's rows. */ + minItems?: number; minLength?: number; + /** A relation that holds many targets rather than one. */ + multiple?: boolean; name: string; nullable: boolean; /** Enum choices, already translated. */ options?: { label: string; value: string }[]; + /** Whether an ordered relation's order is the author's to choose. */ + ordered?: boolean; required: boolean; } @@ -104,6 +122,30 @@ const projectFormField = ( value, })), }; + case "group": + case "repeatable": + return { + ...base, + fields: Object.entries(contentInnerFields(fieldValue)).map( + ([leaf, leafValue]) => + projectFormField( + leaf, + leafValue, + labelEnum, + // A leaf's label is looked up under its canonical path, so an + // override for `seo.title` is possible without one for every + // group that happens to have a `title`. + (leafName, descriptor) => + labelField(contentFieldPath(name, leafName), descriptor), + ), + ), + ...(fieldValue.kind === "repeatable" + ? { + maxItems: contentRepeatableMax(fieldValue), + minItems: contentRepeatableMin(fieldValue), + } + : {}), + }; case "number": return { ...base, @@ -112,6 +154,12 @@ const projectFormField = ( max: fieldValue.max, min: fieldValue.min, }; + case "relation": + return { + ...base, + multiple: fieldValue.multiple, + ordered: fieldValue.ordered, + }; case "slug": // No default and no minimum: an empty slug input means "derive it", // and the server is what decides whether that is possible. @@ -237,6 +285,37 @@ export type ContentReferenceOption = z.infer; export const isReferenceKind = (kind: ContentFieldKind): boolean => kind === "relation" || kind === "user"; +/** + * One group's or repeatable row's leaves, as a nested object schema. + * + * Nested rather than flattened into `seo.title` keys: react-hook-form would + * happily accept the dotted names, but then the value the form holds and the + * value the API takes would be two different shapes, and the conversion would + * have to live somewhere. One shape, all the way through. + */ +const leafObjectSchema = ( + spec: ContentFormFieldSpec, + values?: Record, +): z.ZodObject => + z.object( + Object.fromEntries( + (spec.fields ?? []).map(leaf => { + const base = baseFieldSchema(leaf); + const nullable = leaf.nullable ? base.nullable() : base; + const current = values?.[leaf.name] ?? leaf.defaultValue; + + return [ + leaf.name, + current === undefined + ? leaf.required + ? nullable + : nullable.optional() + : nullable.default(current), + ]; + }), + ), + ); + const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { switch (spec.kind) { case "boolean": @@ -252,6 +331,8 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { ? z.enum(values as [string, ...string[]]) : z.string(); } + case "group": + return leafObjectSchema(spec); case "number": { // A number input hands react-hook-form a string, so the form schema // coerces - `z.number()` would reject "0" and disable submit. @@ -262,6 +343,21 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { return schema; } case "relation": + // A to-many relation holds identifiers, not combobox options: the picker + // renders the labels it fetched and stores what the API takes. + if (spec.multiple) return z.array(z.number()); + + return referenceOptionSchema; + case "repeatable": { + // `id` marks a child that already exists; a row without one is new. The + // client id the editor uses for its React keys never crosses the wire. + const row = leafObjectSchema(spec).extend({ id: z.number().optional() }); + + return z + .array(row) + .min(spec.minItems ?? 0) + .max(spec.maxItems ?? Number.MAX_SAFE_INTEGER); + } case "user": // `AutoFormCombobox` holds the whole option, not the id - the same shape // the blog plugin models by hand. `contentFormValuesToPayload` turns it @@ -330,7 +426,17 @@ export const contentFormValuesToPayload = ( 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]; + // A group and a repeatable already hold the shape the API takes - the + // editors control the nested value directly - and a to-many relation + // already holds identifiers. Only the single-relation combobox, which + // holds `{ label, value }` so it can show a name, needs converting. + if ( + !fieldSpec || + fieldSpec.multiple === true || + !isReferenceKind(fieldSpec.kind) + ) { + return [name, value]; + } const option = value as ContentReferenceOption | null | undefined; if (!option?.value) return [name, null]; @@ -354,8 +460,28 @@ export const buildFormSchemaFromSpec = ( z.object( Object.fromEntries( spec.fields.map(fieldSpec => { + // A group builds its leaf defaults from the value it is editing, which + // a shared `baseFieldSchema` cannot see. + if (fieldSpec.kind === "group") { + const current = values?.[fieldSpec.name] as + null | Record | undefined; + const object = leafObjectSchema(fieldSpec, current ?? undefined); + const nullable: z.ZodType = fieldSpec.nullable + ? object.nullable() + : object; + + return [ + fieldSpec.name, + // `seo: null` is a real state a nullable group can be in, and the + // editor has to open on it rather than on an empty object. + current === null ? nullable.default(null) : nullable.optional(), + ]; + } + const base = - isReferenceKind(fieldSpec.kind) && fieldSpec.required + isReferenceKind(fieldSpec.kind) && + !fieldSpec.multiple && + fieldSpec.required ? baseFieldSchema(fieldSpec).refine( option => (option as ContentReferenceOption).value !== "", ) diff --git a/packages/vitnode/src/content/advanced.test-d.ts b/packages/vitnode/src/content/advanced.test-d.ts new file mode 100644 index 000000000..7c4d1dc06 --- /dev/null +++ b/packages/vitnode/src/content/advanced.test-d.ts @@ -0,0 +1,396 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import type { ContentModel } from "./server"; +import type { + AnyContentTypeDefinition, + ContentAdvancedValues, + ContentChangedPath, + ContentCreateInput, + ContentDetail, + ContentFilterInput, + ContentIndexInput, + ContentPublicExposableField, + ContentPublicSelect, + ContentSelect, + ContentUpdateInput, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +/** + * The type-level contract of Stage 6. + * + * The interesting assertions are the negative ones. A group that leaked its + * flattened column names into the public type, a collection that crept into a + * list row, a private leaf that showed up in a public response, an index that + * accepted a repeatable path - each is a bug the compiler is the only thing + * that catches early, and each is asserted here rather than hoped for. + */ + +const categoryContentType = defineContentType({ + admin: { label: { plural: "Categories", singular: "Category" } }, + fields: { name: field.text({ required: true }) }, + id: "test.d-category", + tableName: "test_d_categories", +}); + +const seoGroup = field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + nullable: true, +}); + +const articleContentType = defineContentType({ + admin: { + label: { plural: "Articles", singular: "Article" }, + list: { columns: ["title"] }, + titleField: "title", + }, + editorial: { enabled: true }, + fields: { + categories: field.relation({ + multiple: true, + target: () => categoryContentType, + }), + faq: field.repeatable({ + fields: { + answer: field.textarea({ required: true }), + question: field.text({ required: true }), + }, + }), + related: field.relation({ multiple: true, ordered: true, self: true }), + seo: seoGroup, + slug: field.slug({ source: "title" }), + syndication: field.group({ + fields: { indexable: field.boolean({ defaultValue: true }) }, + }), + title: field.text({ required: true }), + }, + id: "test.d-article", + publicApi: { + enabled: true, + fields: ["title", "slug", "categories", "seo.title", "faq.question"], + path: "d-articles", + }, + publication: { enabled: true }, + tableName: "test_d_articles", +}); + +type Article = typeof articleContentType; + +describe("select values", () => { + it("keeps a group nested and never leaks its column names", () => { + expectTypeOf>().toHaveProperty("seo"); + assertType["seo"]>({ + description: null, + title: "SEO", + }); + assertType["seo"]>(null); + // The flattened name is an internal mapping and is absent from every + // user-facing type. + expectTypeOf>().not.toHaveProperty("seoTitle"); + }); + + it("keeps a non-nullable group non-nullable", () => { + assertType["syndication"]>({ indexable: true }); + // @ts-expect-error the group is not nullable, so neither is its value. + assertType["syndication"]>(null); + }); + + it("leaves collections out of a row", () => { + // Two extra queries each: a list that carried them would issue one per row. + expectTypeOf>().not.toHaveProperty("categories"); + expectTypeOf>().not.toHaveProperty("faq"); + expectTypeOf>().not.toHaveProperty("related"); + }); + + it("puts them on the detail read instead", () => { + assertType["categories"]>([1, 2]); + assertType["related"]>([3]); + assertType["faq"]>([ + { answer: "A", id: 11, question: "Q" }, + ]); + assertType["faq"]>([ + // @ts-expect-error a child always comes back with its identifier. + { answer: "A", question: "Q" }, + ]); + expectTypeOf>().toHaveProperty("faq"); + expectTypeOf>().toHaveProperty("title"); + }); +}); + +describe("create values", () => { + it("takes a nested group and a collection", () => { + assertType>({ + categories: [1, 2], + faq: [{ answer: "A", question: "Q" }], + seo: { title: "SEO" }, + title: "Hello", + }); + }); + + it("accepts null for a nullable group", () => { + assertType>({ seo: null, title: "Hello" }); + }); + + it("lets a repeatable child name an existing id", () => { + assertType>({ + faq: [{ answer: "A", id: 11, question: "Q" }], + title: "Hello", + }); + }); + + it("rejects a leaf the group does not declare", () => { + assertType>({ + // @ts-expect-error `seo` has `title` and `description`, not `keywords`. + seo: { keywords: "no" }, + title: "Hello", + }); + }); + + it("rejects a flattened column name", () => { + assertType>({ + // @ts-expect-error the logical shape is nested; `seoTitle` is not a field. + seoTitle: "no", + title: "Hello", + }); + }); +}); + +describe("update values", () => { + it("takes one leaf of a group without the others", () => { + assertType>({ + seo: { description: "Just this" }, + }); + }); + + it("takes a collection whole", () => { + assertType>({ categories: [3] }); + assertType>({ related: [] }); + }); + + it("still rejects an unknown leaf", () => { + // @ts-expect-error partial does not mean permissive. + assertType>({ seo: { keywords: "no" } }); + }); + + it("rejects a to-many relation value that is not a list of ids", () => { + // @ts-expect-error a collection is replaced whole, with identifiers. + assertType>({ categories: [{ id: 3 }] }); + }); +}); + +describe("changed paths", () => { + it("names group leaves and collections, never a whole group", () => { + expectTypeOf<"seo.title">().toExtend>(); + expectTypeOf<"seo.description">().toExtend>(); + expectTypeOf<"categories">().toExtend>(); + expectTypeOf<"faq">().toExtend>(); + expectTypeOf<"title">().toExtend>(); + expectTypeOf<"seo">().not.toExtend>(); + }); +}); + +describe("filters", () => { + it("takes a membership object for a to-many relation", () => { + assertType>({ categories: { contains: 7 } }); + }); + + it("rejects a bare identifier for one", () => { + // @ts-expect-error a to-many relation is not an equality filter. + assertType>({ categories: 7 }); + }); +}); + +describe("public projection", () => { + it("nests the exposed leaves and nothing else", () => { + assertType["seo"]>({ title: "SEO" }); + assertType["seo"]>(null); + // `seo.description` is not exposed, so it is absent from the type - and + // therefore from the generated SELECT. + assertType["seo"]>({ + // @ts-expect-error a private leaf is not part of the public shape. + description: "leak", + title: "SEO", + }); + }); + + it("exposes a to-many relation as identifiers", () => { + assertType["categories"]>([1, 2]); + }); + + it("exposes a repeatable as its allowlisted leaves", () => { + assertType["faq"]>([ + { id: 11, question: "Q" }, + ]); + assertType["faq"]>([ + // @ts-expect-error `faq.answer` is not in `publicApi.fields`. + { answer: "leak", id: 11, question: "Q" }, + ]); + }); + + it("omits a private collection entirely", () => { + expectTypeOf>().not.toHaveProperty("related"); + expectTypeOf>().not.toHaveProperty( + "syndication", + ); + }); + + it("refuses to expose a group whole", () => { + type Exposable = ContentPublicExposableField; + + expectTypeOf<"seo.title">().toExtend(); + expectTypeOf<"faq.answer">().toExtend(); + expectTypeOf<"categories">().toExtend(); + expectTypeOf<"seo">().not.toExtend(); + expectTypeOf<"faq">().not.toExtend(); + }); +}); + +describe("indexes", () => { + type Index = ContentIndexInput; + + it("accepts a group leaf path", () => { + assertType({ on: ["seo.title"] }); + }); + + it("rejects a repeatable leaf", () => { + // @ts-expect-error a repeatable leaf is a column on a child table. + assertType({ on: ["faq.question"] }); + }); + + it("rejects a to-many relation", () => { + // @ts-expect-error a to-many relation has no column to index. + assertType({ on: ["categories"] }); + }); + + it("rejects a group by name", () => { + // @ts-expect-error a group is several columns rather than one. + assertType({ on: ["seo"] }); + }); +}); + +describe("admin surfaces", () => { + it("refuse a collection as a list column", () => { + defineContentType({ + admin: { + label: { plural: "Bad", singular: "Bad" }, + // @ts-expect-error a to-many relation is not a column. + list: { columns: ["tags"] }, + }, + fields: { + name: field.text({ required: true }), + tags: field.relation({ + multiple: true, + target: () => categoryContentType, + }), + }, + id: "test.d-badlist", + tableName: "test_d_badlist", + }); + }); +}); + +type Service = ReturnType["service"]>; +type Editorial = ReturnType< + NonNullable["editorialService"]> +>; + +declare const service: Service; +declare const editorial: Editorial; + +describe("the typed collection API", () => { + it("keys relations by the content type's actual collection names", () => { + expectTypeOf().toHaveProperty("categories"); + expectTypeOf().toHaveProperty("related"); + // `Record` accepted this and failed at runtime. + expectTypeOf().not.toHaveProperty( + "thisFieldDoesNotExist", + ); + // A repeatable is not a relation, and vice versa. + expectTypeOf().not.toHaveProperty("faq"); + }); + + it("keys repeatables by the content type's actual repeatable names", () => { + expectTypeOf().toHaveProperty("faq"); + expectTypeOf().not.toHaveProperty( + "thisFieldDoesNotExist", + ); + expectTypeOf().not.toHaveProperty("categories"); + }); + + it("types a relation helper's arguments", () => { + expectTypeOf() + .parameter(1) + .toEqualTypeOf(); + expectTypeOf< + Service["relations"]["categories"]["get"] + >().returns.resolves.toEqualTypeOf(); + }); + + it("infers a repeatable's create values from its own leaves", () => { + void service.repeatable.faq.create(7, { answer: "A", question: "Q" }); + // @ts-expect-error `unknownField` is not a leaf of `faq`. + void service.repeatable.faq.create(7, { unknownField: "no" }); + // @ts-expect-error `answer` is required. + void service.repeatable.faq.create(7, { question: "Q" }); + }); + + it("infers a repeatable's update values as a partial of the same leaves", () => { + void service.repeatable.faq.update(7, 11, { answer: "A" }); + // @ts-expect-error partial does not mean permissive. + void service.repeatable.faq.update(7, 11, { unknownField: "no" }); + }); + + it("types what a repeatable lists", () => { + assertType>( + service.repeatable.faq.list(7), + ); + // Every child comes back with the identity a later edit addresses it by. + expectTypeOf(service.repeatable.faq.list(7)).resolves.toHaveProperty( + "length", + ); + }); + + it("requires an actor and a version on the editorial helpers", () => { + void editorial.relations.categories.add(7, 1, { + actor: { type: "staff", userId: 1 }, + expectedVersion: 3, + }); + // @ts-expect-error the editorial API never writes without a version guard. + void editorial.relations.categories.add(7, 1, {}); + // @ts-expect-error and never without an actor to attribute the revision to. + void editorial.relations.categories.add(7, 1, { expectedVersion: 3 }); + }); + + it("keeps the plain helpers free of an expected version", () => { + // The plain service has no version column to guard on, so offering the + // argument would be offering one it has to ignore. + // @ts-expect-error use `editorialService.relations` for optimistic locking. + void service.relations.categories.add(7, 1, { expectedVersion: 3 }); + }); +}); + +describe("variance", () => { + /** + * A concrete model stays assignable to the erased one. + * + * Load-bearing, and easy to break by accident: every route builder, every + * registry and every piece of background work is written against + * `ContentModel`. A type helper that puts + * `TDefinition` in a contravariant position - a `UnionToIntersection`, say - + * makes the parameter invariant and quietly breaks all of them at once. + */ + it("keeps a concrete model assignable to the erased one", () => { + expectTypeOf>().toExtend< + ContentModel + >(); + }); + + it("keeps the definition itself assignable", () => { + expectTypeOf
().toExtend(); + }); +}); diff --git a/packages/vitnode/src/content/advanced.test.ts b/packages/vitnode/src/content/advanced.test.ts new file mode 100644 index 000000000..258f87fea --- /dev/null +++ b/packages/vitnode/src/content/advanced.test.ts @@ -0,0 +1,578 @@ +import { describe, expect, it } from "vitest"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +/** + * Definition-time validation for Stage 6. + * + * Every case here is a mistake whose *first* symptom would otherwise be a query + * against a table that does not exist, a column two fields quietly share, or a + * localized list nothing knows how to reorder. They fail at import time, which + * is to say before the process serves anything. + */ + +const base = { + admin: { label: { plural: "Things", singular: "Thing" } }, + id: "test.advanced", + tableName: "test_advanced", +} as const; + +const target = defineContentType({ + admin: { label: { plural: "Targets", singular: "Target" } }, + fields: { name: field.text({ required: true }) }, + id: "test.target", + tableName: "test_targets", +}); + +describe("relations", () => { + it("generates one junction table per to-many field", () => { + const definition = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + tags: field.relation({ multiple: true, target: () => target }), + }, + }); + + expect(definition.advanced.junctions).toStrictEqual([ + { + field: "tags", + positionIndexName: "test_advanced_tags_position_key", + primaryKeyName: "test_advanced_tags_pk", + relatedIndexName: "test_advanced_tags_related_item_id_idx", + tableName: "test_advanced_tags", + }, + ]); + }); + + it("snake_cases a camelCase field name into its table name", () => { + const definition = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + relatedThings: field.relation({ multiple: true, target: () => target }), + }, + }); + + expect(definition.advanced.junctions[0].tableName).toBe( + "test_advanced_related_things", + ); + }); + + it("binds a `self: true` relation to the definition being built", () => { + const selfReferential = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + // No `target`, and deliberately so: `() => selfReferential` would make + // the definition's own inferred type circular, and TypeScript widens + // that to `any` rather than reporting it. + related: field.relation({ multiple: true, ordered: true, self: true }), + }, + }); + + expect(selfReferential.advanced.junctions[0].tableName).toBe( + "test_advanced_related", + ); + expect(selfReferential.fields.related.target().id).toBe("test.advanced"); + expect(selfReferential.fields.related.target()).toBe(selfReferential); + }); + + it("does not mutate the descriptor a caller passed in", () => { + const shared = field.relation({ multiple: true, self: true }); + + const first = defineContentType({ + ...base, + fields: { name: field.text({ required: true }), related: shared }, + }); + const second = defineContentType({ + ...base, + id: "test.other", + fields: { name: field.text({ required: true }), related: shared }, + tableName: "test_other", + }); + + // A descriptor const reused by two content types must not end up pointing + // both relations at whichever one was declared last. + expect(first.fields.related.target().id).toBe("test.advanced"); + expect(second.fields.related.target().id).toBe("test.other"); + }); + + it("generates no junction table for a to-one relation", () => { + const definition = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + one: field.relation({ nullable: true, target: () => target }), + }, + }); + + expect(definition.advanced.junctions).toStrictEqual([]); + }); + + it("rejects `ordered` without `multiple`", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + one: field.relation({ + nullable: true, + ordered: true, + target: () => target, + }), + }, + }), + ).toThrow(/One target has no order/); + }); + + it("rejects `required` on a to-many relation", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + many: field.relation({ + multiple: true, + required: true, + target: () => target, + }), + }, + }), + ).toThrow(/the empty set is what "no targets" looks like/); + }); + + it('rejects `onDelete: "set null"` on a to-many relation', () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + many: field.relation({ + multiple: true, + onDelete: "set null", + target: () => target, + }), + }, + }), + ).toThrow(/nothing to null/); + }); + + it("rejects a relation with both `self` and a `target`", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + both: field.relation({ self: true, target: () => target }), + }, + }), + ).toThrow(/declares both `self: true` and a `target`/); + }); + + it("rejects a relation with neither", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + neither: field.relation({ nullable: true }), + }, + }), + ).toThrow(/needs a `target`/); + }); + + it("rejects a localized to-many relation", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + many: field.relation({ + localized: true, + multiple: true, + target: () => target, + } as never), + }, + localization: { defaultLocale: "en", enabled: true }, + }), + ).toThrow(/per-locale references are out of scope/); + }); +}); + +describe("groups", () => { + const seo = { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }; + + it("maps every leaf to a canonical path and a column", () => { + const definition = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ fields: seo }), + }, + }); + + expect(definition.advanced.leaves).toStrictEqual([ + { + columnName: "seoDescription", + group: "seo", + leaf: "description", + localized: false, + path: "seo.description", + }, + { + columnName: "seoTitle", + group: "seo", + leaf: "title", + localized: false, + path: "seo.title", + }, + ]); + }); + + it("marks the leaves of a localized group", () => { + const definition = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ fields: seo, localized: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + }); + + expect(definition.advanced.leaves.every(leaf => leaf.localized)).toBe(true); + }); + + it("rejects a localized leaf inside a group", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ + fields: { title: field.text({ localized: true, nullable: true }) }, + }), + }, + localization: { defaultLocale: "en", enabled: true }, + }), + ).toThrow(/Localization is a property of the whole group/); + }); + + it("rejects a nested group", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + outer: field.group({ + fields: { + inner: field.group({ + fields: { title: field.text({ nullable: true }) }, + }), + } as never, + }), + }, + }), + ).toThrow(/cannot sit inside a group/); + }); + + it("rejects a relation inside a group", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + meta: field.group({ + fields: { + owner: field.relation({ target: () => target }), + } as never, + }), + }, + }), + ).toThrow(/cannot sit inside a group/); + }); + + it("rejects a slug inside a group", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + meta: field.group({ fields: { url: field.slug() } as never }), + }, + }), + ).toThrow(/cannot sit inside a group/); + }); + + it("rejects an empty group", () => { + expect(() => + defineContentType({ + ...base, + fields: { + empty: field.group({ fields: {} }), + name: field.text({ required: true }), + }, + }), + ).toThrow(/declares no leaves/); + }); + + it("rejects a nullable group with a non-nullable leaf", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ + fields: { title: field.text({ required: true }) }, + nullable: true, + }), + }, + }), + ).toThrow(/setting it to null has to blank every leaf/); + }); + + it("rejects an optional group whose leaf has nothing to fall back to", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ + fields: { title: field.text({ required: true }) }, + }), + }, + }), + ).toThrow(/every leaf needs a value it can fall back to/); + }); + + it("accepts a required group with a required leaf", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ + fields: { title: field.text({ required: true }) }, + required: true, + }), + }, + }), + ).not.toThrow(); + }); + + it("rejects a leaf whose column collides with a declared field", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ + fields: { title: field.text({ nullable: true }) }, + }), + // Compiles to the same column the leaf does. + seoTitle: field.text({ nullable: true }), + }, + }), + ).toThrow(/already declares as a field/); + }); + + it("rejects a localized leaf that shadows a translation column", () => { + expect(() => + defineContentType({ + ...base, + fields: { + // `item` + `Id` compiles to `itemId`, which every translation table + // generates for itself. + item: field.group({ + fields: { id: field.text({ nullable: true }) }, + localized: true, + }), + name: field.text({ required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + }), + ).toThrow(/the translation table generates for itself/); + }); +}); + +describe("repeatables", () => { + const faq = { + answer: field.textarea({ required: true }), + question: field.text({ required: true }), + }; + + it("generates one child table per repeatable field", () => { + const definition = defineContentType({ + ...base, + fields: { + faq: field.repeatable({ fields: faq }), + name: field.text({ required: true }), + }, + }); + + expect(definition.advanced.repeatables).toStrictEqual([ + { + field: "faq", + positionIndexName: "test_advanced_faq_position_key", + tableName: "test_advanced_faq", + }, + ]); + }); + + it("rejects a localized repeatable", () => { + expect(() => + defineContentType({ + ...base, + fields: { + faq: field.repeatable({ fields: faq, localized: true } as never), + name: field.text({ required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + }), + ).toThrow(/Repeatable fields are shared in Stage 6/); + }); + + it("rejects a localized leaf inside a repeatable", () => { + expect(() => + defineContentType({ + ...base, + fields: { + faq: field.repeatable({ + fields: { + question: field.text({ localized: true, required: true }), + }, + }), + name: field.text({ required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + }), + ).toThrow(/repeatable fields are shared in Stage 6/); + }); + + it("rejects a leaf that shadows a generated child column", () => { + expect(() => + defineContentType({ + ...base, + fields: { + faq: field.repeatable({ + fields: { position: field.text({ required: true }) }, + }), + name: field.text({ required: true }), + }, + }), + ).toThrow(/collides with a generated column/); + }); + + it("rejects an out-of-range max", () => { + expect(() => + defineContentType({ + ...base, + fields: { + faq: field.repeatable({ fields: faq, max: 0 }), + name: field.text({ required: true }), + }, + }), + ).toThrow(/must be a whole number between 1 and/); + }); + + it("refuses two fields that would generate the same table", () => { + expect(() => + defineContentType({ + ...base, + fields: { + // Both snake_case to `test_advanced_faq_items`. + faqItems: field.repeatable({ fields: faq }), + faq_items: field.repeatable({ fields: faq }) as never, + name: field.text({ required: true }), + }, + }), + ).toThrow(/must be camelCase|already used by/); + }); +}); + +describe("indexes over advanced fields", () => { + it("materialises a leaf path against its generated column", () => { + const definition = defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ + fields: { code: field.text({ nullable: true }) }, + }), + }, + indexes: [{ on: ["seo.code"], unique: true }], + }); + + const declared = definition.indexes.find(index => + index.on.includes("seoCode"), + ); + + expect(declared?.unique).toBe(true); + expect(declared?.name).toBe("test_advanced_seo_code_key"); + }); + + it("refuses a repeatable leaf rather than dropping it silently", () => { + expect(() => + defineContentType({ + ...base, + fields: { + faq: field.repeatable({ + fields: { answer: field.textarea({ required: true }) }, + }), + name: field.text({ required: true }), + }, + indexes: [{ on: ["faq.answer"] as never }], + }), + ).toThrow(/columns on a generated child table/); + }); + + it("refuses a to-many relation", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + tags: field.relation({ multiple: true, target: () => target }), + }, + indexes: [{ on: ["tags"] as never }], + }), + ).toThrow(/its values live in a generated junction table/); + }); + + it("refuses a whole group", () => { + expect(() => + defineContentType({ + ...base, + fields: { + name: field.text({ required: true }), + seo: field.group({ + fields: { code: field.text({ nullable: true }) }, + }), + }, + indexes: [{ on: ["seo"] as never }], + }), + ).toThrow(/several columns rather than one/); + }); +}); + +describe("stage 1-5 content types", () => { + it("resolve to an empty advanced config", () => { + const flat = defineContentType({ + ...base, + fields: { + category: field.relation({ nullable: true, target: () => target }), + name: field.text({ required: true }), + }, + }); + + expect(flat.advanced).toStrictEqual({ + junctions: [], + leaves: [], + repeatables: [], + }); + }); +}); diff --git a/packages/vitnode/src/content/advanced.ts b/packages/vitnode/src/content/advanced.ts new file mode 100644 index 000000000..9f94a101c --- /dev/null +++ b/packages/vitnode/src/content/advanced.ts @@ -0,0 +1,516 @@ +import type { + ContentFieldDescriptor, + ContentFieldMap, + ContentLeafColumn, + ContentRelationField, + ContentRelationJunction, + ContentRepeatableTable, + ResolvedContentAdvancedConfig, +} from "./types"; + +import { + CONTENT_ADVANCED_LEAF_KINDS, + CONTENT_FIELD_NAME_PATTERN, + CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_JUNCTION_SYSTEM_FIELDS, + CONTENT_RELATION_COLLECTION_MAX, + CONTENT_REPEATABLE_ABSOLUTE_MAX, + CONTENT_REPEATABLE_DEFAULT_MAX, + CONTENT_REPEATABLE_SYSTEM_FIELDS, +} from "./const"; +import { ContentEngineError } from "./errors"; +import { unboundSelfTarget } from "./fields"; +import { clampWithFingerprint } from "./fingerprint"; +import { toSnakeCase } from "./indexes"; +import { + contentLeafColumnName, + contentLeafColumns, + isContentLeafKind, + isContentRelationCollection, + partitionContentStorage, +} from "./paths"; + +/** + * Resolves - and checks - everything Stage 6 adds to a content type. + * + * Every rule here fails at **definition time**, which is to say at import time, + * which is to say before the process is serving anything. A generated table name + * that collides, a leaf that shadows a declared column, a localized repeatable: + * all of them are mistakes whose first symptom would otherwise be a query + * against a table that does not exist, on a Tuesday, in production. + */ + +const emptyAdvanced: ResolvedContentAdvancedConfig = { + junctions: [], + leaves: [], + repeatables: [], +}; + +/** The disabled default, for a content type with no advanced field. */ +export const contentAdvancedDisabled = (): ResolvedContentAdvancedConfig => ({ + ...emptyAdvanced, + junctions: [], + leaves: [], + repeatables: [], +}); + +/** + * `("example_articles", "relatedArticles")` -> `example_articles_related_articles`. + * + * Deterministic and clamped: a long base table plus a long field name passes + * Postgres' 63-character limit easily, and Postgres truncates silently - so two + * fields whose names differ only past the cut would generate one table and + * quietly share it. `clampWithFingerprint` is what the index and translation + * table names already use. + */ +export const contentCollectionTableName = ( + tableName: string, + field: string, +): string => + clampWithFingerprint( + `${tableName}_${toSnakeCase(field)}`, + CONTENT_IDENTIFIER_MAX_LENGTH, + ); + +const suffixed = (tableName: string, suffix: string): string => + clampWithFingerprint(`${tableName}_${suffix}`, CONTENT_IDENTIFIER_MAX_LENGTH); + +const junctionSystemFields: readonly string[] = CONTENT_JUNCTION_SYSTEM_FIELDS; +const repeatableSystemFields: readonly string[] = + CONTENT_REPEATABLE_SYSTEM_FIELDS; + +/** Checks the inner field map a group or a repeatable declares. */ +const assertLeafFields = ({ + container, + fields, + id, + kind, + reserved, +}: { + container: string; + fields: ContentFieldMap; + id: string; + kind: "group" | "repeatable"; + reserved: readonly string[]; +}): void => { + const names = Object.keys(fields); + + if (names.length === 0) { + throw new ContentEngineError( + `${kind} field "${container}" declares no leaves, so it would generate ${kind === "group" ? "no columns" : "a table with nothing but its keys"}. Give it at least one field.`, + { contentTypeId: id }, + ); + } + + for (const leaf of names) { + if (!CONTENT_FIELD_NAME_PATTERN.test(leaf)) { + throw new ContentEngineError( + `Leaf "${container}.${leaf}" must be camelCase and start with a lowercase letter.`, + { contentTypeId: id }, + ); + } + + const leafValue = fields[leaf] as ContentFieldDescriptor | undefined; + if (!leafValue?.kind) { + throw new ContentEngineError( + `Leaf "${container}.${leaf}" is not a field descriptor. Build it with \`field.text()\`, \`field.number()\`, and so on.`, + { contentTypeId: id }, + ); + } + + if (!isContentLeafKind(leafValue.kind)) { + throw new ContentEngineError( + `Leaf "${container}.${leaf}" is a "${leafValue.kind}" field, which cannot sit inside a ${kind}. Allowed kinds: ${CONTENT_ADVANCED_LEAF_KINDS.join(", ")}.`, + { contentTypeId: id }, + ); + } + + if (leafValue.localized === true) { + throw new ContentEngineError( + kind === "group" + ? `Leaf "${container}.${leaf}" is \`localized: true\`. Localization is a property of the whole group - put \`localized: true\` on "${container}" itself, so all of its leaves live on the same table with one revision history and one permission.` + : `Leaf "${container}.${leaf}" is \`localized: true\`, but repeatable fields are shared in Stage 6. A per-language list of different lengths has no defensible reorder or restore semantics; see the Advanced Modeling limitations page.`, + { contentTypeId: id }, + ); + } + + if (reserved.includes(leaf)) { + throw new ContentEngineError( + `Leaf "${container}.${leaf}" collides with a generated column. The ${kind === "group" ? "junction" : "child"} table always carries ${reserved.join(", ")}. Rename the leaf.`, + { contentTypeId: id }, + ); + } + } +}; + +/** + * Every rule a **group** has to satisfy. + * + * The two nullability rules are the ones worth the words, because both are about + * making "the group has no value" and "one leaf happens to be empty" two + * different states rather than one ambiguous row: + * + * 1. `nullable: true` needs every leaf nullable. `seo: null` writes `NULL` to + * every leaf column, and it cannot do that to a `NOT NULL` one. + * 2. A group that is not `required: true` may be left out of a create payload, + * so every leaf has to be writable without input - nullable or defaulted. A + * `required: true` non-nullable leaf inside an optional group would be a row + * that can never be inserted, which is the same failure `assertField` already + * catches one level up. + */ +const assertGroup = ( + id: string, + name: string, + fieldValue: ContentFieldDescriptor, +): void => { + const fields = (fieldValue as { fields: ContentFieldMap }).fields; + + assertLeafFields({ + container: name, + fields, + id, + kind: "group", + reserved: [], + }); + + for (const [leaf, leafValue] of Object.entries(fields)) { + if (fieldValue.nullable && !leafValue.nullable) { + throw new ContentEngineError( + `Group "${name}" is \`nullable: true\`, so setting it to null has to blank every leaf - but "${name}.${leaf}" is not nullable. Mark the leaf \`nullable: true\`, or drop \`nullable\` from the group.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.required || leafValue.nullable) continue; + + // `assertLeafFields` has already proven every leaf is one of + // `CONTENT_ADVANCED_LEAF_KINDS`, all of which carry `defaultValue` - but + // the descriptor union here is still the wide one. + const hasDefault = + leafValue.kind === "dateTime" + ? leafValue.defaultNow + : (leafValue as { defaultValue?: unknown }).defaultValue !== undefined; + + if (!hasDefault) { + throw new ContentEngineError( + `Group "${name}" may be omitted from a create payload, so every leaf needs a value it can fall back to - but "${name}.${leaf}" is neither nullable nor defaulted. Add \`nullable: true\` or a \`defaultValue\` to the leaf, or \`required: true\` to the group.`, + { contentTypeId: id }, + ); + } + } +}; + +const assertRepeatable = ( + id: string, + name: string, + fieldValue: ContentFieldDescriptor, +): void => { + if (fieldValue.localized === true) { + throw new ContentEngineError( + `Repeatable field "${name}" is \`localized: true\`. Repeatable fields are shared in Stage 6 - a per-language list of different lengths has no defensible reorder or restore semantics. See the Advanced Modeling limitations page.`, + { contentTypeId: id }, + ); + } + + const repeatableValue = fieldValue as { + fields: ContentFieldMap; + max?: number; + min?: number; + }; + + assertLeafFields({ + container: name, + fields: repeatableValue.fields, + id, + kind: "repeatable", + reserved: repeatableSystemFields, + }); + + const max = repeatableValue.max ?? CONTENT_REPEATABLE_DEFAULT_MAX; + if ( + !Number.isInteger(max) || + max < 1 || + max > CONTENT_REPEATABLE_ABSOLUTE_MAX + ) { + throw new ContentEngineError( + `Repeatable field "${name}" has max ${max}; it must be a whole number between 1 and ${CONTENT_REPEATABLE_ABSOLUTE_MAX}. A repeatable is a handful of rows a person edits in one form - model a content type for anything larger.`, + { contentTypeId: id }, + ); + } + + const min = repeatableValue.min; + if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > max)) { + throw new ContentEngineError( + `Repeatable field "${name}" has min ${min}, which must be a whole number between 0 and its max of ${max}.`, + { contentTypeId: id }, + ); + } +}; + +const assertRelationCollection = ( + id: string, + name: string, + fieldValue: ContentRelationField, +): void => { + if (fieldValue.required || fieldValue.nullable) { + throw new ContentEngineError( + `Relation field "${name}" is \`multiple: true\`, so it is neither required nor nullable - the empty set is what "no targets" looks like. Remove \`${fieldValue.required ? "required" : "nullable"}\` from it.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.localized === true) { + throw new ContentEngineError( + `Relation field "${name}" is \`localized: true\`. A relation is a foreign key, and per-locale references are out of scope - the targets a record points at are the same in every language.`, + { contentTypeId: id }, + ); + } + + // "set null" describes a column that is set to NULL. A junction row has no + // such column: the honest analogue of "forget this reference" is to delete the + // row, which is `cascade`. + if (fieldValue.onDelete === "set null") { + throw new ContentEngineError( + `Relation field "${name}" is \`multiple: true\` with \`onDelete: "set null"\`, which has nothing to null: a to-many reference is a junction row, not a nullable column. Use \`"cascade"\` to drop the reference when the target goes, or \`"restrict"\` to refuse the delete.`, + { contentTypeId: id }, + ); + } +}; + +/** A to-one relation may not carry `ordered`, which would mean nothing. */ +const assertRelation = ( + id: string, + name: string, + fieldValue: ContentRelationField, +): void => { + if (fieldValue.multiple) { + assertRelationCollection(id, name, fieldValue); + + return; + } + + if (fieldValue.ordered) { + throw new ContentEngineError( + `Relation field "${name}" is \`ordered: true\` but not \`multiple: true\`. One target has no order. Add \`multiple: true\`, or drop \`ordered\`.`, + { contentTypeId: id }, + ); + } +}; + +/** + * Checks every advanced field and resolves the tables they generate. + * + * Runs from `defineContentType` before anything else reads the field map, so a + * generated column name is known to be free by the time the index resolver, the + * schema builder and the admin resolver each look at it. + */ +export const resolveContentAdvanced = ({ + fields, + id, + tableName, +}: { + fields: ContentFieldMap; + id: string; + tableName: string; +}): ResolvedContentAdvancedConfig => { + const { groups, relationCollections, repeatables } = + partitionContentStorage(fields); + + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind === "relation") assertRelation(id, name, fieldValue); + if (fieldValue.kind === "group") assertGroup(id, name, fieldValue); + if (fieldValue.kind === "repeatable") + assertRepeatable(id, name, fieldValue); + } + + const leaves = contentLeafColumns(fields); + assertLeafColumnsAreFree(id, fields, groups, leaves); + + const junctions: ContentRelationJunction[] = Object.keys( + relationCollections, + ).map(field => { + const junctionTable = contentCollectionTableName(tableName, field); + + return { + field, + positionIndexName: suffixed(junctionTable, "position_key"), + primaryKeyName: suffixed(junctionTable, "pk"), + relatedIndexName: suffixed(junctionTable, "related_item_id_idx"), + tableName: junctionTable, + }; + }); + + const repeatableTables: ContentRepeatableTable[] = Object.keys( + repeatables, + ).map(field => { + const childTable = contentCollectionTableName(tableName, field); + + return { + field, + positionIndexName: suffixed(childTable, "position_key"), + tableName: childTable, + }; + }); + + assertGeneratedTableNames(id, tableName, junctions, repeatableTables); + + return { junctions, leaves, repeatables: repeatableTables }; +}; + +/** + * Every generated leaf column has to be a name nothing else claims. + * + * `seo.title` compiles to `seoTitle`, and a content type that *also* declares a + * field called `seoTitle` would generate one column and have two fields read it + * - which is a silent data bug, not a crash, so it is refused here. + */ +const assertLeafColumnsAreFree = ( + id: string, + fields: ContentFieldMap, + groups: ContentFieldMap, + leaves: readonly ContentLeafColumn[], +): void => { + const declared = new Set(Object.keys(fields)); + const seen = new Map(); + + for (const leaf of leaves) { + if (declared.has(leaf.columnName)) { + throw new ContentEngineError( + `Leaf "${leaf.path}" is stored in a column called "${leaf.columnName}", which this content type already declares as a field. Rename one of them.`, + { contentTypeId: id }, + ); + } + + const collision = seen.get(leaf.columnName); + if (collision !== undefined) { + throw new ContentEngineError( + `Leaves "${collision}" and "${leaf.path}" both compile to the column "${leaf.columnName}". Rename one of them.`, + { contentTypeId: id }, + ); + } + seen.set(leaf.columnName, leaf.path); + } + + // A junction table's own columns are fixed, so a *group* whose flattened name + // matched one would be a problem only on the base table - which the check + // above already covers. What is left is the group-name-versus-leaf-name case: + // `seo` and `seoTitle` declared side by side is caught above, and a leaf + // called the same as its own group is harmless. + for (const group of Object.keys(groups)) { + if (!junctionSystemFields.includes(group)) continue; + + throw new ContentEngineError( + `Group "${group}" shares its name with a generated junction column. Rename it - a junction table always carries ${junctionSystemFields.join(", ")}.`, + { contentTypeId: id }, + ); + } +}; + +/** + * Two advanced fields must not generate the same table. + * + * Reachable in exactly one way that is not a typo: two long field names whose + * generated table names collide **after** the identifier clamp. The fingerprint + * makes that vanishingly unlikely rather than impossible, so it is checked + * rather than assumed - and a content type must not generate a table sharing its + * own name either. + */ +const assertGeneratedTableNames = ( + id: string, + tableName: string, + junctions: readonly ContentRelationJunction[], + repeatables: readonly ContentRepeatableTable[], +): void => { + const byName = new Map([[tableName, "the content type"]]); + + for (const entry of [...junctions, ...repeatables]) { + const owner = byName.get(entry.tableName); + if (owner !== undefined) { + throw new ContentEngineError( + `Field "${entry.field}" generates the table "${entry.tableName}", which is already used by ${owner}. Rename the field.`, + { contentTypeId: id }, + ); + } + byName.set(entry.tableName, `"${entry.field}"`); + } +}; + +/** + * Exactly one of `self` and `target`, on every relation. + * + * Runs **before** `bindSelfRelations` rebinds the thunk, which is the only + * moment the two are still distinguishable: after binding, a self-relation's + * `target` is a real function too. + * + * Checked here rather than by a union in `field.relation`'s signature, because + * a union there would stop TypeScript inferring `self` as a literal - and + * `ContentReferences` reads that literal to decide which relations a database + * module has to supply a thunk for. + */ +export const assertContentRelationTargets = ( + id: string, + fields: ContentFieldMap, +): void => { + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "relation") continue; + + // The placeholder `field.relation` installs for a self-relation is not a + // target the caller supplied; comparing identity is what tells them apart. + const hasTarget = + fieldValue.target !== undefined && + fieldValue.target !== unboundSelfTarget; + + if (fieldValue.self && hasTarget) { + throw new ContentEngineError( + `Relation field "${name}" declares both \`self: true\` and a \`target\`. A self-relation's target is this content type, so drop the \`target\`.`, + { contentTypeId: id }, + ); + } + + if (!fieldValue.self && !hasTarget) { + throw new ContentEngineError( + `Relation field "${name}" needs a \`target\` - or \`self: true\` if it points at this content type.`, + { contentTypeId: id }, + ); + } + } +}; + +/** The resolved ceiling on a repeatable's child count. */ +export const contentRepeatableMax = ( + fieldValue: ContentFieldDescriptor, +): number => + fieldValue.kind === "repeatable" + ? (fieldValue.max ?? CONTENT_REPEATABLE_DEFAULT_MAX) + : CONTENT_REPEATABLE_DEFAULT_MAX; + +export const contentRepeatableMin = ( + fieldValue: ContentFieldDescriptor, +): number => (fieldValue.kind === "repeatable" ? (fieldValue.min ?? 0) : 0); + +export const contentRelationCollectionMax = (): number => + CONTENT_RELATION_COLLECTION_MAX; + +/** Looks a generated junction up by its field name. */ +export const findContentJunction = ( + advanced: ResolvedContentAdvancedConfig, + field: string, +): ContentRelationJunction | undefined => + advanced.junctions.find(entry => entry.field === field); + +/** Looks a generated child table up by its field name. */ +export const findContentRepeatableTable = ( + advanced: ResolvedContentAdvancedConfig, + field: string, +): ContentRepeatableTable | undefined => + advanced.repeatables.find(entry => entry.field === field); + +/** The column one canonical leaf path is stored in, or `undefined`. */ +export const findContentLeafColumn = ( + advanced: ResolvedContentAdvancedConfig, + path: string, +): ContentLeafColumn | undefined => + advanced.leaves.find(entry => entry.path === path); + +/** Re-exported so callers need one import for the whole path vocabulary. */ +export { contentLeafColumnName }; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index e5c0a247a..594de9ed5 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -129,6 +129,91 @@ export const isLocalizableFieldKind = (kind: string): boolean => /** Appended to the base table name to get the generated translation table. */ export const CONTENT_TRANSLATION_TABLE_SUFFIX = "_translations"; +// --------------------------------------------------------------------------- +// Advanced modeling (Stage 6) +// --------------------------------------------------------------------------- + +/** What separates a container from its leaf in a canonical path: `seo.title`. */ +export const CONTENT_PATH_SEPARATOR = "."; + +/** + * Field kinds that may sit inside a `group` or a `repeatable`. + * + * Scalars only, and every exclusion is a decision rather than an oversight: + * + * - **group** - nesting would need a second level of column naming and a second + * level of partial-update merging, for no modelling gain a second group next + * to the first does not already give. + * - **repeatable** - a child table of a child table is a tree, and a tree needs + * its own ordering, its own cascade and its own restore semantics. + * - **slug** - a slug is a URL segment with a uniqueness scope. Inside a group + * the scope would be the row (fine) and inside a repeatable it would be the + * parent (not a URL at all), so one name would mean two things. + * - **relation** / **user** - a foreign key the relation services do not look + * at is a foreign key nothing maintains. Model it as a to-many relation on the + * content type instead. + */ +export const CONTENT_ADVANCED_LEAF_KINDS = [ + "boolean", + "dateTime", + "enum", + "number", + "text", + "textarea", +] as const; + +/** + * How many children one repeatable field may hold, unless it says otherwise. + * + * A ceiling exists at all because every write replaces the whole list in one + * statement and every read loads it whole: a repeatable is a handful of FAQ + * entries, not a table. An author who wants more should model a content type. + */ +export const CONTENT_REPEATABLE_DEFAULT_MAX = 100; +export const CONTENT_REPEATABLE_ABSOLUTE_MAX = 1000; + +/** + * How many targets one to-many relation may hold. + * + * Same reasoning as the repeatable ceiling, and the same shape of enforcement: + * the generated schema rejects a longer array before any query runs. + */ +export const CONTENT_RELATION_COLLECTION_MAX = 500; + +/** The first position of an ordered collection. Contiguous from here. */ +export const CONTENT_COLLECTION_FIRST_POSITION = 0; + +/** The columns every generated junction table carries. */ +export const CONTENT_JUNCTION_SYSTEM_FIELDS = [ + "itemId", + "relatedItemId", + "position", + "createdAt", +] as const; + +/** + * The columns every generated repeatable child table carries. + * + * `id` is a `serial` of its own rather than `(itemId, position)`: position is + * where a row currently sits, and identity has to survive a reorder or "edit the + * third one" means something different after every drag. + */ +export const CONTENT_REPEATABLE_SYSTEM_FIELDS = [ + "id", + "itemId", + "position", + "createdAt", + "updatedAt", +] as const; + +/** Machine-readable reasons an advanced write was refused. */ +export const CONTENT_ADVANCED_CODES = { + duplicateTarget: "CONTENT_RELATION_DUPLICATE_TARGET", + missingChild: "CONTENT_REPEATABLE_UNKNOWN_CHILD", + missingTarget: "CONTENT_RELATION_MISSING_TARGET", + notOrdered: "CONTENT_RELATION_NOT_ORDERED", +} as const; + /** * What a public read does when a locale has no translation. * diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index cd19371b3..db0bb63c3 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1,4 +1,5 @@ import type { + AnyContentTypeDefinition, ContentAdminConfig, ContentEditorialConfig, ContentEditorialEnabled, @@ -26,6 +27,10 @@ import type { ResolvedContentSearchConfig, } from "./types"; +import { + assertContentRelationTargets, + resolveContentAdvanced, +} from "./advanced"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, @@ -65,6 +70,11 @@ import { partitionContentFields, resolveContentLocalization, } from "./localization"; +import { + contentStorageColumns, + isContentRelationCollection, + splitContentFieldPath, +} from "./paths"; import { buildContentSchemas } from "./schemas"; /** Kinds the default `searchableFields` picks up, and `titleField` falls back to. */ @@ -100,9 +110,14 @@ const slugifyModule = (value: string): string => /** 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; - } + // A group is writable because its leaves are - `resolveContentAdvanced` + // proves each of them is nullable or defaulted when the group is optional. + // A collection is writable because the empty set is its default. + if (fieldValue.kind === "group" || fieldValue.kind === "repeatable") { + return true; + } + if (fieldValue.kind === "relation") return fieldValue.multiple; + if (fieldValue.kind === "user") return false; // A sourced slug has no column default and is not required, but it is always // writable: the service derives it from the source field. if (fieldValue.kind === "slug") return fieldValue.source !== undefined; @@ -149,8 +164,10 @@ const FIELD_KINDS = new Set([ "boolean", "dateTime", "enum", + "group", "number", "relation", + "repeatable", "slug", "text", "textarea", @@ -185,7 +202,14 @@ const assertField = ( } } - if (fieldValue.kind === "relation" || fieldValue.kind === "user") { + // A to-many relation is checked by `resolveContentAdvanced` instead: it is + // never nullable by construction, and `"set null"` means something different + // for a junction row than for a column - so the message has to be different + // too, and there is only one place it can be. + if ( + (fieldValue.kind === "relation" && !fieldValue.multiple) || + 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) { @@ -333,6 +357,77 @@ const assertNotLocalized = ( } }; +/** + * Checks that every column an index names is one it can actually be built on. + * + * A repeatable leaf and a to-many relation are refused **loudly** rather than + * silently dropped: `{ on: ["faq.answer"] }` looks like it works, and an index + * that was quietly not created is a performance bug nobody can see. Both live on + * their own generated tables, which already carry the indexes they need. + */ +const assertIndexable = ( + id: string, + names: readonly string[], + fields: ContentFieldMap, + localizedFields: ContentFieldMap, +): void => { + for (const name of names) { + const path = splitContentFieldPath(name); + const owner = path ? path[0] : name; + const fieldValue = fields[owner]; + + if (localizedFields[owner] !== undefined) { + throw new ContentEngineError( + path + ? `indexes names "${name}", a leaf of the localized group "${owner}". Its column is on the translation table, where the unique scope is per language - see \`localization.translationIndexes\`.` + : `indexes names the localized field "${owner}", which is not a column on the base table. Localized values get their own AdminCP surface in Stage 5B.`, + { contentTypeId: id }, + ); + } + + if (!fieldValue) continue; + + if (fieldValue.kind === "repeatable") { + throw new ContentEngineError( + `indexes names "${name}", which belongs to the repeatable "${owner}". Repeatable leaves are columns on a generated child table, not on the base table, so an index here would have nothing to cover. The child table already carries \`(itemId, position)\`.`, + { contentTypeId: id }, + ); + } + + if (isContentRelationCollection(fieldValue)) { + throw new ContentEngineError( + `indexes names the to-many relation "${owner}", which is not a column: its values live in a generated junction table, which already carries its own primary key and reverse index.`, + { contentTypeId: id }, + ); + } + + if (fieldValue.kind === "group" && !path) { + throw new ContentEngineError( + `indexes names the group "${owner}", which is several columns rather than one. Name the leaves you mean, e.g. \`{ on: ["${owner}.${Object.keys((fieldValue as { fields: ContentFieldMap }).fields)[0]}"] }\`.`, + { contentTypeId: id }, + ); + } + } +}; + +/** + * Kinds that are not one column on the base table, so they cannot be a list + * cell, an `orderBy` or a `titleField`. + * + * A group is several columns under generated names; a repeatable and a to-many + * relation are on other tables entirely. All three still belong on the *form* - + * that is what `admin.form.fields` is for, and it is checked against the wider + * set. + */ +const NON_COLUMN_KINDS = new Set([ + "group", + "repeatable", +]); + +const isAdminColumnField = (fieldValue: ContentFieldDescriptor): boolean => + !NON_COLUMN_KINDS.has(fieldValue.kind) && + !isContentRelationCollection(fieldValue); + const resolveAdmin = ( id: string, fields: ContentFieldMap, @@ -342,6 +437,22 @@ const resolveAdmin = ( editorial: boolean, ): ResolvedContentAdminConfig => { const fieldNames = Object.keys(fields); + // The subset a DataTable cell, an `orderBy` and a toast title may name. + const columnFieldNames = fieldNames.filter(name => + isAdminColumnField(fields[name]), + ); + const assertColumnField = (label: string, names: readonly string[]): void => { + const advanced = names.find( + name => fields[name] !== undefined && !isAdminColumnField(fields[name]), + ); + if (advanced === undefined) return; + + throw new ContentEngineError( + `${label} names "${advanced}", a "${fields[advanced].kind}" field. It is not one column on the base table, so it cannot be shown as a cell, ordered by, or used as a title. List it in \`admin.form.fields\` instead${fields[advanced].kind === "group" ? `, or name one of its leaves` : ""}.`, + { contentTypeId: id }, + ); + }; + for (const [label, names] of [ ["admin.form.fields", admin.form?.fields], ["admin.list.columns", admin.list?.columns], @@ -360,6 +471,11 @@ const resolveAdmin = ( ] as const) { if (!names) continue; assertNotLocalized(id, label, names.map(String), localizedFields); + // Every surface here addresses a *column* - except the form, which is the + // one that renders a group as a section and a collection as an editor. + if (label === "admin.form.fields") continue; + + assertColumnField(label, names.map(String)); } const generatedColumns = [ @@ -367,17 +483,17 @@ const resolveAdmin = ( ...(publication ? publicationFields : []), ...(editorial ? editorialFields : []), ]; - const knownColumns = new Set([...fieldNames, ...generatedColumns]); + const knownColumns = new Set([...columnFieldNames, ...generatedColumns]); const searchableFields = ( admin.list?.searchableFields?.map(String) ?? - fieldNames.filter(name => SEARCHABLE_KINDS.has(fields[name].kind)) + columnFieldNames.filter(name => SEARCHABLE_KINDS.has(fields[name].kind)) ).map(String); assertKnownColumns( id, "admin.list.searchableFields", searchableFields, - new Set(fieldNames), + new Set(columnFieldNames), ); const notSearchable = searchableFields.find( name => !EXPLICIT_SEARCHABLE_KINDS.has(fields[name].kind), @@ -394,14 +510,16 @@ const resolveAdmin = ( id, "admin.list.orderableFields", orderableFields, - new Set(fieldNames), + new Set(columnFieldNames), ); // A published/draft badge is the first thing anyone looks for, so it leads - // the default column list. + // the default column list. Advanced fields are absent by default: a to-many + // relation and a repeatable are each an extra query, and defaulting them into + // the list would issue one per row. const defaultColumns = publication - ? ["status", ...fieldNames, "updatedAt"] - : [...fieldNames, "updatedAt"]; + ? ["status", ...columnFieldNames, "updatedAt"] + : [...columnFieldNames, "updatedAt"]; const columns = (admin.list?.columns?.map(String) ?? defaultColumns).map( String, ); @@ -425,10 +543,11 @@ const resolveAdmin = ( const titleField = admin.titleField === undefined - ? (fieldNames.find(name => SEARCHABLE_KINDS.has(fields[name].kind)) ?? - null) + ? (columnFieldNames.find(name => + SEARCHABLE_KINDS.has(fields[name].kind), + ) ?? null) : String(admin.titleField); - if (titleField !== null && !fieldNames.includes(titleField)) { + if (titleField !== null && !columnFieldNames.includes(titleField)) { throw new ContentEngineError( `admin.titleField references unknown field "${titleField}".`, { contentTypeId: id }, @@ -480,6 +599,88 @@ const assertPublicPath = (id: string, path: string): void => { } }; +/** + * Resolves a name or a canonical path to the descriptor it addresses. + * + * One function, so every allowlist in this file - public fields, searchable, + * filterable, orderable, and the three search slots - asks the same question and + * gets the same answer. `container` says where the value lives, which is what + * separates "a column on the row" from "a column on a child row": the second can + * be indexed for search but never filtered, ordered or searched by a list query. + */ +const resolveFieldTarget = ( + fields: ContentFieldMap, + name: string, +): null | { + container: "group" | "repeatable" | "row"; + descriptor: ContentFieldDescriptor; +} => { + const path = splitContentFieldPath(name); + if (!path) { + const fieldValue = fields[name]; + + return fieldValue ? { container: "row", descriptor: fieldValue } : null; + } + + const [owner, leaf] = path; + const container = fields[owner]; + if (container?.kind !== "group" && container?.kind !== "repeatable") { + return null; + } + + const leafValue = (container as { fields: ContentFieldMap }).fields[leaf]; + + return leafValue + ? { container: container.kind, descriptor: leafValue } + : null; +}; + +/** + * Checks one exposed **leaf path**, e.g. `"seo.title"` or `"faq.question"`. + * + * The container has to be a group or a repeatable, and the leaf has to be one it + * declares: a path that resolves to nothing would be a key the response promises + * and never carries, which a generated OpenAPI schema turns into a lie rather + * than an error. + */ +const assertPublicLeafPath = ( + id: string, + fields: ContentFieldMap, + name: string, + [owner, leaf]: [string, string], +): void => { + const container = fields[owner]; + if (!container) { + throw new ContentEngineError( + `publicApi.fields includes "${name}", but this content type declares no field called "${owner}".`, + { contentTypeId: id }, + ); + } + + if (container.kind !== "group" && container.kind !== "repeatable") { + throw new ContentEngineError( + `publicApi.fields includes "${name}", but "${owner}" is a "${container.kind}" field rather than a group or a repeatable. Only those have leaves.`, + { contentTypeId: id }, + ); + } + + const inner = (container as { fields: ContentFieldMap }).fields; + const leafValue = inner[leaf]; + if (!leafValue) { + throw new ContentEngineError( + `publicApi.fields includes "${name}", but "${owner}" declares no leaf called "${leaf}". It has: ${Object.keys(inner).join(", ")}.`, + { contentTypeId: id }, + ); + } + + if (!publicExposableKinds.has(leafValue.kind)) { + throw new ContentEngineError( + `publicApi.fields includes "${name}" of kind "${leafValue.kind}", which cannot be exposed publicly.`, + { contentTypeId: id }, + ); + } +}; + /** * Checks and fills in `publicApi`. * @@ -550,6 +751,12 @@ const resolvePublicApi = ( for (const name of exposed) { if (publicExposableColumns.includes(name)) continue; + const path = splitContentFieldPath(name); + if (path) { + assertPublicLeafPath(id, fields, name, path); + continue; + } + const fieldValue = fields[name]; if (!fieldValue) { if (publicationFields.includes(name)) { @@ -572,6 +779,22 @@ const resolvePublicApi = ( ); } + // A group or a repeatable is never exposed whole. Naming `seo` would + // publish `seo.indexable` because somebody wanted `seo.title`, and a field + // added to the group later would become public without anyone deciding it + // should - which is precisely what an allowlist with no wildcard exists to + // stop. Its leaves are named one at a time. + if (fieldValue.kind === "group" || fieldValue.kind === "repeatable") { + const leaves = Object.keys( + (fieldValue as { fields: ContentFieldMap }).fields, + ); + + throw new ContentEngineError( + `publicApi.fields includes the ${fieldValue.kind} "${name}". A ${fieldValue.kind} is exposed one leaf at a time, so a leaf added later stays private until somebody says otherwise: list ${leaves.map(leaf => `"${name}.${leaf}"`).join(", ")} - or only the ones you mean.`, + { contentTypeId: id }, + ); + } + if (!publicExposableKinds.has(fieldValue.kind)) { throw new ContentEngineError( `publicApi.fields includes "${name}" of kind "${fieldValue.kind}", which cannot be exposed publicly.`, @@ -593,38 +816,73 @@ const resolvePublicApi = ( const searchableFields = (publicApi.searchableFields ?? []).map(String); assertExposed("publicApi.searchableFields", searchableFields); - const notSearchable = searchableFields.find( - name => !EXPLICIT_SEARCHABLE_KINDS.has(fields[name]?.kind), - ); + const notSearchable = searchableFields.find(name => { + const target = resolveFieldTarget(fields, name); + + return ( + target === null || + target.container === "repeatable" || + !EXPLICIT_SEARCHABLE_KINDS.has(target.descriptor.kind) + ); + }); if (notSearchable !== undefined) { throw new ContentEngineError( - `publicApi.searchableFields includes "${notSearchable}", which is not a text, textarea or slug field.`, + resolveFieldTarget(fields, notSearchable)?.container === "repeatable" + ? `publicApi.searchableFields includes the repeatable leaf "${notSearchable}", which lives on a child table rather than on the row. A list search is a predicate on the row; index it with \`search.contentFields\` instead.` + : `publicApi.searchableFields includes "${notSearchable}", which is not a text, textarea or slug field.`, { contentTypeId: id }, ); } const filterableFields = (publicApi.filterableFields ?? []).map(String); assertExposed("publicApi.filterableFields", filterableFields); - const notFilterable = filterableFields.find( - name => !isFilterableFieldKind(fields[name]?.kind ?? ""), - ); + const notFilterable = filterableFields.find(name => { + const target = resolveFieldTarget(fields, name); + if (target === null || target.container === "repeatable") return true; + // A to-many relation filters through an indexed EXISTS over its junction + // table rather than by equality, but it is still a `relation` kind - so the + // ordinary kind check accepts it and the query builder branches on `multiple`. + + return !isFilterableFieldKind(target.descriptor.kind); + }); if (notFilterable !== undefined) { throw new ContentEngineError( - `publicApi.filterableFields includes "${notFilterable}", which is not an equality-filterable field. Filterable kinds: ${CONTENT_FILTERABLE_FIELD_KINDS.join(", ")}.`, + resolveFieldTarget(fields, notFilterable)?.container === "repeatable" + ? `publicApi.filterableFields includes the repeatable leaf "${notFilterable}", which lives on a child table. Filtering by one would ask "does any child match", which is a different question from equality - write your own route for it.` + : `publicApi.filterableFields includes "${notFilterable}", which is not an equality-filterable field. Filterable kinds: ${CONTENT_FILTERABLE_FIELD_KINDS.join(", ")}.`, { contentTypeId: id }, ); } const declaredOrderable = (publicApi.orderableFields ?? []).map(String); assertExposed("publicApi.orderableFields", declaredOrderable); + const notOrderable = declaredOrderable.find(name => { + const target = resolveFieldTarget(fields, name); + + return ( + target !== null && + (target.container === "repeatable" || + isContentRelationCollection(target.descriptor)) + ); + }); + if (notOrderable !== undefined) { + throw new ContentEngineError( + `publicApi.orderableFields includes "${notOrderable}", which is not one column on the row: a repeatable leaf and a to-many relation are both sets, and a list cannot be ordered by a set.`, + { contentTypeId: id }, + ); + } // A localized column is not on the base table, and ordering by one would not // just be awkward to generate - it would be wrong. The list a reader pages // through would reshuffle itself for every language, and a fallback set would // interleave two collations, so the same cursor would mean two different // positions. Order by something the record has one of. - const localizedOrderable = declaredOrderable.find( - name => localizedFields[name] !== undefined, - ); + const localizedOrderable = declaredOrderable.find(name => { + // A leaf of a localized group is on the translation table, so it is exactly + // as unorderable as the localized field it belongs to. + const path = splitContentFieldPath(name); + + return localizedFields[path ? path[0] : name] !== undefined; + }); if (localizedOrderable !== undefined) { throw new ContentEngineError( `publicApi.orderableFields includes the localized field "${localizedOrderable}". A public list is ordered by a column of the record, not of one of its translations - ordering by a localized field would reorder the list per language and make a cursor mean two different positions across a fallback.`, @@ -691,6 +949,7 @@ const disabledSearch: ResolvedContentSearchConfig = { * function with anything at all. */ const assertSearchField = ({ + allowRepeatable = false, exposed, fields, id, @@ -698,6 +957,8 @@ const assertSearchField = ({ label, name, }: { + /** Whether a leaf of a child table may fill this slot. Only the body may. */ + allowRepeatable?: boolean; exposed: ReadonlySet; fields: ContentFieldMap; id: string; @@ -705,14 +966,23 @@ const assertSearchField = ({ label: string; name: string; }): void => { - const fieldValue = fields[name]; - if (!fieldValue) { + const target = resolveFieldTarget(fields, name); + if (!target) { throw new ContentEngineError( `${label} references unknown field "${name}".`, { contentTypeId: id }, ); } + const fieldValue = target.descriptor; + + if (target.container === "repeatable" && !allowRepeatable) { + throw new ContentEngineError( + `${label} names the repeatable leaf "${name}", which is many values rather than one. A heading and a description each have to be a single value; use \`search.contentFields\` for prose that repeats.`, + { contentTypeId: id }, + ); + } + if (!kinds.has(fieldValue.kind)) { throw new ContentEngineError( `${label} names "${name}" of kind "${fieldValue.kind}". Expected one of: ${[...kinds].sort().join(", ")}.`, @@ -861,7 +1131,7 @@ const resolveSearch = ( // reaches full coverage. Rejecting the nullable field is the cheap half of // that; a blank value written straight into the database is still possible, so // the mapper keeps its own check. - if (fields[titleField].nullable) { + if (resolveFieldTarget(fields, titleField)?.descriptor.nullable) { throw new ContentEngineError( `search.titleField names the nullable field "${titleField}". A search result needs a heading, so the title field must not be nullable.`, { contentTypeId: id }, @@ -905,6 +1175,10 @@ const resolveSearch = ( for (const name of contentFields) { assertSearchField({ + // The body is the one slot a repeatable leaf can fill: `faq.answer` is + // many values, and many values concatenated in position order is exactly + // what a searchable body is made of. + allowRepeatable: true, exposed, fields, id, @@ -1223,7 +1497,18 @@ export const defineContentType = < // 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; + // Before the rebind, which is the only moment a supplied `target` and the + // self-relation placeholder are still distinguishable. + assertContentRelationTargets(id, fields as unknown as ContentFieldMap); + + const fieldMap = bindSelfRelations( + fields as unknown as ContentFieldMap, + // Read lazily, so `definition` is fully assigned by the time a relation + // resolves. The widening is the same one `AnyContentTypeDefinition` exists + // for: a self-relation's target is read by code that cannot know which + // concrete content type it was handed. + () => definition as unknown as AnyContentTypeDefinition, + ); const fieldNames = Object.keys(fieldMap); if (fieldNames.length === 0) { throw new ContentEngineError("A content type needs at least one field.", { @@ -1242,14 +1527,34 @@ export const defineContentType = < assertSlugSources(id, fieldMap); + // First, because every resolver below is stated in terms of what it produces: + // the generated table names, and above all the one leaf-path -> column mapping + // the indexes, the schemas, the services, the revisions, the public projection + // and the AdminCP all read. It throws on every advanced-field mistake, so + // nothing downstream has to defend against a half-valid group. + const resolvedAdvanced = resolveContentAdvanced({ + fields: fieldMap, + id, + tableName, + }); + // The one partition every subsystem downstream of here reads. A localized // field is not a column on the base table, so it takes no part in the base // indexes, the admin surfaces or the base schemas. - const { localizedFields, sharedFields } = partitionContentFields(fieldMap); - const sharedFieldNames = Object.keys(sharedFields); + const { collectionFields, localizedFields, sharedFields } = + partitionContentFields(fieldMap); + // Groups flattened into the columns they generate, which is what an index and + // a unique constraint actually address. + const sharedColumns = contentStorageColumns(sharedFields); + const leafColumnByPath = new Map( + resolvedAdvanced.leaves.map(leaf => [leaf.path, leaf.columnName]), + ); const knownColumns = new Set([ - ...sharedFieldNames, + ...Object.keys(sharedColumns), + ...resolvedAdvanced.leaves + .filter(leaf => !leaf.localized) + .map(leaf => leaf.path), ...systemFields, ...(publicationEnabled ? publicationFields : []), ...(editorialEnabled ? editorialFields : []), @@ -1258,22 +1563,27 @@ export const defineContentType = < contentTypeId: id, declared: indexes.map(index => { const on = index.on.map(String); - assertNotLocalized(id, "indexes", on, localizedFields); + assertIndexable(id, on, fieldMap, localizedFields); assertKnownColumns(id, "indexes", on, knownColumns); - return { ...index, on }; + // Declared in canonical paths, materialised against real columns: the + // author writes `["seo.title"]` and the migration gets `seo_title`. + return { + ...index, + on: on.map(name => leafColumnByPath.get(name) ?? name), + }; }), // Shared only: a localized slug's unique index is scoped to a language and // belongs to the translation table, which `resolveContentTranslationIndexes` // builds. - fields: sharedFields, + fields: sharedColumns, publication: publicationEnabled, tableName, }); const resolvedAdmin = resolveAdmin( id, - sharedFields, + { ...sharedFields, ...collectionFields }, localizedFields, admin, publicationEnabled, @@ -1333,14 +1643,27 @@ export const defineContentType = < tableName, }); - return { + const definition: ContentTypeDefinition< + TId, + TFields, + TPublication, + TPublicField, + TPublicEnabled, + ContentSearchEnabled, + ContentEditorialEnabled, + ContentPreviewEnabled, + ContentSchedulingEnabled, + ContentLocalizationEnabled + > = { admin: resolvedAdmin, + advanced: resolvedAdvanced, editorial: resolvedEditorial as ResolvedContentEditorialConfig< ContentEditorialEnabled, ContentPreviewEnabled, ContentSchedulingEnabled >, - fields, + // The rebound copy, so a self-relation resolves rather than throwing. + fields: fieldMap as unknown as TFields, id, indexes: resolvedIndexes, localization: resolvedLocalization as ResolvedContentLocalizationConfig< @@ -1369,6 +1692,7 @@ export const defineContentType = < > >({ admin: resolvedAdmin, + advanced: resolvedAdvanced, editorial: editorialEnabled, fields: fieldMap, localization: resolvedLocalization, @@ -1380,4 +1704,32 @@ export const defineContentType = < >, tableName, }; + + return definition; +}; + +/** + * Rebinds every `self: true` relation to the definition being built. + * + * On a **copy** of the field map, never in place: a descriptor object can be a + * shared `const` reused by several content types, and mutating it would point + * one content type's relation at another's table. The copy is what the + * definition carries, so `field.target()` resolves correctly everywhere + * downstream - and the thunk is read lazily, so `definition` is fully assigned + * by the time anybody calls it. + */ +const bindSelfRelations = ( + fields: ContentFieldMap, + self: () => AnyContentTypeDefinition, +): ContentFieldMap => { + const bound: ContentFieldMap = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + bound[name] = + fieldValue.kind === "relation" && fieldValue.self + ? { ...fieldValue, target: self } + : fieldValue; + } + + return bound; }; diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index 5ba631d78..cb036d1b5 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -1,5 +1,9 @@ +import type { CONTENT_ADVANCED_CODES } from "./const"; import type { ContentScheduleCode } from "./schedules"; +export type ContentAdvancedCode = + (typeof CONTENT_ADVANCED_CODES)[keyof typeof CONTENT_ADVANCED_CODES]; + /** * Thrown while a content type definition is being built or registered - always * at import/boot time, never per request. The message names the offending @@ -273,6 +277,46 @@ export class ContentTranslationItemMissing extends ContentEngineError { readonly itemId: number; } +/** + * A collection write the engine understood but cannot apply: a relation target + * that does not exist, or a repeatable child that belongs to another record. + * + * Per-request, like {@link ContentInputError}, and checked **before** anything is + * written so a collection mutation stays all or nothing. It exists rather than + * leaving the foreign key to fail because the driver's `23503` cannot say which + * of a junction row's two references was the bad one - and "category 99 is gone" + * and "article 99 is gone" want different answers. + * + * `ids` names the offending identifiers and nothing else. It is the caller's own + * input echoed back, so there is nothing internal in it for a client to read. + */ +export class ContentAdvancedInputError extends ContentInputError { + constructor({ + code, + contentTypeId, + field, + ids, + message, + }: { + code: ContentAdvancedCode; + contentTypeId: string; + field: string; + ids: number[]; + message: string; + }) { + super(message, { contentTypeId }); + + this.name = "ContentAdvancedInputError"; + this.code = code; + this.field = field; + this.ids = ids; + } + + readonly code: ContentAdvancedCode; + readonly field: string; + readonly ids: number[]; +} + /** * A schedule that does not make sense: a time already past, or an unpublish * that would fire before the publish it is meant to follow. diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index f5089bd93..0a2b0d46b 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -3,9 +3,11 @@ import type { ContentBooleanField, ContentDateTimeField, ContentEnumField, + ContentGroupField, ContentNumberField, ContentOnDelete, ContentRelationField, + ContentRepeatableField, ContentSlugField, ContentSlugRequired, ContentTextareaField, @@ -13,6 +15,8 @@ import type { ContentUserField, } from "./types"; +import { ContentEngineError } from "./errors"; + interface SharedArgs< TRequired extends boolean = false, TNullable extends boolean = false, @@ -220,19 +224,154 @@ const user = < }; }; +/** + * The placeholder a `self: true` relation carries until it is rebound. + * + * Throws rather than returning something plausible: reaching it means + * `defineContentType` did not rebind the thunk, and a relation silently + * pointing at the wrong table is a data bug rather than a crash. + */ +export const unboundSelfTarget = (): AnyContentTypeDefinition => { + throw new ContentEngineError( + "A `self: true` relation was read before `defineContentType` bound it. Build the field inside a `defineContentType` call.", + ); +}; + +/** + * A reference to rows of another content type - or of this one. + * + * ```ts + * category: field.relation({ target: () => categoryContentType }) + * categories: field.relation({ target: () => categoryContentType, multiple: true }) + * related: field.relation({ self: true, multiple: true, ordered: true }) + * ``` + * + * `target` is a thunk, so two content types can point at each other without a + * circular import. A **self**-relation uses `self: true` instead, and the + * difference is not stylistic: `target: () => thisContentType` would make the + * definition's own inferred type circular, and TypeScript resolves that by + * widening the whole definition to `any` - taking every nested value type and + * every allowlist check with it, silently. + * + * `multiple: true` moves the value off the row into a generated junction table. + * A to-many relation is therefore never `required` and never `nullable` - the + * empty set is what "no targets" looks like - and `defineContentType` rejects + * both arguments alongside it. + * + * `ordered: true` keeps the author's order. Without it the set comes back in + * ascending target-id order, which is still deterministic; it is simply not + * something anybody chose. + * + * Exactly one of `self` and `target` is required. It is checked by + * `defineContentType` rather than by a union in this signature, because a union + * here would stop TypeScript inferring `self` as a literal - and + * `ContentReferences` reads that literal to decide which relations the database + * module has to supply a thunk for. The check still fails at import time. + */ const relation = < TRequired extends boolean = false, TNullable extends boolean = false, + TMultiple extends boolean = false, + TOrdered extends boolean = false, + TSelf extends boolean = false, >( args: SharedArgs & { + multiple?: TMultiple; onDelete?: ContentOnDelete; - target: () => AnyContentTypeDefinition; + ordered?: TOrdered; + /** The target is this content type. Mutually exclusive with `target`. */ + self?: TSelf; + target?: () => AnyContentTypeDefinition; }, -): ContentRelationField => ({ +): ContentRelationField => ({ ...args, ...shared(args), kind: "relation", + // The assertions keep the literal the caller inferred, exactly as `shared` + // and `localizedOf` do: `?? false` alone widens back to `boolean`, and every + // `multiple extends true` partition would resolve to the to-one branch. + multiple: (args.multiple ?? false) as TMultiple, onDelete: args.onDelete ?? "restrict", + ordered: (args.ordered ?? false) as TOrdered, + self: (args.self ?? false) as TSelf, + target: args.target ?? unboundSelfTarget, +}); + +/** + * A reusable structured group: several related leaves under one name. + * + * ```ts + * const seoGroup = field.group({ + * fields: { + * title: field.text({ nullable: true }), + * description: field.textarea({ nullable: true }), + * }, + * }); + * + * // then, in as many content types as you like: + * fields: { title: field.text({ required: true }), seo: seoGroup } + * ``` + * + * The value stays nested (`row.seo.title`); the storage stays relational (a + * `seo_title` column, indexable and constrainable like any other). Leaves are + * scalars - see `CONTENT_ADVANCED_LEAF_KINDS` for why each other kind is out. + * + * `localized: true` moves the **whole** group into the translation table. + * Marking one leaf is a definition-time error: half a logical value on each + * table would mean two revision histories and two permissions for one thing an + * editor sees as one box. + */ +const group = < + const TFields extends Record, + TRequired extends boolean = false, + TNullable extends boolean = false, + TLocalized extends boolean = false, +>( + args: LocalizableArgs & + SharedArgs & { fields: TFields }, +): ContentGroupField => ({ + ...args, + ...shared(args), + kind: "group", + localized: localizedOf(args), +}); + +/** + * A repeatable structured group: zero or more ordered child rows. + * + * ```ts + * faq: field.repeatable({ + * fields: { + * question: field.text({ required: true }), + * answer: field.textarea({ required: true }), + * }, + * }) + * ``` + * + * Stored in a generated child table with a `serial` primary key, so every child + * keeps a stable identity across reorders - which is what makes "update child + * 11" and "restore the row that used to be here" mean anything. + * + * Never nullable, never required and never localized. The first two because the + * empty array already says "nothing here"; the third because a per-language list + * of *different lengths* has no defensible restore or reorder semantics, and + * guessing one is worse than saying no. `field.repeatable({ localized: true })` + * is a definition-time error with that explanation. + */ +const repeatable = >( + args: { + description?: string; + fields: TFields; + /** Upper bound on child rows. Defaults to 100. */ + max?: number; + /** Lower bound on child rows. Defaults to none. */ + min?: number; + } & { localized?: never }, +): ContentRepeatableField => ({ + ...args, + kind: "repeatable", + nullable: false, + required: false, }); /** @@ -244,8 +383,10 @@ export const field = { boolean, dateTime, enum: enumField, + group, number, relation, + repeatable, slug, text, textarea, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 7ba73c95a..98b55d524 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -118,7 +118,9 @@ export { RESERVED_FILTER_KEYS, } from "./const"; export { defineContentType } from "./define"; +export type { ContentAdvancedCode } from "./errors"; export { + ContentAdvancedInputError, ContentDefaultTranslationRequired, ContentEngineError, ContentInputError, diff --git a/packages/vitnode/src/content/localization.test.ts b/packages/vitnode/src/content/localization.test.ts index 7cb518858..6b578d877 100644 --- a/packages/vitnode/src/content/localization.test.ts +++ b/packages/vitnode/src/content/localization.test.ts @@ -247,7 +247,7 @@ describe("localization validation", () => { }, admin: { label: { plural: "Bad", singular: "Bad" } }, }), - ).toThrow(/Only slug, text, textarea fields can be localized/); + ).toThrow(/Only slug, text, textarea fields and `field.group`/); }); it("rejects a localized field named after a translation column", () => { diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts index e3f02ccd7..6eedb91ce 100644 --- a/packages/vitnode/src/content/localization.ts +++ b/packages/vitnode/src/content/localization.ts @@ -18,6 +18,7 @@ import { import { ContentEngineError } from "./errors"; import { clampWithFingerprint } from "./fingerprint"; import { resolveContentTranslationIndexes } from "./indexes"; +import { contentStorageColumns, isContentCollectionField } from "./paths"; /** * The one place that decides whether a field is localized. @@ -33,6 +34,18 @@ export const isLocalizedContentField = ( ): boolean => fieldValue.localized === true; export interface ContentFieldPartition { + /** + * Fields whose value lives outside the row: a to-many relation (junction + * table) and a repeatable (child table). + * + * Present on the partition rather than left for each caller to filter, + * because "which fields are columns" is the same question the shared/localized + * split answers and it has to be answered in the same place. Every existing + * caller reads `sharedFields` and `localizedFields`, both of which now exclude + * these - so a Stage 1-5 content type, which declares none, sees exactly the + * partition it always did. + */ + collectionFields: ContentFieldMap; /** Fields stored in the translation table, one row per language. */ localizedFields: ContentFieldMap; /** Fields stored on the base table. */ @@ -42,16 +55,25 @@ export interface ContentFieldPartition { /** * Splits a field map into its base-table and translation-table halves. * - * Declaration order is preserved in both, so the generated column order, the - * generated schema key order and the migration all stay deterministic. + * Declaration order is preserved in all three, so the generated column order, + * the generated schema key order and the migration all stay deterministic. + * + * A `group` lands in whichever half its own `localized` flag names, whole: its + * leaves are flattened into columns of that one table by + * {@link contentStorageColumns}, never split across both. */ export const partitionContentFields = ( fields: ContentFieldMap, ): ContentFieldPartition => { + const collectionFields: ContentFieldMap = {}; const localizedFields: ContentFieldMap = {}; const sharedFields: ContentFieldMap = {}; for (const [name, fieldValue] of Object.entries(fields)) { + if (isContentCollectionField(fieldValue)) { + collectionFields[name] = fieldValue; + continue; + } if (isLocalizedContentField(fieldValue)) { localizedFields[name] = fieldValue; continue; @@ -59,7 +81,7 @@ export const partitionContentFields = ( sharedFields[name] = fieldValue; } - return { localizedFields, sharedFields }; + return { collectionFields, localizedFields, sharedFields }; }; /** `example_articles` -> `example_articles_translations`. */ @@ -141,9 +163,15 @@ const assertLocalizedFields = ( } for (const [name, fieldValue] of Object.entries(localizedFields)) { - if (!isLocalizableFieldKind(fieldValue.kind)) { + // A `group` is localizable whatever its leaves are: it moves whole, and its + // leaves are already restricted to the scalar kinds by `resolveContentAdvanced`. + // What a leaf holds is a question about the group, not about localization. + if ( + fieldValue.kind !== "group" && + !isLocalizableFieldKind(fieldValue.kind) + ) { throw new ContentEngineError( - `Field "${name}" is \`localized: true\` but its kind is "${fieldValue.kind}". Only ${CONTENT_LOCALIZED_FIELD_KINDS.join(", ")} fields can be localized - an enum's identifiers have to be the same in every language, and a relation's target is shared.`, + `Field "${name}" is \`localized: true\` but its kind is "${fieldValue.kind}". Only ${CONTENT_LOCALIZED_FIELD_KINDS.join(", ")} fields and \`field.group\` can be localized - an enum's identifiers have to be the same in every language, and a relation's target is shared.`, { contentTypeId: id }, ); } @@ -156,6 +184,21 @@ const assertLocalizedFields = ( } } + // A localized group's leaves become columns on the *translation* table, where + // the reserved names are different from the base table's. `seo.version` would + // pass every base-table check and then shadow the translation's optimistic + // lock, so it is caught here rather than at the first conditional UPDATE. + for (const columnName of Object.keys( + contentStorageColumns(localizedFields), + )) { + if (!translationSystemFields.includes(columnName)) continue; + + throw new ContentEngineError( + `A localized group leaf compiles to the column "${columnName}", which the translation table generates for itself. Rename the leaf - the translation table always carries ${translationSystemFields.join(", ")}.`, + { contentTypeId: id }, + ); + } + for (const [name, fieldValue] of Object.entries(fields)) { if (fieldValue.kind !== "slug" || fieldValue.source === undefined) continue; diff --git a/packages/vitnode/src/content/paths.test.ts b/packages/vitnode/src/content/paths.test.ts new file mode 100644 index 000000000..aa77d0a0a --- /dev/null +++ b/packages/vitnode/src/content/paths.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentFieldMap } from "./types"; + +import { field } from "./fields"; +import { + contentColumnsToValues, + contentFieldPath, + contentLeafColumnName, + contentLeafColumns, + contentStorageColumns, + contentValuesToColumns, + isContentCollectionField, + partitionContentStorage, + readContentPath, + splitContentFieldPath, +} from "./paths"; + +/** + * The one leaf-path <-> column mapping, tested on its own. + * + * Everything downstream - the table generator, the schemas, the services, the + * revision snapshotter, the public projector, the search mapper and the AdminCP + * - reads this module rather than re-deriving the rule, so a bug here would be + * a bug in all of them at once. + */ + +const target = { id: "test.target", tableName: "test_targets" }; + +const fields = { + categories: field.relation({ + multiple: true, + target: () => target as never, + }), + faq: field.repeatable({ + fields: { answer: field.textarea({ required: true }) }, + }), + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + nullable: true, + }), + syndication: field.group({ + fields: { indexable: field.boolean({ defaultValue: true }) }, + }), + title: field.text({ required: true }), +} as unknown as ContentFieldMap; + +describe("paths", () => { + it("builds and splits a canonical path", () => { + expect(contentFieldPath("seo", "title")).toBe("seo.title"); + expect(splitContentFieldPath("seo.title")).toStrictEqual(["seo", "title"]); + }); + + it("is not a path when there is nothing on one side", () => { + expect(splitContentFieldPath("title")).toBeNull(); + expect(splitContentFieldPath(".title")).toBeNull(); + expect(splitContentFieldPath("seo.")).toBeNull(); + }); + + it("refuses a two-level path rather than reading it as one", () => { + // `a.b.c` is not something this engine can mean anything by, and silently + // reading it as `a` + `b.c` would generate a column nobody declared. + expect(splitContentFieldPath("a.b.c")).toBeNull(); + }); + + it("compiles a leaf to camelCase, like every other VitNode column", () => { + expect(contentLeafColumnName("seo", "title")).toBe("seoTitle"); + expect(contentLeafColumnName("seo", "metaDescription")).toBe( + "seoMetaDescription", + ); + }); +}); + +describe("contentLeafColumns", () => { + it("emits every group leaf in declaration order", () => { + expect(contentLeafColumns(fields).map(leaf => leaf.path)).toStrictEqual([ + "seo.description", + "seo.title", + "syndication.indexable", + ]); + }); + + it("reads localization off the group rather than the leaf", () => { + const localized = contentLeafColumns({ + seo: field.group({ + fields: { title: field.text({ nullable: true }) }, + localized: true, + }), + }); + + expect(localized[0].localized).toBe(true); + }); +}); + +describe("contentStorageColumns", () => { + it("flattens groups and drops collections", () => { + expect(Object.keys(contentStorageColumns(fields))).toStrictEqual([ + "seoDescription", + "seoTitle", + "syndicationIndexable", + "title", + ]); + }); + + it("keeps a leaf's own nullability", () => { + const columns = contentStorageColumns(fields); + + // The definition-time rules already prove both states are storable, so + // nothing is relaxed here - `syndicationIndexable` stays NOT NULL DEFAULT. + expect(columns.syndicationIndexable.nullable).toBe(false); + expect(columns.seoTitle.nullable).toBe(true); + }); +}); + +describe("partitionContentStorage", () => { + it("splits a field map by where each value is stored", () => { + const partition = partitionContentStorage(fields); + + expect(Object.keys(partition.scalars)).toStrictEqual(["title"]); + expect(Object.keys(partition.groups)).toStrictEqual(["seo", "syndication"]); + expect(Object.keys(partition.relationCollections)).toStrictEqual([ + "categories", + ]); + expect(Object.keys(partition.repeatables)).toStrictEqual(["faq"]); + }); + + it("counts both collection kinds as not-a-column", () => { + expect(isContentCollectionField(fields.categories)).toBe(true); + expect(isContentCollectionField(fields.faq)).toBe(true); + expect(isContentCollectionField(fields.seo)).toBe(false); + expect(isContentCollectionField(fields.title)).toBe(false); + }); +}); + +describe("contentValuesToColumns", () => { + it("emits only the leaves the caller supplied", () => { + // The whole of what makes a partial group update partial: `seoTitle` is not + // in the statement, so a concurrent edit of it is not overwritten. + expect( + contentValuesToColumns(fields, { seo: { description: "New" } }), + ).toStrictEqual({ seoDescription: "New" }); + }); + + it("expands null into every leaf", () => { + expect(contentValuesToColumns(fields, { seo: null })).toStrictEqual({ + seoDescription: null, + seoTitle: null, + }); + }); + + it("ignores a leaf the group does not declare", () => { + expect( + contentValuesToColumns(fields, { seo: { keywords: "no" } }), + ).toStrictEqual({}); + }); + + it("drops collections, which are written by the store instead", () => { + expect( + contentValuesToColumns(fields, { categories: [1], faq: [], title: "T" }), + ).toStrictEqual({ title: "T" }); + }); +}); + +describe("contentColumnsToValues", () => { + it("folds leaf columns back into a nested object", () => { + expect( + contentColumnsToValues(fields, { + seoDescription: "D", + seoTitle: "T", + syndicationIndexable: true, + title: "Hello", + }), + ).toStrictEqual({ + seo: { description: "D", title: "T" }, + syndication: { indexable: true }, + title: "Hello", + }); + }); + + it("reads a nullable group back as null when every leaf is empty", () => { + const values = contentColumnsToValues(fields, { + seoDescription: null, + seoTitle: null, + }); + + // The exact inverse of what writing `null` does - which is why a nullable + // group requires nullable leaves. + expect(values.seo).toBeNull(); + }); + + it("keeps a partly-filled nullable group as an object", () => { + const values = contentColumnsToValues(fields, { + seoDescription: null, + seoTitle: "T", + }); + + expect(values.seo).toStrictEqual({ description: null, title: "T" }); + }); + + it("omits a group whose columns were not selected", () => { + // A projection that left `seo` out must not make it look as if the record + // has no SEO. + expect(contentColumnsToValues(fields, { title: "Hello" })).toStrictEqual({ + title: "Hello", + }); + }); +}); + +describe("readContentPath", () => { + const values = { seo: { title: "T" }, title: "Hello" }; + + it("reads a top-level value and a leaf", () => { + expect(readContentPath(values, "title")).toBe("Hello"); + expect(readContentPath(values, "seo.title")).toBe("T"); + }); + + it("answers null through a group that is null", () => { + expect(readContentPath({ seo: null }, "seo.title")).toBeNull(); + }); + + it("answers null for a leaf that is not there", () => { + expect(readContentPath(values, "seo.description")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/paths.ts b/packages/vitnode/src/content/paths.ts new file mode 100644 index 000000000..53d29797f --- /dev/null +++ b/packages/vitnode/src/content/paths.ts @@ -0,0 +1,378 @@ +import type { + ContentFieldDescriptor, + ContentFieldMap, + ContentLeafColumn, + ContentRelationField, + ContentRepeatableField, +} from "./types"; + +import { CONTENT_ADVANCED_LEAF_KINDS, CONTENT_PATH_SEPARATOR } from "./const"; + +/** + * The one place a canonical field path is built, split or turned into a column. + * + * Stage 6 gives one logical value two representations: `seo.title`, which every + * subsystem speaks, and `seoTitle`, which only Postgres speaks. Every subsystem + * that needs the second one asks *this* module for it - the table generator, the + * schemas, the services, the revision snapshotter, the public projector, the + * search mapper and the AdminCP alike - so the mapping cannot be reinvented + * three times and disagree on the fourth. + */ + +/** `("seo", "title")` -> `"seo.title"`. */ +export const contentFieldPath = (owner: string, leaf: string): string => + `${owner}${CONTENT_PATH_SEPARATOR}${leaf}`; + +/** `"seo.title"` -> `["seo", "title"]`, or `null` when it is not a path. */ +export const splitContentFieldPath = ( + path: string, +): [string, string] | null => { + const separator = path.indexOf(CONTENT_PATH_SEPARATOR); + if (separator <= 0 || separator === path.length - 1) return null; + + const owner = path.slice(0, separator); + const leaf = path.slice(separator + 1); + // Exactly one separator: `a.b.c` is not a path this engine can mean anything + // by, and silently reading it as `a` + `b.c` would generate a column nobody + // declared. + if (leaf.includes(CONTENT_PATH_SEPARATOR)) return null; + + return [owner, leaf]; +}; + +export const isContentFieldPath = (path: string): boolean => + splitContentFieldPath(path) !== null; + +/** + * `("seo", "title")` -> `"seoTitle"`. + * + * camelCase, because that is what every VitNode column is called in SQL too: + * Drizzle is configured with no `casing` transform, so a column key is the + * column name verbatim - `createdAt`, not `created_at`. A leaf column joining + * that convention rather than inventing a second one is what keeps a generated + * migration readable next to a hand-written table. + */ +export const contentLeafColumnName = (owner: string, leaf: string): string => + `${owner}${leaf.charAt(0).toUpperCase()}${leaf.slice(1)}`; + +const leafKinds: ReadonlySet = new Set(CONTENT_ADVANCED_LEAF_KINDS); + +/** Whether a descriptor may sit inside a group or a repeatable. */ +export const isContentLeafKind = (kind: string): boolean => leafKinds.has(kind); + +export const isContentGroupField = ( + fieldValue: ContentFieldDescriptor, +): boolean => fieldValue.kind === "group"; + +export const isContentRepeatableField = ( + fieldValue: ContentFieldDescriptor, +): boolean => fieldValue.kind === "repeatable"; + +/** + * Whether a relation holds many targets. + * + * A function rather than `fieldValue.multiple === true` at each call site, for + * the same reason `isLocalizedContentField` is one: it is the rule that decides + * whether a field is a column or a junction table, and two copies of a rule like + * that is the pair that drifts. + * + * Deliberately **not** a type guard. Narrowing the argument would also narrow + * every `else` branch to "not a relation at all", and the to-one branch is + * exactly what those branches are usually about. + */ +export const isContentRelationCollection = ( + fieldValue: ContentFieldDescriptor, +): boolean => fieldValue.kind === "relation" && fieldValue.multiple; + +/** The to-many relation descriptor, or `null` when the field is not one. */ +export const asContentRelationCollection = ( + fieldValue: ContentFieldDescriptor, +): ContentRelationField | null => + fieldValue.kind === "relation" && fieldValue.multiple ? fieldValue : null; + +/** The repeatable descriptor, or `null` when the field is not one. */ +export const asContentRepeatableField = ( + fieldValue: ContentFieldDescriptor, +): ContentRepeatableField | null => + fieldValue.kind === "repeatable" ? fieldValue : null; + +/** + * Whether a field's value lives somewhere other than the row it belongs to. + * + * The subtraction behind every "columns only" view in the engine. + */ +export const isContentCollectionField = ( + fieldValue: ContentFieldDescriptor, +): boolean => + isContentRepeatableField(fieldValue) || + isContentRelationCollection(fieldValue); + +/** The inner field map of a group or a repeatable, or an empty one. */ +export const contentInnerFields = ( + fieldValue: ContentFieldDescriptor, +): ContentFieldMap => { + if (fieldValue.kind === "group" || fieldValue.kind === "repeatable") { + return fieldValue.fields; + } + + return {}; +}; + +/** + * Every group leaf of a field map, in declaration order. + * + * Order matters and is load-bearing: it decides generated column order, schema + * key order and therefore the bytes of a revision snapshot, so two equal states + * serialise identically and a diff is a table rather than a set comparison. + */ +export const contentLeafColumns = ( + fields: ContentFieldMap, +): ContentLeafColumn[] => { + const leaves: ContentLeafColumn[] = []; + + for (const [group, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "group") continue; + + for (const leaf of Object.keys(contentInnerFields(fieldValue))) { + leaves.push({ + columnName: contentLeafColumnName(group, leaf), + group, + leaf, + localized: fieldValue.localized, + path: contentFieldPath(group, leaf), + }); + } + } + + return leaves; +}; + +/** + * A field map flattened into the columns it actually generates. + * + * A scalar keeps its own name; a group contributes one entry per leaf under the + * generated column name. Groups themselves disappear, and so do the two + * collection kinds - they are not columns on this table at all. + * + * This is what the table generator, the `SELECT` maps and the `toColumnValues` + * coercion all read, which is why they need to know nothing about groups. + */ +export const contentStorageColumns = ( + fields: ContentFieldMap, +): ContentFieldMap => { + const columns: ContentFieldMap = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (isContentCollectionField(fieldValue)) continue; + + if (fieldValue.kind !== "group") { + columns[name] = fieldValue; + continue; + } + + const inner = contentInnerFields(fieldValue); + for (const [leaf, leafValue] of Object.entries(inner)) { + // The leaf's own nullability, unchanged. `resolveContentAdvanced` has + // already proven the two states a group can be in are both storable: a + // `nullable: true` group has all-nullable leaves, so `seo: null` can blank + // every column, and an optional group has leaves that are nullable or + // defaulted, so omitting it writes something valid. Nothing has to be + // relaxed here, which is why `syndicationIndexable` comes out + // `NOT NULL DEFAULT true` rather than merely defaulted. + columns[contentLeafColumnName(name, leaf)] = leafValue; + } + } + + return columns; +}; + +export interface ContentAdvancedPartition { + /** Group descriptors, by field name. */ + groups: ContentFieldMap; + /** To-many relation descriptors, by field name. */ + relationCollections: ContentFieldMap; + /** Repeatable descriptors, by field name. */ + repeatables: ContentFieldMap; + /** Everything that is one column on the row: scalars only. */ + scalars: ContentFieldMap; +} + +/** Splits a field map by *where and how* each field is stored. */ +export const partitionContentStorage = ( + fields: ContentFieldMap, +): ContentAdvancedPartition => { + const groups: ContentFieldMap = {}; + const relationCollections: ContentFieldMap = {}; + const repeatables: ContentFieldMap = {}; + const scalars: ContentFieldMap = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (isContentRelationCollection(fieldValue)) { + relationCollections[name] = fieldValue; + continue; + } + if (fieldValue.kind === "repeatable") { + repeatables[name] = fieldValue; + continue; + } + if (fieldValue.kind === "group") { + groups[name] = fieldValue; + continue; + } + scalars[name] = fieldValue; + } + + return { groups, relationCollections, repeatables, scalars }; +}; + +/** + * Turns a logical (nested) value object into the flat column record Drizzle + * writes. + * + * Only the keys the caller actually supplied are emitted, which is what makes a + * partial group update partial: `{ seo: { description } }` produces exactly + * `{ seoDescription }` and leaves `seo_title` untouched by the `UPDATE`. + * + * `seo: null` is the one expansion: it produces every leaf column set to `null`, + * because "this group has no value" is stored as "none of its leaves do". + */ +export const contentValuesToColumns = ( + fields: ContentFieldMap, + values: Record, +): Record => { + const columns: Record = {}; + + for (const [name, value] of Object.entries(values)) { + const fieldValue = fields[name]; + + if (fieldValue?.kind !== "group") { + // A collection is not a column, so it is dropped here and written by the + // store instead - which is what lets a create pass one payload to both. + if (fieldValue && isContentCollectionField(fieldValue)) continue; + + columns[name] = value; + continue; + } + + const inner = contentInnerFields(fieldValue); + + if (value === null) { + for (const leaf of Object.keys(inner)) { + columns[contentLeafColumnName(name, leaf)] = null; + } + continue; + } + + if (typeof value !== "object") continue; + + for (const [leaf, leafValue] of Object.entries( + value as Record, + )) { + if (!(leaf in inner)) continue; + + columns[contentLeafColumnName(name, leaf)] = leafValue; + } + } + + return columns; +}; + +/** + * Turns a flat database row back into the logical (nested) shape. + * + * A nullable group whose every leaf column is `NULL` reads back as `null` rather + * than as an object of nulls - the round trip of the expansion above, and the + * reason a nullable group requires every leaf to be nullable: without that rule + * "the group is absent" and "one leaf happens to be empty" would be the same + * row. + */ +export const contentColumnsToValues = ( + fields: ContentFieldMap, + row: Record, +): Record => { + const values: Record = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (isContentCollectionField(fieldValue)) continue; + + if (fieldValue.kind !== "group") { + if (name in row) values[name] = row[name]; + continue; + } + + const inner = contentInnerFields(fieldValue); + const leaves = Object.keys(inner); + // A group whose columns were not selected is absent from the result, rather + // than present and empty - a projection that left `seo` out must not make it + // look as if the record has no SEO. + if (!leaves.some(leaf => contentLeafColumnName(name, leaf) in row)) { + continue; + } + + const nested: Record = {}; + let allNull = true; + + for (const leaf of leaves) { + const value = row[contentLeafColumnName(name, leaf)] ?? null; + if (value !== null) allNull = false; + + nested[leaf] = value; + } + + values[name] = fieldValue.nullable && allNull ? null : nested; + } + + return values; +}; + +/** + * Reads one group leaf out of a row, whichever shape the row is in. + * + * A database row carries the flattened column (`seoTitle`); a logical row - a + * translation's `values`, a restore's patch - carries the nested object + * (`seo.title`). Both reach the snapshotter and the differ, and both have to + * produce the same answer, so the "which shape is this" question is asked in + * exactly one place. + * + * `undefined` means the leaf is not represented at all, which a caller has to be + * able to tell from a stored `null`: a projection that did not select `seo` must + * not look like a record whose SEO is empty. + */ +export const readContentLeaf = ( + values: Record, + group: string, + leaf: string, +): unknown => { + const column = contentLeafColumnName(group, leaf); + if (column in values) return values[column] ?? null; + + if (!(group in values)) return undefined; + + const nested = values[group]; + if (nested === null || nested === undefined) return null; + if (typeof nested !== "object" || Array.isArray(nested)) return undefined; + + return (nested as Record)[leaf] ?? null; +}; + +/** + * Reads one canonical path out of a logical value object. + * + * `"title"` reads a top-level value; `"seo.title"` reads through the group and + * answers `null` when the group itself is `null`. Used by the search mapper and + * the public projector, both of which are handed paths from configuration. + */ +export const readContentPath = ( + values: Record, + path: string, +): unknown => { + const parts = splitContentFieldPath(path); + if (!parts) return values[path]; + + const [owner, leaf] = parts; + const container = values[owner]; + if (container === null || container === undefined) return null; + if (typeof container !== "object" || Array.isArray(container)) return null; + + return (container as Record)[leaf] ?? null; +}; diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index f9a3d0a78..793a0f6b7 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -33,7 +33,30 @@ export interface ContentActor { * runtime class instance in a snapshot, so re-reading one years later needs * nothing but `JSON.parse`. */ -export type ContentSnapshotValue = boolean | null | number | string; +export type ContentSnapshotScalar = boolean | null | number | string; + +/** + * A value as it is stored in a snapshot. + * + * Still plain JSON - re-reading a snapshot years later needs nothing but + * `JSON.parse` - but Stage 6 gives it three shapes rather than one: + * + * - a **scalar**, as before; + * - a **group**, as the nested object the field actually is (`{ title, description }`), + * or `null`. Never the flattened `seo_title` columns: a snapshot records the + * logical state, and the column names are an internal mapping that a schema + * change is allowed to move; + * - a **collection**, as identity. A to-many relation is `[2, 5, 9]` - the ids, + * in stored order - and a repeatable is its child rows, each with its own `id`. + * Never the *expanded* related records: those have their own history, their own + * permissions and their own publication state, and restoring an article must + * not rewrite a category. + */ +export type ContentSnapshotValue = + | ContentSnapshotScalar + | number[] + | Record + | Record[]; /** * The complete post-mutation editable state of one record. @@ -138,6 +161,26 @@ export interface ContentRevisionDiffEntry { name: string; } +/** + * Whether two snapshot values are the same. + * + * `JSON.stringify` rather than `===`, because a group and a collection are + * objects: two structurally equal `seo` groups are the same state, and the + * revision list must not claim otherwise. Key order is deterministic - both + * sides are built by `contentRevisionSnapshot`, which emits declaration order - + * so the comparison is exact rather than approximate. + */ +const sameSnapshotValue = ( + before: ContentSnapshotValue | undefined, + after: ContentSnapshotValue | undefined, +): boolean => { + if (before === after) return true; + if (before === undefined || after === undefined) return false; + if (typeof before !== "object" || typeof after !== "object") return false; + + return JSON.stringify(before) === JSON.stringify(after); +}; + /** * Field-level difference between two snapshots, in declaration order. * @@ -160,7 +203,7 @@ export const contentRevisionDiff = ( const previous = before?.fields[name]; const next = after.fields[name]; - if (before !== null && previous === next) continue; + if (before !== null && sameSnapshotValue(previous, next)) continue; entries.push({ after: next, before: previous, name }); } diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 97780419a..f7dc8cad7 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -11,16 +11,23 @@ import type { ContentSelect, ContentUpdateInput, ResolvedContentAdminConfig, + ResolvedContentAdvancedConfig, ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, } from "./types"; +import { + contentAdvancedDisabled, + contentRepeatableMax, + contentRepeatableMin, +} from "./advanced"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_LOCALE_MAX_LENGTH, CONTENT_PUBLIC_ALWAYS_ORDERABLE, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, + CONTENT_RELATION_COLLECTION_MAX, CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, isFilterableFieldKind, @@ -29,6 +36,11 @@ import { contentLocalizationDisabled, partitionContentFields, } from "./localization"; +import { + contentInnerFields, + isContentRelationCollection, + splitContentFieldPath, +} from "./paths"; /** What a content type without `publicApi` carries: nothing exposed at all. */ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { @@ -84,6 +96,15 @@ export interface ContentTranslationSchemas< } export interface ContentSchemas { + /** + * The advanced collections of one record: a `number[]` per to-many relation + * and an array of identified children per repeatable. + * + * An empty object for a content type that declares neither, which is what + * lets a generated route compose it unconditionally and still produce exactly + * the response schema it produced in Stage 5. + */ + advancedSelect: z.ZodObject; /** * Request body for create. Rejects unknown keys and system columns. * @@ -191,11 +212,39 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { return z.date(); case "enum": return z.enum(fieldValue.values); + case "group": { + const inner = contentInnerFields(fieldValue); + + return z.object( + Object.fromEntries( + Object.entries(inner).map(([leaf, leafValue]) => [ + leaf, + applyNullable(baseSelectSchema(leafValue), leafValue), + ]), + ), + ); + } case "number": return numberSchema(fieldValue); case "relation": - case "user": - return referenceSchema(); + return fieldValue.multiple + ? z.array(referenceSchema()) + : referenceSchema(); + case "repeatable": { + const inner = contentInnerFields(fieldValue); + + return z.array( + z.object({ + id: referenceSchema(), + ...Object.fromEntries( + Object.entries(inner).map(([leaf, leafValue]) => [ + leaf, + applyNullable(baseSelectSchema(leafValue), leafValue), + ]), + ), + }), + ); + } case "slug": // Never empty: the service normalises before writing, and a value that // folds to nothing is rejected rather than stored. @@ -206,6 +255,8 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { case "text": case "textarea": return textSchema(fieldValue); + case "user": + return referenceSchema(); } }; @@ -234,7 +285,9 @@ const applyPresence = ( if ( fieldValue.kind !== "dateTime" && + fieldValue.kind !== "group" && fieldValue.kind !== "relation" && + fieldValue.kind !== "repeatable" && fieldValue.kind !== "slug" && fieldValue.kind !== "user" && fieldValue.defaultValue !== undefined @@ -245,6 +298,108 @@ const applyPresence = ( return schema.optional(); }; +/** + * The set of target identifiers a to-many relation accepts. + * + * Positive integers, distinct, and bounded. Distinctness is enforced here rather + * than deduplicated silently: `[2, 2, 5]` is a caller that thinks it is setting + * three categories, and quietly storing two would be the kind of "helpful" + * behaviour that hides a bug in the caller's own list handling. + */ +const relationSetSchema = (): z.ZodType => + z + .array(referenceSchema()) + .max(CONTENT_RELATION_COLLECTION_MAX) + .refine(value => new Set(value).size === value.length, { + message: "Relation targets must be distinct.", + }); + +/** + * One repeatable child, as it is written. + * + * `id` is optional and is the whole protocol: present means "this is the + * existing child with that identifier", absent means "create a new one". There + * is no `position` - the array order is the order, so a payload cannot describe + * two rows in the same slot. + */ +const repeatableRowSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { + const inner = contentInnerFields(fieldValue); + const names = Object.keys(inner); + + return z.strictObject({ + id: referenceSchema().optional(), + ...leafInputShape(inner, names), + }); +}; + +const repeatableSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => + z + .array(repeatableRowSchema(fieldValue)) + .min(contentRepeatableMin(fieldValue)) + .max(contentRepeatableMax(fieldValue)); + +/** The leaf half of {@link inputShape}, with no advanced kind to consider. */ +const leafInputShape = ( + fields: ContentFieldMap, + names: readonly string[], +): z.ZodRawShape => + Object.fromEntries( + names.map(name => { + const fieldValue = fields[name]; + + return [ + name, + applyPresence( + applyNullable(baseInputSchema(fieldValue), fieldValue), + fieldValue, + ), + ]; + }), + ); + +/** + * A group, as it is written on create: a nested object of its leaves. + * + * Strict, like every other content object in this file: an unknown key inside + * `seo` is a typo the author wants to hear about, not something to drop. The + * four presence states a group can be in - absent, `null`, present-and-complete, + * present-with-a-required-leaf-missing - fall straight out of `.nullable()`, + * `.optional()` and the leaves' own requiredness, with no fifth code path. + */ +const groupInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { + const inner = contentInnerFields(fieldValue); + const object = z.strictObject(leafInputShape(inner, Object.keys(inner))); + + return applyPresence(applyNullable(object, fieldValue), fieldValue); +}; + +/** + * A group, as it is written on update: every leaf optional. + * + * This is what makes `{ seo: { description } }` a one-leaf change rather than a + * request to blank `seo.title`. `.refine` keeps the object from being empty, so + * `{ seo: {} }` is a mistake rather than a silent no-op that still counts as a + * write. + */ +const groupPatchSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { + const inner = contentInnerFields(fieldValue); + const names = Object.keys(inner); + const object = z + .strictObject( + Object.fromEntries( + names.map(name => [ + name, + applyNullable(baseInputSchema(inner[name]), inner[name]).optional(), + ]), + ), + ) + .refine(value => Object.keys(value).length > 0, { + message: "Provide at least one leaf to update, or send null.", + }); + + return applyNullable(object, fieldValue).optional(); +}; + const inputShape = ( fields: ContentFieldMap, names: readonly string[], @@ -253,6 +408,18 @@ const inputShape = ( names.map(name => { const fieldValue = fields[name]; + if (fieldValue.kind === "group") { + return [name, groupInputSchema(fieldValue)]; + } + if (fieldValue.kind === "repeatable") { + // Defaulted rather than optional: a create that says nothing about + // `faq` means "no entries", and the empty array is what that is. + return [name, repeatableSchema(fieldValue).default([])]; + } + if (isContentRelationCollection(fieldValue)) { + return [name, relationSetSchema().default([])]; + } + return [ name, applyPresence( @@ -276,6 +443,16 @@ const updateShape = ( names.map(name => { const fieldValue = fields[name]; + if (fieldValue.kind === "group") { + return [name, groupPatchSchema(fieldValue)]; + } + if (fieldValue.kind === "repeatable") { + return [name, repeatableSchema(fieldValue).optional()]; + } + if (isContentRelationCollection(fieldValue)) { + return [name, relationSetSchema().optional()]; + } + return [ name, applyNullable(baseInputSchema(fieldValue), fieldValue).optional(), @@ -309,9 +486,26 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => case "enum": return [name, z.enum(fieldValue.values).optional()]; case "number": - case "relation": case "user": return [name, z.coerce.number().optional()]; + case "relation": + // A to-many relation filters by membership, and it arrives from a + // query string as one identifier: `?categories=7`. The transform is + // what turns it into the `{ contains }` object the query builder + // branches on, so the wire format stays as flat as every other + // filter while the service still sees a shape it cannot confuse + // with an equality. + return fieldValue.multiple + ? [ + name, + z.coerce + .number() + .optional() + .transform(value => + value === undefined ? undefined : { contains: value }, + ), + ] + : [name, z.coerce.number().optional()]; default: return [name, z.string().optional()]; } @@ -339,6 +533,62 @@ const publicRelationSchema = (): z.ZodObject => * response is one base row joined to one translation, so where a value is stored * is a fact about the query rather than about the response. */ +/** + * Groups an allowlist's leaf paths by the container they belong to. + * + * `["title", "seo.title", "seo.description"]` becomes `{ seo: ["title", + * "description"] }` and leaves `"title"` to the flat pass. Order within a + * container follows the allowlist, so the generated response shape is as + * deterministic as everything else the engine emits. + */ +export const groupPublicLeafPaths = ( + names: readonly string[], +): Map => { + const owners = new Map(); + + for (const name of names) { + const path = splitContentFieldPath(name); + if (!path) continue; + + const [owner, leaf] = path; + const leaves = owners.get(owner); + if (leaves) { + leaves.push(leaf); + continue; + } + owners.set(owner, [leaf]); + } + + return owners; +}; + +/** + * One exposed container - a group or a repeatable - carrying **only** the leaves + * the allowlist named. + * + * This is where leaf-level privacy is actually implemented: `seo.indexable` is + * absent from the shape, and therefore absent from the `SELECT` the shape drives, + * however many other `seo.*` paths are public. + */ +const publicContainerSchema = ( + fieldValue: ContentFieldDescriptor, + leaves: readonly string[], +): z.ZodType => { + const inner = contentInnerFields(fieldValue); + const shape = Object.fromEntries( + leaves.map(leaf => [ + leaf, + applyNullable(baseSelectSchema(inner[leaf]), inner[leaf]), + ]), + ); + + if (fieldValue.kind === "repeatable") { + return z.array(z.object({ id: z.number(), ...shape })); + } + + return applyNullable(z.object(shape), fieldValue); +}; + const publicSelectShape = ( fields: ContentFieldMap, publicApi: ResolvedContentPublicApiConfig, @@ -349,20 +599,36 @@ const publicSelectShape = ( // content type, so this cannot shadow a declared field. ...(localization.enabled ? { locale: z.string() } : {}), ...Object.fromEntries( - publicApi.fields.map(name => { - if (name === "id") return [name, z.number()]; - if (name === "createdAt" || name === "updatedAt") return [name, z.date()]; - if (name === "publishedAt") return [name, z.date().nullable()]; + publicApi.fields + .filter(name => splitContentFieldPath(name) === null) + .map(name => { + if (name === "id") return [name, z.number()]; + if (name === "createdAt" || name === "updatedAt") { + return [name, z.date()]; + } + if (name === "publishedAt") return [name, z.date().nullable()]; - const fieldValue = fields[name]; - if (fieldValue.kind === "relation") { - const relation = publicRelationSchema(); + const fieldValue = fields[name]; + if (fieldValue.kind === "relation") { + // A to-many relation is a list of identifiers rather than a list of + // `{ id }` objects: the single-relation wrapper exists so a `null` + // relation is distinguishable from a missing key, and an empty array + // already says that on its own. + if (fieldValue.multiple) return [name, z.array(z.number())]; - return [name, fieldValue.nullable ? relation.nullable() : relation]; - } + const relation = publicRelationSchema(); - return [name, applyNullable(baseSelectSchema(fieldValue), fieldValue)]; - }), + return [name, fieldValue.nullable ? relation.nullable() : relation]; + } + + return [name, applyNullable(baseSelectSchema(fieldValue), fieldValue)]; + }), + ), + ...Object.fromEntries( + [...groupPublicLeafPaths(publicApi.fields)].map(([owner, leaves]) => [ + owner, + publicContainerSchema(fields[owner], leaves), + ]), ), }); @@ -473,6 +739,7 @@ const buildTranslationSchemas = ({ export const buildContentSchemas = ({ admin, + advanced = contentAdvancedDisabled(), editorial = false, fields, localization = contentLocalizationDisabled(), @@ -480,6 +747,7 @@ export const buildContentSchemas = ({ publication = false, }: { admin: ResolvedContentAdminConfig; + advanced?: ResolvedContentAdvancedConfig; editorial?: boolean; /** Every declared field. Partitioned here, so no caller has to. */ fields: ContentFieldMap; @@ -489,7 +757,16 @@ export const buildContentSchemas = ({ }): ContentSchemas => { // Everything below this line is about the base table, so it reads the shared // half only. The localized half gets its own schemas at the bottom. - const { localizedFields, sharedFields } = partitionContentFields(fields); + const { collectionFields, localizedFields, sharedFields } = + partitionContentFields(fields); + // A to-many relation and a repeatable are shared values that are not columns. + // They belong in every schema that describes what a caller may *write* - and + // in none of the ones that describe a row. + const writableFields: ContentFieldMap = { + ...sharedFields, + ...collectionFields, + }; + const writableNames = Object.keys(writableFields); const fieldNames = Object.keys(sharedFields); // Read-only on the wire: absent from `create` and `update` (both strict), so @@ -524,9 +801,9 @@ export const buildContentSchemas = ({ // `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(sharedFields, fieldNames)); + const create = z.strictObject(inputShape(writableFields, writableNames)); const update = z - .strictObject(updateShape(sharedFields, fieldNames)) + .strictObject(updateShape(writableFields, writableNames)) .refine(value => Object.keys(value).length > 0, { message: "Provide at least one field to update.", }); @@ -561,18 +838,29 @@ export const buildContentSchemas = ({ ); return { + // Keyed by the generated tables rather than by the field map, so a + // collection that has no table cannot appear here and a table that has no + // schema cannot be read - the two lists are resolved from the same place. + advancedSelect: z.object( + Object.fromEntries( + [ + ...advanced.junctions.map(entry => entry.field), + ...advanced.repeatables.map(entry => entry.field), + ].map(name => [name, baseSelectSchema(collectionFields[name])]), + ), + ), // 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(sharedFields), + ...filterShape(writableFields), ...(publication ? { status: z.enum(CONTENT_PUBLICATION_STATUSES).optional() } : {}), }), - form: z.object(inputShape(sharedFields, admin.form.fields)), + form: z.object(inputShape(writableFields, admin.form.fields)), order: z.object({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(orderable as [string, ...string[]]).optional(), diff --git a/packages/vitnode/src/content/search.ts b/packages/vitnode/src/content/search.ts index f5f2ac8db..db0f2049c 100644 --- a/packages/vitnode/src/content/search.ts +++ b/packages/vitnode/src/content/search.ts @@ -4,6 +4,7 @@ import { CONTENT_SEARCH_LOCALE_PLACEHOLDER, CONTENT_SEARCH_SLUG_PLACEHOLDER, } from "./const"; +import { isContentRelationCollection, splitContentFieldPath } from "./paths"; /** * The public URL of one record, for a search hit. @@ -88,3 +89,73 @@ export const contentSearchIndexedFieldNames = ( ), ]; }; + +/** + * Everything a `changedFields` entry may name that would change the document. + * + * The indexed names, plus the **container** of every indexed leaf path. A + * repeatable reports itself whole (`faq`) when its children move, while the + * configuration names leaves (`faq.question`), so without the container the + * synchronizer would decide a rewritten answer changed nothing. A group is the + * other way round - it reports leaves and is configured by leaves - so its + * container simply never appears in a diff and adding it costs nothing. + */ +export const contentSearchIndexedPaths = ( + definition: AnyContentTypeDefinition, +): Set => { + const names = contentSearchIndexedFieldNames(definition); + + return new Set( + names.flatMap(name => { + const path = splitContentFieldPath(name); + + return path ? [name, path[0]] : [name]; + }), + ); +}; + +/** + * The collection fields a search document is actually made of. + * + * Empty for every Stage 1-5 content type, and for a Stage 6 one whose search + * config names no collection path - which is what keeps "read the collections + * back after a write" from becoming a cost every content type pays. When it is + * not empty it is also the *allowlist*: only these are loaded, so indexing + * `faq.answer` never queries a private junction table. + */ +export const contentSearchIndexedCollections = ( + definition: AnyContentTypeDefinition, +): string[] => { + const owners = new Set(); + + for (const name of contentSearchIndexedFieldNames(definition)) { + const path = splitContentFieldPath(name); + if (!path) continue; + + const fieldValue = definition.fields[path[0]]; + if (!fieldValue) continue; + if ( + fieldValue.kind !== "repeatable" && + !isContentRelationCollection(fieldValue) + ) { + continue; + } + + owners.add(path[0]); + } + + return [...owners]; +}; + +/** + * Whether any indexed field lives in a generated collection table. + * + * The one question that decides whether the effects layer has to read a + * record's collections back after a write: a document made of `faq.answer` is + * made of child rows, and those are not on the row the mutation returned. A + * content type that indexes none - which is every Stage 1-5 one - reads nothing + * extra, ever. + */ +export const contentSearchIndexesCollections = ( + definition: AnyContentTypeDefinition, +): boolean => contentSearchIndexedCollections(definition).length > 0; diff --git a/packages/vitnode/src/content/server/advanced-projection.test.ts b/packages/vitnode/src/content/server/advanced-projection.test.ts new file mode 100644 index 000000000..a5a35199d --- /dev/null +++ b/packages/vitnode/src/content/server/advanced-projection.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentFieldMap } from "../types"; + +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { + contentPublicCollectionFields, + createContentPublicProjector, + nestContentPublicRow, +} from "./public-service"; +import { changedPathsToColumns, diffChangedPaths } from "./query"; +import { + contentRevisionSnapshot, + projectRevisionSnapshot, +} from "./revision-snapshot"; +import { contentSearchDocument } from "./search-document"; + +/** + * The pure projections Stage 6 adds, tested without a database. + * + * Each of these is a rule that is easy to state and easy to get subtly wrong: + * which paths a patch changed, which columns that becomes, what a snapshot + * records, what a search document is made of, and which keys a public response + * carries. All of them are functions of their arguments, so all of them are + * table tests rather than fixtures. + */ + +const categoryContentType = defineContentType({ + admin: { label: { plural: "Categories", singular: "Category" } }, + fields: { name: field.text({ required: true }) }, + id: "test.proj-category", + tableName: "test_proj_categories", +}); + +const articleContentType = defineContentType({ + admin: { + label: { plural: "Articles", singular: "Article" }, + list: { columns: ["title"] }, + titleField: "title", + }, + editorial: { enabled: true }, + fields: { + categories: field.relation({ + multiple: true, + target: () => categoryContentType, + }), + faq: field.repeatable({ + fields: { + answer: field.textarea({ required: true }), + question: field.text({ required: true }), + }, + }), + related: field.relation({ multiple: true, ordered: true, self: true }), + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + nullable: true, + }), + slug: field.slug({ source: "title" }), + syndication: field.group({ + fields: { indexable: field.boolean({ defaultValue: true }) }, + }), + title: field.text({ required: true }), + }, + id: "test.proj-article", + publicApi: { + enabled: true, + fields: [ + "title", + "slug", + "categories", + "seo.title", + "faq.question", + "publishedAt", + ], + path: "proj-articles", + }, + publication: { enabled: true }, + search: { + contentFields: ["title", "seo.title", "faq.question"], + enabled: true, + pathTemplate: "/proj-articles/{slug}", + titleField: "title", + }, + tableName: "test_proj_articles", +}); + +const fields = articleContentType.fields as unknown as ContentFieldMap; + +/** A row as it comes back from Postgres: flat columns, not nested values. */ +const row = { + createdAt: new Date("2026-01-01T00:00:00.000Z"), + id: 7, + publishedAt: new Date("2026-01-02T00:00:00.000Z"), + seoDescription: "Old description", + seoTitle: "Old SEO", + slug: "hello", + status: "published", + syndicationIndexable: true, + title: "Hello", + updatedAt: new Date("2026-01-03T00:00:00.000Z"), + version: 3, +}; + +describe("diffChangedPaths", () => { + it("names the leaves that moved, never the group", () => { + expect( + diffChangedPaths(fields, row, { seo: { description: "New" } }), + ).toStrictEqual(["seo.description"]); + }); + + it("drops a leaf that is already what was sent", () => { + expect( + diffChangedPaths(fields, row, { + seo: { description: "Old description", title: "New SEO" }, + }), + ).toStrictEqual(["seo.title"]); + }); + + it("reports every non-empty leaf when a group is blanked", () => { + expect(diffChangedPaths(fields, row, { seo: null })).toStrictEqual([ + "seo.description", + "seo.title", + ]); + }); + + it("reports nothing when a group was already blank", () => { + expect( + diffChangedPaths( + fields, + { seoDescription: null, seoTitle: null }, + { + seo: null, + }, + ), + ).toStrictEqual([]); + }); + + it("still reports a plain scalar by its own name", () => { + expect(diffChangedPaths(fields, row, { title: "Goodbye" })).toStrictEqual([ + "title", + ]); + }); + + it("ignores collections, which the store diffs instead", () => { + expect(diffChangedPaths(fields, row, { categories: [1] })).toStrictEqual( + [], + ); + }); +}); + +describe("changedPathsToColumns", () => { + it("writes only the leaves that moved", () => { + expect( + changedPathsToColumns(fields, { seo: { description: "New" } }, [ + "seo.description", + ]), + ).toStrictEqual({ seoDescription: "New" }); + }); + + it("writes null into every leaf of a blanked group", () => { + expect( + changedPathsToColumns(fields, { seo: null }, [ + "seo.description", + "seo.title", + ]), + ).toStrictEqual({ seoDescription: null, seoTitle: null }); + }); +}); + +describe("contentRevisionSnapshot", () => { + it("records groups nested and collections as identity", () => { + const snapshot = contentRevisionSnapshot(articleContentType, { + ...row, + categories: [5, 2], + faq: [{ answer: "A", id: 11, question: "Q" }], + related: [9], + }); + + expect(snapshot.fields.seo).toStrictEqual({ + description: "Old description", + title: "Old SEO", + }); + expect(snapshot.fields.categories).toStrictEqual([5, 2]); + expect(snapshot.fields.related).toStrictEqual([9]); + expect(snapshot.fields.faq).toStrictEqual([ + { answer: "A", id: 11, question: "Q" }, + ]); + // Never the flattened column names: those are an internal mapping, and a + // history that recorded one would be invalidated by a rename. + expect(snapshot.fields).not.toHaveProperty("seoTitle"); + }); + + it("records an empty nullable group as null", () => { + const snapshot = contentRevisionSnapshot(articleContentType, { + ...row, + seoDescription: null, + seoTitle: null, + }); + + expect(snapshot.fields.seo).toBeNull(); + }); + + it("records an absent collection as the empty set", () => { + const snapshot = contentRevisionSnapshot(articleContentType, row); + + expect(snapshot.fields.categories).toStrictEqual([]); + expect(snapshot.fields.faq).toStrictEqual([]); + }); +}); + +describe("projectRevisionSnapshot", () => { + it("drops a leaf the group no longer declares", () => { + const projected = projectRevisionSnapshot(articleContentType, { + contentTypeId: articleContentType.id, + createdAt: row.createdAt.toISOString(), + fields: { + seo: { keywords: "gone", title: "Kept" }, + title: "Hello", + }, + id: 7, + schemaVersion: 1, + updatedAt: row.updatedAt.toISOString(), + version: 1, + }); + + // The past is allowed to mention things that no longer exist; left in, it + // would hit the strict object schema and make every old revision a 422. + expect(projected.seo).toStrictEqual({ title: "Kept" }); + }); + + it("keeps a child's id so a restore can match rather than recreate", () => { + const projected = projectRevisionSnapshot(articleContentType, { + contentTypeId: articleContentType.id, + createdAt: row.createdAt.toISOString(), + fields: { faq: [{ answer: "A", gone: "x", id: 11, question: "Q" }] }, + id: 7, + schemaVersion: 1, + updatedAt: row.updatedAt.toISOString(), + version: 1, + }); + + expect(projected.faq).toStrictEqual([ + { answer: "A", id: 11, question: "Q" }, + ]); + }); +}); + +describe("contentSearchDocument", () => { + const values = { + ...row, + faq: [ + { answer: "A1", id: 11, question: "First question" }, + { answer: "A2", id: 12, question: "Second question" }, + ], + seo: { description: null, title: "SEO heading" }, + }; + + it("reads a group leaf through its canonical path", () => { + const document = contentSearchDocument(articleContentType, values); + + expect(document?.content).toContain("SEO heading"); + }); + + it("joins a repeatable leaf in position order", () => { + const document = contentSearchDocument(articleContentType, values); + + // Position order rather than insertion order: position is what the page + // renders, and an index that disagreed would highlight the wrong entry. + expect(document?.content).toContain("First question\nSecond question"); + }); + + it("indexes nothing from a group that is null", () => { + const document = contentSearchDocument(articleContentType, { + ...values, + seo: null, + }); + + expect(document?.content).not.toContain("SEO heading"); + expect(document?.content).toContain("Hello"); + }); + + it("never indexes relation identifiers as text", () => { + const document = contentSearchDocument(articleContentType, { + ...values, + categories: [42], + }); + + expect(document?.content).not.toContain("42"); + }); +}); + +describe("public projection", () => { + const project = createContentPublicProjector(articleContentType); + + it("nests the exposed leaves and drops the private ones", () => { + const projected = project( + nestContentPublicRow({ + categories: [2, 5], + faq: [{ answer: "secret", id: 11, question: "Public?" }], + id: 7, + publishedAt: row.publishedAt, + "seo.title": "SEO heading", + slug: "hello", + title: "Hello", + }), + ); + + expect(projected).toStrictEqual({ + categories: [2, 5], + faq: [{ id: 11, question: "Public?" }], + publishedAt: row.publishedAt, + seo: { title: "SEO heading" }, + slug: "hello", + title: "Hello", + }); + }); + + it("omits a private collection and a private group entirely", () => { + const projected = project(nestContentPublicRow({ id: 7, title: "Hello" })); + + expect(projected).not.toHaveProperty("related"); + expect(projected).not.toHaveProperty("syndication"); + // `id` is fetched for the cursor and dropped again unless it was exposed. + expect(projected).not.toHaveProperty("id"); + }); + + it("names only the collections the allowlist actually exposes", () => { + // `related` is private, so a public list joins nothing for it. + expect(contentPublicCollectionFields(articleContentType)).toStrictEqual([ + "categories", + "faq", + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/advanced-store.ts b/packages/vitnode/src/content/server/advanced-store.ts new file mode 100644 index 000000000..08cc4dabb --- /dev/null +++ b/packages/vitnode/src/content/server/advanced-store.ts @@ -0,0 +1,772 @@ +import type { SQL } from "drizzle-orm"; +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; + +import { and, asc, eq, getTableName, inArray, sql } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; + +import type { + AnyContentTypeDefinition, + ContentFieldMap, + ContentRelationFilter, +} from "../types"; +import type { ContentDatabase } from "./service"; +import type { ContentAdvancedTables } from "./types"; + +import { + CONTENT_ADVANCED_CODES, + CONTENT_COLLECTION_FIRST_POSITION, +} from "../const"; +import { ContentAdvancedInputError, ContentEngineError } from "../errors"; +import { + asContentRelationCollection, + contentInnerFields, + isContentCollectionField, +} from "../paths"; +import { toColumnValues } from "./query"; + +/** + * The read and write layer for a content type's advanced collections. + * + * One module rather than one per kind, because a to-many relation and a + * repeatable are the same problem twice: an ordered list of child rows keyed by + * a parent, replaced as a whole, with a unique `(itemId, position)` that must + * never be violated even for an instant. Sharing the write dance is what keeps + * "reorder cannot produce a duplicate slot" one piece of code rather than two + * that agree on the day they are written. + * + * **Nothing here locks anything.** Every mutation runs inside a transaction the + * caller owns, and the caller has already taken the source record's lock - + * optimistically through the guarded `version` UPDATE on an editorial content + * type, pessimistically through `SELECT ... FOR UPDATE` on one without. That is + * why two concurrent `set` calls cannot interleave here: one of them never + * reaches this module. + */ + +/** One repeatable child, as it is written back. */ +type ChildValues = Record; + +export interface ContentAdvancedStore { + /** + * The field names whose collections a patch would actually move. + * + * Read-only, so a caller can decide whether there is anything to write - and + * therefore whether to bump the version, write a revision and emit an event - + * before it takes the write lock. A reorder to the order that is already + * there comes back empty, which is what makes it a no-op rather than a + * version bump with nothing in it. + */ + diff: ( + tx: ContentDatabase, + itemId: number, + patch: Record, + ) => Promise; + /** Whether the content type declares any advanced collection at all. */ + readonly enabled: boolean; + /** The collection field names, in declaration order. */ + readonly fields: string[]; + /** + * Every collection of one record, in logical shape. + * + * `only` narrows it to the fields a caller actually needs: a public read wants + * the exposed ones and a search document wants the indexed ones, and querying + * a private junction table to discard the rows afterwards is work with no + * answer attached. Omit it for all of them. + */ + load: ( + itemId: number, + database: ContentDatabase, + only?: readonly string[], + ) => Promise>; + /** + * Every collection of many records, in **two queries per collection field** + * rather than two per record. + * + * The whole reason a to-many relation is absent from `ContentSelect`: a list + * that carried one would issue a query per row, and an admin table of 25 rows + * with two collections would be 50 round trips. + */ + loadMany: ( + itemIds: readonly number[], + database: ContentDatabase, + only?: readonly string[], + ) => Promise>>; + /** + * An indexed `EXISTS` over one relation's junction table. + * + * `EXISTS` rather than a join, so a record matches once however many junction + * rows it has and the outer query needs no `DISTINCT`. The junction's primary + * key `(itemId, relatedItemId)` covers the lookup exactly. + */ + membershipCondition: ( + field: string, + filter: ContentRelationFilter, + ) => SQL | undefined; + /** + * Makes a historical collection state applicable to the record as it stands. + * + * Two different rules, because the two kinds fail differently: + * + * - a **repeatable child** that no longer exists is *recreated*. Its values + * are all in the snapshot, so restoring it loses nothing except the original + * identifier - and the alternative, refusing, would mean a record could + * never be restored past a delete. + * - a **relation target** that no longer exists is *fatal*. Its values are not + * in the snapshot and never were: the row belongs to another content type + * with its own history, so there is nothing here to recreate it from. + * `missingRelations` names the fields, and the caller turns that into a + * structured not-restorable answer rather than a partial restore. + */ + prepareRestore: ( + tx: ContentDatabase, + itemId: number, + patch: Record, + ) => Promise<{ + missingRelations: { field: string; ids: number[] }[]; + patch: Record; + }>; + /** Applies a patch's collection half. Returns the fields that moved. */ + write: ( + tx: ContentDatabase, + itemId: number, + patch: Record, + ) => Promise; +} + +/** The row shape a relation's junction table holds, in write order. */ +interface JunctionRow { + createdAt?: Date; + relatedItemId: number; +} + +const sameJunction = ( + current: readonly JunctionRow[], + desired: readonly number[], +): boolean => + current.length === desired.length && + current.every((row, index) => row.relatedItemId === desired[index]); + +/** + * The order a relation's targets are stored in. + * + * An ordered relation keeps the author's order. An unordered one is sorted by + * target id, which is what makes `set([9, 2, 5])` and `set([2, 5, 9])` the same + * state rather than two writes that differ only in a column nobody declared. + */ +const normalizeTargets = ( + ids: readonly number[], + ordered: boolean, +): number[] => (ordered ? [...ids] : [...ids].sort((a, b) => a - b)); + +const sameChildValues = ( + leaves: readonly string[], + current: ChildValues, + desired: ChildValues, +): boolean => + leaves.every(leaf => { + const before = current[leaf]; + const after = desired[leaf]; + + if (before instanceof Date) { + return ( + after !== null && + after !== undefined && + before.getTime() === new Date(after as string).getTime() + ); + } + + return before === after; + }); + +/** + * Builds the store for one content type. + * + * Returns a disabled stub - every method a no-op - when the content type + * declares no advanced collection, so every caller can use it unconditionally + * and a Stage 1-5 content type still issues exactly the queries it always did. + */ +export const createContentAdvancedStore = < + TDefinition extends AnyContentTypeDefinition, +>({ + definition, + table, + tables, +}: { + definition: TDefinition; + table: PgTable; + tables: ContentAdvancedTables; +}): ContentAdvancedStore => { + const contentTypeId = definition.id; + const fields = definition.fields; + const collectionNames = Object.keys(fields).filter(name => + isContentCollectionField(fields[name]), + ); + const baseColumns = table as unknown as Record; + + const junctionOf = ( + field: string, + ): null | { columns: Record; table: PgTable } => { + const junction = tables.junctions[field]; + if (!junction) return null; + + return { + columns: junction as unknown as Record, + table: junction as unknown as PgTable, + }; + }; + + const childOf = ( + field: string, + ): null | { columns: Record; table: PgTable } => { + const child = tables.repeatables[field]; + if (!child) return null; + + return { + columns: child as unknown as Record, + table: child as unknown as PgTable, + }; + }; + + const leafNamesOf = (field: string): string[] => + Object.keys(contentInnerFields(fields[field])); + + /** + * The target `id` column of one to-many relation, read off the junction's own + * foreign key. + * + * Read from the constraint rather than from the descriptor's `target()` thunk, + * so the table this checks is by construction the table Postgres will check. + * Resolved lazily and memoised: `foreignKey.reference()` is the thunk Drizzle + * leaves unevaluated so two content types can refer to each other, and forcing + * it at construction would defeat that. + */ + const targetColumns = new Map(); + const relationTargetColumn = (field: string): null | PgColumn => { + const cached = targetColumns.get(field); + if (cached !== undefined) return cached; + + const junction = tables.junctions[field]; + const resolved = junction + ? (getTableConfig(junction as unknown as PgTable) + .foreignKeys.map(foreignKey => foreignKey.reference()) + // Drizzle is configured with no `casing` transform, so a column is + // named by its object key on both sides - `relatedItemId` here and + // `relatedItemId` in SQL. + .find(reference => + reference.columns.some(column => column.name === "relatedItemId"), + )?.foreignColumns[0] ?? null) + : null; + + targetColumns.set(field, resolved); + + return resolved; + }; + + /** + * Refuses a relation set whose targets are not all real rows. + * + * Checked rather than left to the foreign key for the reason + * `ContentAdvancedInputError` documents: `23503` cannot say which of the two + * references failed, and a caller that sent a stale category id should be told + * that rather than told the article is gone. + */ + const assertTargetsExist = async ( + tx: ContentDatabase, + field: string, + ids: readonly number[], + ): Promise => { + if (ids.length === 0) return; + + const target = relationTargetColumn(field); + if (!target) return; + + const rows = await tx + .select({ id: target }) + .from(target.table) + .where(inArray(target, [...ids])); + + const found = new Set(rows.map(row => Number(row.id))); + const missing = ids.filter(id => !found.has(id)); + if (missing.length === 0) return; + + throw new ContentAdvancedInputError({ + code: CONTENT_ADVANCED_CODES.missingTarget, + contentTypeId, + field, + ids: missing, + message: `Relation "${field}" references ${missing.length === 1 ? "a record" : "records"} that no longer exist: ${missing.join(", ")}.`, + }); + }; + + const readJunction = async ( + field: string, + itemIds: readonly number[], + database: ContentDatabase, + ): Promise> => { + const junction = junctionOf(field); + const result = new Map(); + if (!junction || itemIds.length === 0) return result; + + const rows = await database + .select({ + createdAt: junction.columns.createdAt, + itemId: junction.columns.itemId, + relatedItemId: junction.columns.relatedItemId, + }) + .from(junction.table) + .where(inArray(junction.columns.itemId, [...itemIds])) + .orderBy(asc(junction.columns.itemId), asc(junction.columns.position)); + + for (const row of rows) { + const itemId = Number(row.itemId); + const list = result.get(itemId) ?? []; + list.push({ + createdAt: row.createdAt instanceof Date ? row.createdAt : undefined, + relatedItemId: Number(row.relatedItemId), + }); + result.set(itemId, list); + } + + return result; + }; + + const readChildren = async ( + field: string, + itemIds: readonly number[], + database: ContentDatabase, + ): Promise> => { + const child = childOf(field); + const result = new Map(); + if (!child || itemIds.length === 0) return result; + + const leaves = leafNamesOf(field); + const rows = await database + .select({ + id: child.columns.id, + itemId: child.columns.itemId, + ...Object.fromEntries(leaves.map(leaf => [leaf, child.columns[leaf]])), + }) + .from(child.table) + .where(inArray(child.columns.itemId, [...itemIds])) + .orderBy(asc(child.columns.itemId), asc(child.columns.position)); + + for (const row of rows) { + const values = row as ChildValues; + const itemId = Number(values.itemId); + const list = result.get(itemId) ?? []; + list.push({ + id: Number(values.id), + ...Object.fromEntries(leaves.map(leaf => [leaf, values[leaf]])), + }); + result.set(itemId, list); + } + + return result; + }; + + /** + * Rewrites one collection's rows to exactly `desired`, in two passes. + * + * The passes exist because of `UNIQUE (itemId, position)`: moving row A from + * slot 0 to slot 1 while row B still sits in slot 1 violates it *during* the + * statement even though the final state is fine. So every surviving row is + * first parked at a negative slot - a space no settled row ever occupies - and + * a single final `UPDATE` maps the whole set back to `0..n-1` at once. No + * deferrable constraint, no delete-and-recreate, and identity survives. + */ + const settlePositions = async ( + tx: ContentDatabase, + target: { columns: Record; table: PgTable }, + itemId: number, + ): Promise => { + await tx + .update(target.table) + .set({ position: sql`-${target.columns.position} - 1` }) + .where(eq(target.columns.itemId, itemId)); + }; + + const parked = (index: number): number => + -(index - CONTENT_COLLECTION_FIRST_POSITION + 1); + + const writeRelation = async ( + tx: ContentDatabase, + field: string, + itemId: number, + desired: readonly number[], + ): Promise => { + const junction = junctionOf(field); + if (!junction) return; + + const current = (await readJunction(field, [itemId], tx)).get(itemId) ?? []; + const keep = new Set(desired); + const removed = current + .filter(row => !keep.has(row.relatedItemId)) + .map(row => row.relatedItemId); + + if (removed.length > 0) { + await tx + .delete(junction.table) + .where( + and( + eq(junction.columns.itemId, itemId), + inArray(junction.columns.relatedItemId, removed), + ), + ); + } + + const existing = new Set( + current + .filter(row => keep.has(row.relatedItemId)) + .map(row => row.relatedItemId), + ); + + for (const [index, relatedItemId] of desired.entries()) { + if (!existing.has(relatedItemId)) continue; + + await tx + .update(junction.table) + .set({ position: parked(index) }) + .where( + and( + eq(junction.columns.itemId, itemId), + eq(junction.columns.relatedItemId, relatedItemId), + ), + ); + } + + const inserted = desired + .map((relatedItemId, index) => ({ index, relatedItemId })) + .filter(entry => !existing.has(entry.relatedItemId)); + + if (inserted.length > 0) { + await tx.insert(junction.table).values( + inserted.map(entry => ({ + itemId, + position: parked(entry.index), + relatedItemId: entry.relatedItemId, + })), + ); + } + + if (desired.length > 0) await settlePositions(tx, junction, itemId); + }; + + const writeRepeatable = async ( + tx: ContentDatabase, + field: string, + itemId: number, + desired: readonly ChildValues[], + ): Promise => { + const child = childOf(field); + if (!child) return; + + const leaves = leafNamesOf(field); + const inner = contentInnerFields(fields[field]); + const current = (await readChildren(field, [itemId], tx)).get(itemId) ?? []; + const currentIds = new Set(current.map(row => Number(row.id))); + + // A child id the caller sent that does not belong to this record is a + // mistake worth naming: silently creating a new row instead would look like + // it worked and quietly duplicate the entry the caller meant to edit. + const claimed = desired + .map(row => row.id) + .filter((id): id is number => typeof id === "number"); + const foreign = claimed.filter(id => !currentIds.has(id)); + if (foreign.length > 0) { + throw new ContentAdvancedInputError({ + code: CONTENT_ADVANCED_CODES.missingChild, + contentTypeId, + field, + ids: foreign, + message: `Repeatable "${field}" was sent ${foreign.length === 1 ? "an entry" : "entries"} that do not belong to this record: ${foreign.join(", ")}. Omit \`id\` to add a new entry.`, + }); + } + + const keep = new Set(claimed); + const removed = [...currentIds].filter(id => !keep.has(id)); + if (removed.length > 0) { + await tx + .delete(child.table) + .where( + and( + eq(child.columns.itemId, itemId), + inArray(child.columns.id, removed), + ), + ); + } + + for (const [index, row] of desired.entries()) { + const values = toColumnValues( + inner, + Object.fromEntries(leaves.map(leaf => [leaf, row[leaf] ?? null])), + ); + + if (typeof row.id === "number") { + await tx + .update(child.table) + .set({ ...values, position: parked(index) }) + .where( + and(eq(child.columns.itemId, itemId), eq(child.columns.id, row.id)), + ); + continue; + } + + await tx + .insert(child.table) + .values({ ...values, itemId, position: parked(index) }); + } + + if (desired.length > 0) await settlePositions(tx, child, itemId); + }; + + /** + * What a patch would change, and the desired state it would change it to. + * + * Computed once and reused by `diff` and `write`, so the no-op rule and the + * write agree by construction rather than by both reading the same comment. + */ + const plan = async ( + tx: ContentDatabase, + itemId: number, + patch: Record, + ): Promise< + { desired: unknown; field: string; kind: "relation" | "repeatable" }[] + > => { + const changes: { + desired: unknown; + field: string; + kind: "relation" | "repeatable"; + }[] = []; + + for (const field of collectionNames) { + const value = patch[field]; + if (value === undefined) continue; + + const relation = asContentRelationCollection(fields[field]); + if (relation) { + if (!Array.isArray(value)) continue; + + const desired = normalizeTargets(value as number[], relation.ordered); + const current = + (await readJunction(field, [itemId], tx)).get(itemId) ?? []; + + if (sameJunction(current, desired)) continue; + + changes.push({ desired, field, kind: "relation" }); + continue; + } + + if (fields[field].kind !== "repeatable") continue; + if (!Array.isArray(value)) continue; + + const desired = value as ChildValues[]; + const current = + (await readChildren(field, [itemId], tx)).get(itemId) ?? []; + const leaves = leafNamesOf(field); + + const unchanged = + current.length === desired.length && + current.every((row, index) => { + const next = desired[index]; + // Identity first: two entries that swapped places have moved even if + // every value is identical, and an entry with no `id` is new whatever + // it holds. + if (next.id !== row.id) return false; + + return sameChildValues(leaves, row, next); + }); + + if (unchanged) continue; + + changes.push({ desired, field, kind: "repeatable" }); + } + + return changes; + }; + + /** + * The collection fields one read should touch. + * + * An unknown name is dropped rather than rejected: callers pass an allowlist + * derived from configuration - the public `fields`, the search paths - and a + * name that is not a collection simply has no collection to load. + */ + const selected = (only?: readonly string[]): string[] => + only === undefined + ? collectionNames + : collectionNames.filter(field => only.includes(field)); + + const enabled = collectionNames.length > 0; + + // Every method a no-op, so a content type with no advanced collection can be + // handed the same object every other one gets and pay for nothing. + const disabled: ContentAdvancedStore = { + diff: async () => Promise.resolve([]), + enabled: false, + fields: [], + load: async () => Promise.resolve({}), + loadMany: async () => Promise.resolve(new Map()), + membershipCondition: () => undefined, + prepareRestore: async (_tx, _itemId, patch) => + Promise.resolve({ missingRelations: [], patch }), + write: async () => Promise.resolve([]), + }; + + if (!enabled) return disabled; + + return { + diff: async (tx, itemId, patch) => + (await plan(tx, itemId, patch)).map(change => change.field), + + enabled, + + fields: collectionNames, + + load: async (itemId, database, only) => { + const loaded = await Promise.all( + selected(only).map(async field => { + if (tables.junctions[field]) { + const rows = + (await readJunction(field, [itemId], database)).get(itemId) ?? []; + + return [field, rows.map(row => row.relatedItemId)] as const; + } + + const rows = + (await readChildren(field, [itemId], database)).get(itemId) ?? []; + + return [field, rows] as const; + }), + ); + + return Object.fromEntries(loaded); + }, + + loadMany: async (itemIds, database, only) => { + const wanted = selected(only); + const result = new Map>(); + for (const itemId of itemIds) { + result.set( + itemId, + Object.fromEntries(wanted.map(field => [field, []])), + ); + } + if (itemIds.length === 0) return result; + + for (const field of wanted) { + if (tables.junctions[field]) { + const rows = await readJunction(field, itemIds, database); + for (const [itemId, list] of rows) { + const entry = result.get(itemId); + if (!entry) continue; + entry[field] = list.map(row => row.relatedItemId); + } + continue; + } + + const rows = await readChildren(field, itemIds, database); + for (const [itemId, list] of rows) { + const entry = result.get(itemId); + if (!entry) continue; + entry[field] = list; + } + } + + return result; + }, + + prepareRestore: async (tx, itemId, patch) => { + const prepared: Record = { ...patch }; + const missingRelations: { field: string; ids: number[] }[] = []; + + for (const field of collectionNames) { + const value = prepared[field]; + if (!Array.isArray(value)) continue; + + if (tables.junctions[field]) { + const ids = (value as number[]).filter(id => Number.isInteger(id)); + const target = relationTargetColumn(field); + if (!target || ids.length === 0) continue; + + const rows = await tx + .select({ id: target }) + .from(target.table) + .where(inArray(target, ids)); + const found = new Set(rows.map(row => Number(row.id))); + const missing = ids.filter(id => !found.has(id)); + + if (missing.length > 0) + missingRelations.push({ field, ids: missing }); + continue; + } + + // A child whose identifier is gone is recreated rather than matched: + // dropping `id` is exactly the "create a new one" branch of the write + // protocol, so the entry comes back with its values and a fresh id. + const current = + (await readChildren(field, [itemId], tx)).get(itemId) ?? []; + const known = new Set(current.map(row => Number(row.id))); + + prepared[field] = (value as ChildValues[]).map(row => { + if (typeof row.id === "number" && known.has(row.id)) return row; + + // Dropping `id` is the "create a new one" branch of the write + // protocol, so the entry comes back with its values and a fresh id. + return Object.fromEntries( + Object.entries(row).filter(([key]) => key !== "id"), + ); + }); + } + + return { missingRelations, patch: prepared }; + }, + + membershipCondition: (field, filter) => { + const junction = junctionOf(field); + if (!junction) { + throw new ContentEngineError( + `Field "${field}" is not a to-many relation, so it has no membership filter.`, + { contentTypeId }, + ); + } + + if (!Number.isInteger(filter.contains)) return undefined; + + // Correlated, so the planner uses the junction's primary key and stops at + // the first matching row - and the outer query needs no `DISTINCT` to keep + // one record from appearing once per matching target. + return sql`exists (select 1 from ${junction.table} where ${junction.columns.itemId} = ${baseColumns.id} and ${junction.columns.relatedItemId} = ${filter.contains})`; + }, + + write: async (tx, itemId, patch) => { + const changes = await plan(tx, itemId, patch); + + for (const change of changes) { + if (change.kind === "relation") { + const desired = change.desired as number[]; + await assertTargetsExist(tx, change.field, desired); + await writeRelation(tx, change.field, itemId, desired); + continue; + } + + await writeRepeatable( + tx, + change.field, + itemId, + change.desired as ChildValues[], + ); + } + + return changes.map(change => change.field); + }, + }; +}; + +/** The generated junction table's name, for diagnostics and tests. */ +export const contentJunctionTableName = ( + tables: ContentAdvancedTables, + field: string, +): null | string => { + const junction = tables.junctions[field]; + + return junction ? getTableName(junction as unknown as PgTable) : null; +}; diff --git a/packages/vitnode/src/content/server/advanced-tables.ts b/packages/vitnode/src/content/server/advanced-tables.ts new file mode 100644 index 000000000..c98fe0670 --- /dev/null +++ b/packages/vitnode/src/content/server/advanced-tables.ts @@ -0,0 +1,285 @@ +import type { + PgColumn, + PgColumnBuilderBase, + PgTable, +} from "drizzle-orm/pg-core"; + +import { + index, + integer, + pgTable, + primaryKey, + serial, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +import type { + AnyContentTypeDefinition, + ContentFieldMap, + ContentOnDelete, +} from "../types"; +import type { ColumnReferenceThunk } from "./column-builders"; +import type { + ContentAdvancedTables, + ContentJunctionTable, + ContentReferences, + ContentRepeatableChildTable, +} from "./types"; + +import { ContentEngineError } from "../errors"; +import { + asContentRelationCollection, + contentInnerFields, + contentStorageColumns, +} from "../paths"; +import { buildContentColumn } from "./column-builders"; + +/** + * Generates the relational storage Stage 6 needs. + * + * Two shapes, both ordinary Drizzle tables so `drizzle-kit` discovers them the + * same way it discovers every other one - by runtime identity, when it globs the + * plugin's built `dist/src/database/*.js`. There is no JSONB column, no + * comma-separated identifier list and no property/value table anywhere in here: + * a to-many relation is a junction table with two foreign keys, and a repeatable + * is a child table with real columns, real constraints and real indexes. + */ + +/** + * The junction table for one to-many relation field. + * + * ```text + * example_articles_categories + * itemId -> example_articles.id ON DELETE CASCADE + * relatedItemId -> example_categories.id ON DELETE + * position integer NOT NULL + * createdAt timestamp NOT NULL DEFAULT now() + * + * PRIMARY KEY (itemId, relatedItemId) + * UNIQUE (itemId, position) + * INDEX (relatedItemId) + * ``` + * + * `itemId` always cascades: the references *belong to* the source record, so + * deleting it takes them in one statement rather than leaving rows pointing at + * nothing. The other side takes the field's own `onDelete`, which is what makes + * `restrict` mean "you cannot delete a category that is still in use" and have + * Postgres be the thing that enforces it - not a check in service code that a + * direct SQL delete would walk straight past. + * + * `position` is always stored, ordered relation or not, and is always contiguous + * from zero. That is what lets one `UNIQUE (item_id, position)` serve both: an + * ordered relation gets deterministic slots, and an unordered one gets a + * deterministic *read* order (ascending target id, assigned at write time) + * instead of whatever the planner felt like returning. + */ +export const createContentJunctionTable = ({ + contentTypeId, + field, + itemReference, + onDelete, + positionIndexName, + primaryKeyName, + relatedIndexName, + relatedReference, + tableName, +}: { + contentTypeId: string; + field: string; + itemReference: ColumnReferenceThunk; + onDelete: ContentOnDelete; + positionIndexName: string; + primaryKeyName: string; + relatedIndexName: string; + relatedReference: ColumnReferenceThunk; + tableName: string; +}): ContentJunctionTable => { + if (onDelete === "set null") { + throw new ContentEngineError( + `Relation field "${field}" cannot be \`onDelete: "set null"\`: a junction row has no nullable column to set.`, + { contentTypeId }, + ); + } + + const columns: Record = { + itemId: integer() + .notNull() + .references(itemReference, { onDelete: "cascade", onUpdate: "cascade" }), + relatedItemId: integer() + .notNull() + .references(relatedReference, { onDelete, onUpdate: "cascade" }), + position: integer().notNull(), + createdAt: timestamp().notNull().defaultNow(), + }; + + return pgTable( + tableName, + () => columns, + table => { + const columnMap = table as unknown as Record; + + return [ + // The identity of a reference *is* the pair, so a surrogate key would + // make "one row per target" a constraint somebody could forget to add. + primaryKey({ + columns: [columnMap.itemId, columnMap.relatedItemId], + name: primaryKeyName, + }), + uniqueIndex(positionIndexName).on(columnMap.itemId, columnMap.position), + // The primary key's B-tree can serve any prefix of `(itemId, ...)`, so + // only the reverse direction needs its own index - which is also the one + // an `ON DELETE RESTRICT` check on the target does. + index(relatedIndexName).on(columnMap.relatedItemId), + ]; + }, + ).enableRLS() as unknown as ContentJunctionTable; +}; + +/** + * The child table for one repeatable field. + * + * ```text + * example_articles_faq + * id serial PRIMARY KEY + * itemId -> example_articles.id ON DELETE CASCADE + * position integer NOT NULL + * createdAt timestamp NOT NULL DEFAULT now() + * updatedAt timestamp NOT NULL DEFAULT now() + * question varchar(200) NOT NULL + * answer text NOT NULL + * + * UNIQUE (itemId, position) + * ``` + * + * `id` is a `serial` of its own and **not** `(itemId, position)`. Position is + * where a child currently sits; identity is what a later edit addresses and what + * a revision restore matches an historical row against. Conflating the two would + * make "update the third FAQ entry" mean a different row after every reorder, + * and would make a restore recreate rows instead of putting values back. + * + * The unique index on `(itemId, position)` is what makes duplicate slots + * impossible rather than merely unlikely; the writer avoids transient collisions + * by replacing the whole list in one delete-then-insert inside the transaction + * that already holds the source row's lock. + */ +export const createContentRepeatableTable = ({ + contentTypeId, + fields, + itemReference, + positionIndexName, + tableName, +}: { + contentTypeId: string; + fields: ContentFieldMap; + itemReference: ColumnReferenceThunk; + positionIndexName: string; + tableName: string; +}): ContentRepeatableChildTable => { + const columns: Record = { + id: serial().primaryKey(), + itemId: integer() + .notNull() + .references(itemReference, { onDelete: "cascade", onUpdate: "cascade" }), + position: integer().notNull(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }; + + for (const [name, fieldValue] of Object.entries(fields)) { + columns[name] = buildContentColumn({ contentTypeId, fieldValue, name }); + } + + return pgTable( + tableName, + () => columns, + table => { + const columnMap = table as unknown as Record; + + return [ + uniqueIndex(positionIndexName).on(columnMap.itemId, columnMap.position), + ]; + }, + ).enableRLS() as unknown as ContentRepeatableChildTable; +}; + +/** + * Every generated collection table of one content type. + * + * Driven by `definition.advanced`, which `defineContentType` has already + * validated and named - so the table this creates and the table a migration + * creates are the same table by construction rather than by coincidence. + */ +export const createContentAdvancedTables = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + { + references = {} as ContentReferences, + table, + }: { + references?: ContentReferences; + table: PgTable; + }, +): ContentAdvancedTables => { + const { advanced, fields, id: contentTypeId } = definition; + const baseColumns = table as unknown as Record; + const referenceThunks = references as Record; + const itemReference: ColumnReferenceThunk = () => baseColumns.id; + + const junctions: Record = {}; + for (const entry of advanced.junctions) { + const fieldValue = asContentRelationCollection(fields[entry.field]); + if (!fieldValue) continue; + + // A self-relation resolves from the table being built. Requiring it in + // `references` would mean writing `() => thisContent.table.id` inside the + // model's own initializer, which widens the whole model to `any`. + const relatedReference: ColumnReferenceThunk | undefined = fieldValue.self + ? itemReference + : referenceThunks[entry.field]; + if (!relatedReference) { + throw new ContentEngineError( + `To-many relation "${entry.field}" has no entry in \`references\`. Add \`${entry.field}: () => .id\` - the junction table's foreign key needs a target just as much as a column would.`, + { contentTypeId }, + ); + } + + junctions[entry.field] = createContentJunctionTable({ + contentTypeId, + field: entry.field, + itemReference, + onDelete: fieldValue.onDelete, + positionIndexName: entry.positionIndexName, + primaryKeyName: entry.primaryKeyName, + relatedIndexName: entry.relatedIndexName, + relatedReference, + tableName: entry.tableName, + }); + } + + const repeatables: Record> = {}; + for (const entry of advanced.repeatables) { + repeatables[entry.field] = createContentRepeatableTable({ + contentTypeId, + // Flattened for symmetry with the base table, though a repeatable's leaves + // are all scalars already - `contentStorageColumns` is a no-op here and + // stays in the path so a future nested leaf is a compile error rather than + // a column that silently never appears. + fields: contentStorageColumns(contentInnerFields(fields[entry.field])), + itemReference, + positionIndexName: entry.positionIndexName, + tableName: entry.tableName, + }); + } + + return { junctions, repeatables }; +}; + +/** Column name -> Drizzle column on one generated collection table. */ +export const contentCollectionTableColumns = ( + table: ContentJunctionTable | ContentRepeatableChildTable, +): Record => table as unknown as Record; diff --git a/packages/vitnode/src/content/server/collection-api.ts b/packages/vitnode/src/content/server/collection-api.ts new file mode 100644 index 000000000..2ad83818c --- /dev/null +++ b/packages/vitnode/src/content/server/collection-api.ts @@ -0,0 +1,286 @@ +import type { AnyContentTypeDefinition } from "../types"; + +import { ContentEngineError } from "../errors"; + +/** + * The convenience collection API, built once for both services. + * + * Every method here is a **read-modify-write**, and that is the whole reason it + * is one module rather than two: the read has to happen after the source row is + * locked, and a helper that read before the lock would lose one of two + * concurrent additions with nothing to show it had. The service supplies the + * locking - `run` below - and this file supplies only the arithmetic. + * + * `set` is the exception that proves the rule: it replaces the whole collection, + * so it does not read at all and cannot lose anything. + */ + +/** + * Locks the source record, reads one collection, and applies what `compute` + * makes of it - all inside one transaction. + * + * `compute` runs **after** the row lock, so the state it derives the next + * collection from is the committed one. It may throw: a reorder that is not a + * permutation of what is stored is refused there, inside the lock, against the + * list the write will actually replace. + */ +export type ContentCollectionRunner = ( + itemId: number, + field: string, + compute: (current: unknown[]) => unknown[], + options: TOptions | undefined, +) => Promise; + +/** Reads one collection without locking. Used only by `get` and `list`. */ +export type ContentCollectionReader = ( + itemId: number, + field: string, + options: unknown, +) => Promise; + +/** Replaces a whole collection. `set` needs no lock-then-read. */ +export type ContentCollectionWriter = ( + itemId: number, + field: string, + next: readonly unknown[], + options: TOptions | undefined, +) => Promise; + +export interface ContentCollectionApi { + read: ContentCollectionReader; + run: ContentCollectionRunner; + write: ContentCollectionWriter; +} + +/** + * A reorder has to be a permutation of what is stored. + * + * Refused rather than treated as a `set`, because the two mean different things + * and only one of them is reversible by looking at the request: a reorder that + * silently dropped an entry would look like a successful drag. Checked inside + * the lock, against the list the write is about to replace - checking it against + * a list read earlier would refuse a valid reorder, or accept an invalid one, + * whenever somebody else had written in between. + */ +export const assertContentPermutation = ({ + contentTypeId, + current, + field, + next, + noun, +}: { + contentTypeId: string; + current: readonly number[]; + field: string; + next: readonly number[]; + noun: string; +}): void => { + const before = [...current].sort((a, b) => a - b); + const after = [...new Set(next)].sort((a, b) => a - b); + + const same = + before.length === after.length && + before.every((id, index) => id === after[index]); + if (same && next.length === new Set(next).size) return; + + throw new ContentEngineError( + `Reorder of "${field}" must list exactly the ${noun} ids it already has, once each. Use \`set\` to add or remove.`, + { contentTypeId }, + ); +}; + +const asNumbers = (current: readonly unknown[]): number[] => + current.map(value => Number(value)).filter(value => Number.isInteger(value)); + +const asRows = (current: readonly unknown[]): Record[] => + current.filter( + (value): value is Record => + typeof value === "object" && value !== null, + ); + +/** + * The five to-many relation operations, for one field. + * + * `add` of a target already present, `remove` of one that is not there and + * `reorder` to the stored order all compute a list equal to what is stored, so + * the diff finds nothing and the write is a no-op - no `updatedAt`, no version + * bump, no revision, no event. That falls out of computing the whole next state + * rather than issuing a targeted `INSERT`, which is why it holds for every one of + * them without a special case. + */ +export const buildContentRelationOperations = ({ + api, + contentTypeId, + field, +}: { + api: ContentCollectionApi; + contentTypeId: string; + field: string; +}) => ({ + add: async ( + itemId: number, + relatedItemId: number, + options?: TOptions, + ): Promise => + await api.run( + itemId, + field, + current => { + const ids = asNumbers(current); + + return ids.includes(relatedItemId) ? ids : [...ids, relatedItemId]; + }, + options, + ), + + get: async (itemId: number, options?: unknown): Promise => + asNumbers(await api.read(itemId, field, options)), + + remove: async ( + itemId: number, + relatedItemId: number, + options?: TOptions, + ): Promise => + await api.run( + itemId, + field, + current => asNumbers(current).filter(id => id !== relatedItemId), + options, + ), + + reorder: async ( + itemId: number, + relatedItemIds: readonly number[], + options?: TOptions, + ): Promise => + await api.run( + itemId, + field, + current => { + assertContentPermutation({ + contentTypeId, + current: asNumbers(current), + field, + next: relatedItemIds, + noun: "target", + }); + + return [...relatedItemIds]; + }, + options, + ), + + set: async ( + itemId: number, + relatedItemIds: readonly number[], + options?: TOptions, + ): Promise => + await api.write(itemId, field, relatedItemIds, options), +}); + +/** The six repeatable operations, for one field. Same locking, same no-op rule. */ +export const buildContentRepeatableOperations = ({ + api, + contentTypeId, + field, +}: { + api: ContentCollectionApi; + contentTypeId: string; + field: string; +}) => ({ + create: async ( + itemId: number, + values: Record, + options?: TOptions, + ): Promise => + await api.run( + itemId, + field, + current => [...asRows(current), values], + options, + ), + + delete: async ( + itemId: number, + childId: number, + options?: TOptions, + ): Promise => + await api.run( + itemId, + field, + current => asRows(current).filter(row => row.id !== childId), + options, + ), + + list: async ( + itemId: number, + options?: unknown, + ): Promise[]> => + asRows(await api.read(itemId, field, options)), + + reorder: async ( + itemId: number, + childIds: readonly number[], + options?: TOptions, + ): Promise => + await api.run( + itemId, + field, + current => { + const rows = asRows(current); + assertContentPermutation({ + contentTypeId, + current: rows.map(row => Number(row.id)), + field, + next: childIds, + noun: "entry", + }); + + const byId = new Map(rows.map(row => [Number(row.id), row])); + + return childIds.map(childId => byId.get(childId) ?? {}); + }, + options, + ), + + set: async ( + itemId: number, + rows: readonly Record[], + options?: TOptions, + ): Promise => await api.write(itemId, field, rows, options), + + update: async ( + itemId: number, + childId: number, + values: Record, + options?: TOptions, + ): Promise => + await api.run( + itemId, + field, + current => + asRows(current).map(row => + Number(row.id) === childId ? { ...row, ...values, id: childId } : row, + ), + options, + ), +}); + +/** Which of the two shapes a collection field is, by name. */ +export const contentCollectionKinds = ( + definition: AnyContentTypeDefinition, + fields: readonly string[], +): { relations: string[]; repeatables: string[] } => { + const relations: string[] = []; + const repeatables: string[] = []; + + for (const field of fields) { + if (definition.fields[field]?.kind === "repeatable") { + repeatables.push(field); + continue; + } + relations.push(field); + } + + return { relations, repeatables }; +}; diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index e460fd74b..3960a9a14 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -181,6 +181,25 @@ export const buildContentColumn = ({ }): PgColumnBuilderBase => { const { nullable } = fieldValue; + // A group is several columns and a repeatable is a table, so neither reaches + // here: `contentStorageColumns` flattens the first and drops the second before + // the table generator ever sees them. Reaching this line means a caller + // skipped that flattening, which would otherwise show up as an untyped column + // in the migration rather than as a message. + if (fieldValue.kind === "group" || fieldValue.kind === "repeatable") { + throw new ContentEngineError( + `Field "${name}" is a ${fieldValue.kind} and has no column of its own. Flatten the field map with \`contentStorageColumns\` before building columns from it.`, + { contentTypeId }, + ); + } + + if (fieldValue.kind === "relation" && fieldValue.multiple) { + throw new ContentEngineError( + `Field "${name}" is a to-many relation, whose values live in a generated junction table rather than in a column.`, + { contentTypeId }, + ); + } + switch (fieldValue.kind) { case "boolean": return withModifiers(boolean(), { diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index 2fb584253..536551c3b 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -4,12 +4,15 @@ import type { EventEmitResult } from "../../api/models/events"; import type { ContentEventAction } from "../events"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentEditorialOutcome } from "./editorial-service"; -import type { ContentSearchSyncOutcome } from "./search-sync"; - import type { AnyContentModel } from "./model"; +import type { ContentSearchSyncOutcome } from "./search-sync"; import { emitContentEvent } from "./emit"; -import { syncContentLocalizedSearch, syncContentSearch } from "./search-sync"; +import { + contentSearchAdvancedValues, + syncContentLocalizedSearch, + syncContentSearch, +} from "./search-sync"; /** A `delete` has no event action of its own beyond the existing one. */ const EVENT_ACTION: Record< @@ -98,11 +101,6 @@ export interface ContentEditorialEffectsOptions { } export interface ContentEditorialEffectsResult { - /** - * One outcome per language, for a localized content type. Empty for every - * other one, whose single outcome is on `search`. - */ - searchByLocale?: ContentSearchSyncOutcome[]; /** * What the event transport reported. `null` for a no-op outcome, which emits * nothing at all. @@ -114,6 +112,11 @@ export interface ContentEditorialEffectsResult { */ event: EventEmitResult | null; search: ContentSearchSyncOutcome | null; + /** + * One outcome per language, for a localized content type. Empty for every + * other one, whose single outcome is on `search`. + */ + searchByLocale?: ContentSearchSyncOutcome[]; } /** @@ -166,6 +169,14 @@ export const contentEditorialEffects = async ( search: null, searchByLocale: model ? await syncContentLocalizedSearch(c, model, { + // A shared field moved, so **every** locale's document is rewritten + // - and each rewrite has to carry the shared collections too, or a + // title edit would drop the FAQ out of every language at once. + advanced: await contentSearchAdvancedValues( + c, + model, + idOf(outcome.row), + ), changed: outcome.changed, changedFields: outcome.changedFields, operation: outcome.operation, @@ -179,6 +190,12 @@ export const contentEditorialEffects = async ( return { event, search: await syncContentSearch(c, definition, { + // Read back only when a document is actually made of collection values, + // and only the collections it names. A content type that indexes none - + // which is every Stage 1-5 one - pays for nothing here. + advanced: model + ? await contentSearchAdvancedValues(c, model, idOf(outcome.row)) + : undefined, changed: outcome.changed, changedFields: outcome.changedFields, operation: outcome.operation, @@ -188,6 +205,13 @@ export const contentEditorialEffects = async ( }; }; +/** The record's own identifier, off a row whose type is still open. */ +const idOf = (row: object): number => { + const id = (row as { id?: unknown }).id; + + return typeof id === "number" ? id : 0; +}; + /** * Says why nothing was indexed, rather than indexing the wrong thing. * diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index e3dab4da7..a18f76114 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -12,23 +12,44 @@ import type { ContentActor, ContentRevisionOperation } from "../revisions"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, + ContentChangedPath, ContentCreateInput, - ContentFieldName, + ContentInnerFieldsOf, + ContentRelationCollectionName, + ContentRepeatableFieldName, + ContentRepeatableInputRow, + ContentRepeatableRow, ContentSelect, ContentUpdateInput, + ContentValuesOf, } from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentRevisionsModel } from "./revisions-model"; import type { ContentSchedulesModel } from "./schedules-model"; import type { ContentDatabase } from "./service"; -import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; +import { + CONTENT_EDITORIAL_FIELDS, + CONTENT_PUBLICATION_FIELDS, + CONTENT_SYSTEM_FIELDS, +} from "../const"; import { ContentEngineError, ContentRevisionNotRestorable, ContentVersionConflict, } from "../errors"; import { partitionContentFields } from "../localization"; -import { diffChangedFields, toColumnValues } from "./query"; +import { contentColumnsToValues, contentStorageColumns } from "../paths"; +import { + buildContentRelationOperations, + buildContentRepeatableOperations, + contentCollectionKinds, +} from "./collection-api"; +import { + changedPathsToColumns, + diffChangedPaths, + toInsertColumns, +} from "./query"; import { contentRevisionSnapshot, projectRevisionSnapshot, @@ -47,7 +68,8 @@ import { createSlugNormalizer } from "./slugs"; export interface ContentEditorialOutcome { /** `false` when nothing moved: no write, no revision, no event, no tags. */ changed: boolean; - changedFields: ContentFieldName[]; + /** Canonical paths - see {@link ContentUpdateResult.changedFields}. */ + changedFields: ContentChangedPath[]; operation: ContentRevisionOperation; /** The slug the record answered to *before* this mutation, if it has one. */ previousSlug: null | string; @@ -97,6 +119,28 @@ export interface ContentEditorialService { id: number, options: ContentEditorialPublicationOptions, ) => Promise | null>; + /** + * Typed to-many relation operations, keyed by the content type's actual + * relation collection names. + * + * The **editorial** ones: each takes an `actor` and an `expectedVersion`, + * bumps the version exactly once per real mutation, writes exactly one + * revision, and answers a stale expectation with a structured + * `ContentVersionConflict`. The plain `service.relations` does none of that - + * it has no version column to guard and no history to write - so the two are + * deliberately separate objects rather than one with different behaviour + * depending on where it came from. + */ + relations: Record< + ContentRelationCollectionName, + ContentEditorialRelationMethods + >; + /** The editorial repeatable operations, each carrying its own child shape. */ + repeatable: { + [ + K in ContentRepeatableFieldName + ]: ContentEditorialRepeatableMethods; + }; restore: ( id: number, revisionId: number, @@ -123,6 +167,77 @@ export interface ContentEditorialService { ) => Promise | null>; } +/** + * The editorial to-many relation operations. + * + * Every mutating one requires the same `actor` and `expectedVersion` an editorial + * `update` does, for the same reason: a collection mutation is an edit of the + * source record, and an edit that could not lose a race would be the only one in + * the engine that cannot. + */ +export interface ContentEditorialRelationMethods { + add: ( + itemId: number, + relatedItemId: number, + options: ContentEditorialWriteOptions, + ) => Promise | null>; + /** The current targets, in stored order. Reads take no version. */ + get: (itemId: number, options?: ContentEditorialOptions) => Promise; + remove: ( + itemId: number, + relatedItemId: number, + options: ContentEditorialWriteOptions, + ) => Promise | null>; + reorder: ( + itemId: number, + relatedItemIds: readonly number[], + options: ContentEditorialWriteOptions, + ) => Promise | null>; + set: ( + itemId: number, + relatedItemIds: readonly number[], + options: ContentEditorialWriteOptions, + ) => Promise | null>; +} + +/** The editorial repeatable operations, typed from the field's own leaves. */ +export interface ContentEditorialRepeatableMethods { + create: ( + itemId: number, + values: ContentValuesOf>, + options: ContentEditorialWriteOptions, + ) => Promise | null>; + delete: ( + itemId: number, + childId: number, + options: ContentEditorialWriteOptions, + ) => Promise | null>; + list: ( + itemId: number, + options?: ContentEditorialOptions, + ) => Promise< + ContentRepeatableRow>[] + >; + reorder: ( + itemId: number, + childIds: readonly number[], + options: ContentEditorialWriteOptions, + ) => Promise | null>; + set: ( + itemId: number, + rows: readonly ContentRepeatableInputRow< + ContentInnerFieldsOf + >[], + options: ContentEditorialWriteOptions, + ) => Promise | null>; + update: ( + itemId: number, + childId: number, + values: Partial>>, + options: ContentEditorialWriteOptions, + ) => Promise | null>; +} + /** * The transactional half of the Content Engine. * @@ -139,6 +254,7 @@ export interface ContentEditorialService { export const createContentEditorialService = < TDefinition extends AnyContentTypeDefinition, >({ + advanced, c, columns, definition, @@ -146,6 +262,8 @@ export const createContentEditorialService = < schemas, table, }: { + /** The collection store, or nothing for a content type that declares none. */ + advanced?: ContentAdvancedStore; c: Context; columns: Record; definition: TDefinition; @@ -161,13 +279,29 @@ export const createContentEditorialService = < } const contentTypeId = definition.id; + const store = advanced; // Shared fields only, everywhere below. A localized field is a column on the // translation table, so selecting it here would address something that does not // exist, and diffing it would report every language's value as changed at once. // `partitionContentFields` returns every field for a content type without // localization, so this is exactly the previous behaviour there. - const fields = partitionContentFields(definition.fields).sharedFields; - const fieldNames = Object.keys(fields) as ContentFieldName[]; + const { collectionFields, sharedFields } = partitionContentFields( + definition.fields, + ); + const fields = sharedFields; + const storageColumns = contentStorageColumns(fields); + // Every canonical path a create "changes", which is all of them: a scalar by + // its own name, a group by each of its leaves, a collection whole. + const allPaths = [ + ...Object.entries(fields).flatMap(([name, fieldValue]) => + fieldValue.kind === "group" + ? Object.keys( + (fieldValue as { fields: Record }).fields, + ).map(leaf => `${name}.${leaf}`) + : [name], + ), + ...Object.keys(collectionFields), + ] as ContentChangedPath[]; const primaryCursor = columns.id; const versionColumn = columns.version; const publication = definition.publication.enabled; @@ -175,17 +309,31 @@ export const createContentEditorialService = < ? definition.publicApi.slugField : null; - const ownColumnNames = [ - "id", - "createdAt", - "updatedAt", + const generatedColumnNames = [ + ...CONTENT_SYSTEM_FIELDS, ...(publication ? CONTENT_PUBLICATION_FIELDS : []), ...CONTENT_EDITORIAL_FIELDS, - ...fieldNames, + ]; + const ownColumnNames = [ + ...generatedColumnNames, + ...Object.keys(storageColumns), ]; const ownSelection = (): Record => Object.fromEntries(ownColumnNames.map(name => [name, columns[name]])); + /** A column row in the logical shape - see the plain service's twin. */ + const projectRow = ( + row: Record, + ): Record => { + const projected: Record = {}; + + for (const name of generatedColumnNames) { + if (name in row) projected[name] = row[name]; + } + + return { ...projected, ...contentColumnsToValues(fields, row) }; + }; + const revisions = createContentRevisionsModel({ c, definition, pluginId }); const schedules = definition.editorial.scheduling.enabled ? createContentSchedulesModel({ c, definition, pluginId }) @@ -196,7 +344,7 @@ export const createContentEditorialService = < ); const toRow = (row: Record): ContentSelect => - row as ContentSelect; + projectRow(row) as ContentSelect; const versionOf = (row: Record): number => typeof row.version === "number" ? row.version : 1; @@ -248,18 +396,33 @@ export const createContentEditorialService = < row: Record; version: number; }, - ): Promise => - await revisions.capture(tx, { + ): Promise => { + const itemId = typeof row.id === "number" ? row.id : 0; + // Read **after** the write and **inside** the transaction, so the snapshot + // is the post-mutation state rather than the state the caller sent. A delete + // is the one case where the rows are already gone, and the caller passes the + // collections it read beforehand instead. + const collections = + operation === "delete" + ? ((row.__collections as Record | undefined) ?? {}) + : ((await store?.load(itemId, tx)) ?? {}); + + return await revisions.capture(tx, { actor, changedFields, - itemId: typeof row.id === "number" ? row.id : 0, + itemId, operation, restoredFromRevisionId, // Stamped with the version the record now holds, which for a delete is the // one it would have had - see `remove` below. - snapshot: contentRevisionSnapshot(definition, { ...row, version }), + snapshot: contentRevisionSnapshot(definition, { + ...row, + ...collections, + version, + }), version, }); + }; /** * The conditional write every editorial mutation goes through. @@ -270,6 +433,31 @@ export const createContentEditorialService = < * when nothing matched, to tell a deleted record (404) from a moved one (409) * - the same shape `transition` in the plain service already uses. */ + /** + * Takes the source row's write lock. + * + * Only for a content type with collections: an ordinary editorial update is + * already serialised by its guarded `UPDATE`, and a read-modify-write is not - + * it reads the junction and child rows first, and that read has to be inside + * the same lock as the write it feeds. + */ + const lockRow = async ( + tx: ContentDatabase, + id: number, + { force = false }: { force?: boolean } = {}, + ): Promise => { + if (!force && !store?.enabled) return true; + + const [row] = await tx + .select({ id: primaryCursor }) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1) + .for("update"); + + return row !== undefined; + }; + const guardedWrite = async ( tx: ContentDatabase, id: number, @@ -377,22 +565,149 @@ export const createContentEditorialService = < }; }); - return { + /** + * Locks the source record, reads one collection and applies what `compute` + * makes of it - all inside the transaction that will write it. + * + * Two guarantees at once, and the order is what produces both: + * + * 1. `SELECT ... FOR UPDATE` before the read, so the next state is computed + * from the committed collection rather than from one a concurrent writer has + * since replaced; + * 2. the ordinary `update` afterwards, so the caller's `expectedVersion` still + * decides the winner. A loser waits for the lock, then finds the version has + * moved, and is told so - it never merges silently and never overwrites. + */ + /** + * Every editorial collection mutation needs an actor and an expected version. + * + * Checked rather than defaulted: a collection mutation is an edit of the record, + * and defaulting the version would make this the one editorial write that + * silently overwrites whatever it finds. + */ + const assertWriteOptions = ( + field: string, + options: Partial | undefined, + ): ContentEditorialWriteOptions => { + if (options?.actor && typeof options.expectedVersion === "number") { + return options as ContentEditorialWriteOptions; + } + + throw new ContentEngineError( + `An editorial mutation of "${field}" needs \`{ actor, expectedVersion }\`. Use \`model.service(c).${field}\` for a write with no version guard.`, + { contentTypeId }, + ); + }; + + const runCollection = async ( + itemId: number, + field: string, + compute: (current: unknown[]) => unknown[], + options: Partial | undefined, + ): Promise | null> => { + const write = assertWriteOptions(field, options); + + return await transact(write, async tx => { + if (!(await lockRow(tx, itemId, { force: true }))) return null; + + const current = await store?.load(itemId, tx, [field]); + const value = current?.[field]; + const next = compute(Array.isArray(value) ? value : []); + + return await editorial.update( + itemId, + schemas.update.parse({ + [field]: next, + }), + { ...write, tx }, + ); + }); + }; + + const collectionApi = { + read: async (itemId: number, field: string, options: unknown) => { + const loaded = await store?.load( + itemId, + (options as ContentEditorialOptions | undefined)?.tx ?? c.get("db"), + [field], + ); + const value = loaded?.[field]; + + return Array.isArray(value) ? value : []; + }, + run: runCollection, + write: async ( + itemId: number, + field: string, + next: readonly unknown[], + options: Partial | undefined, + ) => { + // `set` replaces the collection whole, so it reads nothing and needs no + // lock of its own - the guarded `UPDATE` inside `update` is the whole + // story. It still needs the actor and the expected version, for the same + // reason every other editorial write does. + const write = assertWriteOptions(field, options); + + return await editorial.update( + itemId, + { [field]: [...next] } as ContentUpdateInput, + write, + ); + }, + }; + + const { relations, repeatables } = contentCollectionKinds( + definition, + store?.fields ?? [], + ); + + const mutableRelations: Record< + string, + ContentEditorialRelationMethods + > = {}; + const mutableRepeatables: Record< + string, + ContentEditorialRepeatableMethods + > = {}; + + for (const field of relations) { + mutableRelations[field] = buildContentRelationOperations({ + api: collectionApi, + contentTypeId, + field, + }); + } + for (const field of repeatables) { + mutableRepeatables[field] = buildContentRepeatableOperations({ + api: collectionApi, + contentTypeId, + field, + }) as ContentEditorialRepeatableMethods; + } + + const editorial: ContentEditorialService = { create: async (values, options) => await transact(options, async tx => { const parsed = schemas.create.parse(values) as Record; const [row] = await tx .insert(table) - .values(toColumnValues(fields, withCreateSlugs(parsed))) + .values(toInsertColumns(fields, withCreateSlugs(parsed))) .returning(ownSelection()); + // In the same transaction as the row: a create that committed its + // categories and rolled back its article would leave junction rows + // pointing at nothing. + if (store?.enabled && typeof row.id === "number") { + await store.write(tx, row.id, parsed); + } + const version = versionOf(row); const revisionId = await capture(tx, { actor: options.actor, - // Everything is new, so every field "changed" - which is what the + // Everything is new, so every path "changed" - which is what the // history should say about a create. - changedFields: fieldNames, + changedFields: allPaths, operation: "create", row, version, @@ -400,7 +715,7 @@ export const createContentEditorialService = < return { changed: true, - changedFields: fieldNames, + changedFields: allPaths, operation: "create", previousSlug: null, restoredFromRevisionId: null, @@ -412,6 +727,11 @@ export const createContentEditorialService = < delete: async (id, options) => await transact(options, async tx => { + // Read before the `DELETE`: `ON DELETE CASCADE` takes the junction and + // child rows with the record, so this is the last moment the history can + // record what the record was related to. + const collections = (await store?.load(id, tx)) ?? {}; + // Same guard as `guardedWrite`, in a `DELETE` - the version has to be // part of the statement that removes the row, not checked before it. const [row] = await tx @@ -452,7 +772,7 @@ export const createContentEditorialService = < actor: options.actor, changedFields: [], operation: "delete", - row, + row: { ...row, __collections: collections }, version, }); @@ -515,8 +835,32 @@ export const createContentEditorialService = < }); } - const patch = withUpdateSlugs(parsed.data); - const changedFields = diffChangedFields(fieldNames, current, patch); + // Made applicable to the record as it stands *before* anything is + // compared: a child whose id is gone comes back as a new entry, and a + // relation target that is gone stops the restore entirely rather than + // letting it write half a state. + const prepared = (await store?.prepareRestore(tx, id, parsed.data)) ?? { + missingRelations: [], + patch: parsed.data, + }; + + if (prepared.missingRelations.length > 0) { + throw new ContentRevisionNotRestorable({ + contentTypeId, + fields: prepared.missingRelations.map( + entry => `${entry.field} (${entry.ids.join(", ")})`, + ), + revisionId, + }); + } + + const patch = withUpdateSlugs(prepared.patch); + const changedPaths = diffChangedPaths(fields, current, patch); + const changedCollections = (await store?.diff(tx, id, patch)) ?? []; + const changedFields = [ + ...changedPaths, + ...changedCollections, + ] as ContentChangedPath[]; if (changedFields.length === 0) { return { @@ -536,13 +880,14 @@ export const createContentEditorialService = < tx, id, options.expectedVersion, - toColumnValues( - fields, - Object.fromEntries(changedFields.map(key => [key, patch[key]])), - ), + changedPaths.length > 0 + ? changedPathsToColumns(fields, patch, changedPaths) + : {}, ); if (!row) return null; + if (changedCollections.length > 0) await store?.write(tx, id, patch); + const version = versionOf(row); const newRevisionId = await capture(tx, { actor: options.actor, @@ -565,6 +910,11 @@ export const createContentEditorialService = < }; }), + relations: mutableRelations, + + repeatable: + mutableRepeatables as ContentEditorialService["repeatable"], + revisions, schedules, @@ -585,10 +935,25 @@ export const createContentEditorialService = < // slug in a different case counts as no change. const patch = withUpdateSlugs(schemas.update.parse(values)); + // Locked before the collections are read, so a collection mutation + // derives its next state from the committed one. The version guard below + // is still what decides the winner - the lock only makes the loser wait + // long enough to be told it lost, instead of overwriting from a state it + // read before the winner existed. + if (!(await lockRow(tx, id))) return null; + const current = await readOne(id, tx); if (!current) return null; - const changedFields = diffChangedFields(fieldNames, current, patch); + const changedPaths = diffChangedPaths(fields, current, patch); + // Computed before the guarded write, so "nothing moved" is one decision: + // a reorder to the order that is already stored is a no-op exactly like + // re-sending an unchanged title. + const changedCollections = (await store?.diff(tx, id, patch)) ?? []; + const changedFields = [ + ...changedPaths, + ...changedCollections, + ] as ContentChangedPath[]; // A no-op is still a *successful* write from the caller's point of view, // but it must not bump the version or leave a revision: an editor who @@ -608,17 +973,24 @@ export const createContentEditorialService = < }; } + // The version guard runs **first**, before a single junction or child + // row is touched. That ordering is the whole concurrency story: a writer + // holding a stale `expectedVersion` fails here and leaves the + // collections exactly as it found them, so there is no partial mutation + // to roll back and no window where one writer's categories sit under + // another writer's version. const row = await guardedWrite( tx, id, options.expectedVersion, - toColumnValues( - fields, - Object.fromEntries(changedFields.map(key => [key, patch[key]])), - ), + changedPaths.length > 0 + ? changedPathsToColumns(fields, patch, changedPaths) + : {}, ); if (!row) return null; + if (changedCollections.length > 0) await store?.write(tx, id, patch); + const version = versionOf(row); const revisionId = await capture(tx, { actor: options.actor, @@ -640,4 +1012,6 @@ export const createContentEditorialService = < }; }), }; + + return editorial; }; diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index 92bd77da0..67cf47cb5 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -11,6 +11,7 @@ import { and, eq, exists, not, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { AnyContentTypeDefinition, ContentPublicSelect } from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentLanguage } from "./language-resolver"; import type { ContentPublicService } from "./public-service"; @@ -21,11 +22,14 @@ import { } from "../const"; import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; +import { isContentRelationCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; import { findContentLanguage } from "./language-resolver"; import { clampContentPublicPageSize, + contentPublicCollectionFields, createContentPublicProjector, + nestContentPublicRow, } from "./public-service"; import { contentTranslationPublicationColumns, @@ -106,6 +110,7 @@ const EMPTY_PAGE = { export const createContentLocalizedPublicService = < TDefinition extends AnyContentTypeDefinition, >({ + advanced, c, columns, definition, @@ -113,6 +118,8 @@ export const createContentLocalizedPublicService = < translationColumns, translationTable, }: { + /** The collection store, or nothing for a content type that declares none. */ + advanced?: ContentAdvancedStore; c: Context; columns: Record; definition: TDefinition; @@ -131,9 +138,8 @@ export const createContentLocalizedPublicService = < ); } - const { localizedFields, sharedFields } = partitionContentFields( - definition.fields, - ); + const { collectionFields, localizedFields, sharedFields } = + partitionContentFields(definition.fields); const isLocalized = (name: string): boolean => localizedFields[name] !== undefined; @@ -149,12 +155,77 @@ export const createContentLocalizedPublicService = < const orderable = publicOrderableColumns(definition); const project = createContentPublicProjector(definition); - const exposedShared = publicApi.fields.filter(name => !isLocalized(name)); - const exposedLocalized = publicApi.fields.filter(isLocalized); + /** + * Which half of the join each exposed name is read from. + * + * A canonical path is answered by its **container**: `seo.title` is on the + * translation table when `seo` is a localized group and on the base row + * otherwise, because a group moves whole. A collection is neither - it has no + * column on either table, and is batch-loaded after the page is fetched. + */ + const ownerOf = (name: string): string => { + const path = splitContentFieldPath(name); + + return path ? path[0] : name; + }; + const isColumnField = (name: string): boolean => { + const fieldValue = definition.fields[ownerOf(name)]; + + if (!fieldValue) return true; + if (fieldValue.kind === "repeatable") return false; + + return !isContentRelationCollection(fieldValue); + }; + const exposedColumns = publicApi.fields.filter(isColumnField); + const exposedShared = exposedColumns.filter( + name => !isLocalized(ownerOf(name)), + ); + const exposedLocalized = exposedColumns.filter(name => + isLocalized(ownerOf(name)), + ); + const publicCollections = contentPublicCollectionFields(definition); + const localizedColumnByPath = new Map( + definition.advanced.leaves + .filter(leaf => leaf.localized) + .map(leaf => [leaf.path, leaf.columnName]), + ); + + /** + * Attaches the exposed collections to a page of rows. + * + * One batch per collection field for the whole page. A collection is shared, + * so it is the same in every language - there is nothing locale-aware to do + * here, and doing it once per page rather than once per locale is the point. + */ + const withCollections = async ( + rows: readonly Record[], + ): Promise[]> => { + const nested = rows.map(nestContentPublicRow); + if (publicCollections.length === 0 || nested.length === 0) return nested; + + const ids = nested + .map(row => row.id) + .filter((id): id is number => typeof id === "number"); + // Only the collections the allowlist actually exposes: querying a private + // junction table to discard its rows afterwards is work with no answer + // attached, and `publicCollections` is already exactly that list. + const loaded = await advanced?.loadMany( + ids, + c.get("db"), + publicCollections, + ); + + return nested.map(row => ({ + ...row, + ...(typeof row.id === "number" ? loaded?.get(row.id) : undefined), + })); + }; const sharedSearchable = publicApi.searchableFields.filter( - name => !isLocalized(name), + name => !isLocalized(ownerOf(name)), + ); + const localizedSearchable = publicApi.searchableFields.filter(name => + isLocalized(ownerOf(name)), ); - const localizedSearchable = publicApi.searchableFields.filter(isLocalized); // Two aliases of the same table, because one statement reads it twice: the // language the reader asked for, and the one it may fall back to. Named rather @@ -238,13 +309,28 @@ export const createContentLocalizedPublicService = < * translation matched and every localized value comes from it, or none did and * every value comes from the fallback. */ + /** + * A canonical path, resolved to the column on the **aliased** translation. + * + * The two aliases are fresh Drizzle tables, so they carry the generated + * column keys and not the path aliases `contentTranslationTableColumns` + * registers. Mapping here rather than there is what keeps the alias trick a + * convenience on the model's column map instead of something every join has + * to reproduce. + */ + const translationColumnName = (name: string): string => + localizedColumnByPath.get(name) ?? name; + const localizedValue = ( name: string, withFallback: boolean, - ): PgColumn | SQL => - withFallback - ? sql`case when ${requestedRows.itemId} is not null then ${requestedRows[name]} else ${fallbackRows[name]} end` - : requestedRows[name]; + ): PgColumn | SQL => { + const column = translationColumnName(name); + + return withFallback + ? sql`case when ${requestedRows.itemId} is not null then ${requestedRows[column]} else ${fallbackRows[column]} end` + : requestedRows[column]; + }; const selection = ( withFallback: boolean, @@ -377,7 +463,11 @@ export const createContentLocalizedPublicService = < { limit: 1 }, ); - return row ? projectRow(row, scope) : null; + if (!row) return null; + + const [withAdvanced] = await withCollections([row]); + + return projectRow(withAdvanced, scope); }; return { @@ -413,10 +503,10 @@ export const createContentLocalizedPublicService = < const raw = filters as Record; const sharedFilters = Object.fromEntries( - Object.entries(raw).filter(([name]) => !isLocalized(name)), + Object.entries(raw).filter(([name]) => !isLocalized(ownerOf(name))), ); const localizedFilters = Object.fromEntries( - Object.entries(raw).filter(([name]) => isLocalized(name)), + Object.entries(raw).filter(([name]) => isLocalized(ownerOf(name))), ); const term = query.search; @@ -450,8 +540,9 @@ export const createContentLocalizedPublicService = < allowed: publicApi.filterableFields, columns, contentTypeId, - fields: sharedFields, + fields: { ...collectionFields, ...sharedFields }, filters: sharedFilters, + membership: advanced?.membershipCondition, }), anyOf( sharedSearch, @@ -502,7 +593,9 @@ export const createContentLocalizedPublicService = < }); return { - edges: data.edges.map(row => projectRow(row, resolved)), + edges: (await withCollections(data.edges)).map(row => + projectRow(row, resolved), + ), pageInfo: data.pageInfo, }; }, diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index b92440dcf..5329b1667 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -6,6 +6,7 @@ import type { AnyContentTypeDefinition, ResolvedContentLocalizationConfig, } from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentEditorialService } from "./editorial-service"; import type { ContentLocalizedService } from "./localized-service"; import type { ContentPublicService } from "./public-service"; @@ -13,6 +14,7 @@ import type { ContentService } from "./service"; import type { ContentTranslationEditorialService } from "./translation-editorial-service"; import type { ContentTranslationModel } from "./translation-model"; import type { + ContentAdvancedTables, ContentColumnName, ContentReferences, ContentTableFor, @@ -21,6 +23,8 @@ import type { } from "./types"; import { ContentEngineError } from "../errors"; +import { createContentAdvancedStore } from "./advanced-store"; +import { createContentAdvancedTables } from "./advanced-tables"; import { createContentEditorialService } from "./editorial-service"; import { createContentLocalizedPublicService } from "./localized-public-service"; import { createContentLocalizedService } from "./localized-service"; @@ -35,6 +39,32 @@ import { } from "./translation-table"; export interface ContentModel { + /** + * The read and write layer for this content type's advanced collections. + * + * On the model rather than built per request, because it holds only the + * resolved tables and the memoised foreign-key targets - every method takes + * the database handle it should run on. A disabled stub for a content type + * that declares no collection, so a caller can use it unconditionally. + * + * Public so the rebuild indexers can batch-load a page's collections: they are + * built from the model and have no service of their own. + */ + advanced: ContentAdvancedStore; + /** + * The generated collection tables, by field name. + * + * Export them from the plugin's database module alongside `table`, so Drizzle + * Kit finds them and the migration is generated: + * + * ```ts + * export const example_articles_categories = + * articleContent.advancedTables.junctions.categories; + * ``` + * + * Empty for a content type that declares no advanced collection. + */ + advancedTables: ContentAdvancedTables; /** Column name -> Drizzle column, for filters, ordering and custom queries. */ columns: Record, PgColumn>; definition: TDefinition; @@ -188,6 +218,18 @@ export const createContentModel = < ): ContentModel => { const table = createContentTable(definition, options); const columns = contentTableColumns(definition, table); + const advancedTables = createContentAdvancedTables(definition, { + ...options, + table, + }); + // One store per model rather than one per request: it holds only the resolved + // tables and the memoised foreign-key targets, and every method takes the + // database handle it should run on. + const advanced: ContentAdvancedStore = createContentAdvancedStore({ + definition, + table, + tables: advancedTables, + }); // `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 @@ -232,6 +274,8 @@ export const createContentModel = < }; return { + advanced, + advancedTables, columns, definition, // The plugin id arrives at call time rather than being captured here: a @@ -242,6 +286,7 @@ export const createContentModel = < editorialService: definition.editorial.enabled ? (c: Context, { pluginId }: { pluginId: string }) => createContentEditorialService({ + advanced, c, columns, definition, @@ -279,6 +324,7 @@ export const createContentModel = < }) : undefined, service: createContentService({ + advanced, c, columns, definition, @@ -297,6 +343,7 @@ export const createContentModel = < ? (c: Context) => localized && translationTable && translationColumns ? createContentLocalizedPublicService({ + advanced, c, columns, definition, @@ -304,11 +351,18 @@ export const createContentModel = < translationColumns, translationTable, }) - : createContentPublicService({ c, columns, definition, table }) + : createContentPublicService({ + advanced, + c, + columns, + definition, + table, + }) : undefined, schemas, service: (c: Context) => createContentService({ + advanced, c, columns, definition, diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index d29614e2b..58e792437 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -32,6 +32,11 @@ import { import { ContentEngineError } from "../errors"; import { resolveContentPublicLocale } from "../locale"; import { partitionContentFields } from "../localization"; +import { + contentColumnsToValues, + contentStorageColumns, + splitContentFieldPath, +} from "../paths"; import { publicOrderableColumns } from "../registry"; import { findContentLanguage, listContentLanguages } from "./language-resolver"; import { verifyContentPreviewToken } from "./preview-token"; @@ -264,15 +269,36 @@ export const buildContentPublicRoutes = < } const { localizedFields } = partitionContentFields(definition.fields); - const exposed = definition.publicApi.fields.filter( - name => localizedFields[name] !== undefined, + // Classified by the **owner** of each exposed name, so a leaf of a localized + // group is read from the translation exactly as the field it belongs to is. + // A top-level lookup would find neither `seo.title` nor `seo.description` + // and silently preview a draft without its SEO - while the *revision* path + // above, which projects a snapshot, would include them. + const exposedLocalized = definition.publicApi.fields.filter(name => { + const path = splitContentFieldPath(name); + + return localizedFields[path ? path[0] : name] !== undefined; + }); + const localizedColumns = contentStorageColumns( + Object.fromEntries( + [ + ...new Set( + exposedLocalized.map( + name => splitContentFieldPath(name)?.[0] ?? name, + ), + ), + ].map(name => [name, localizedFields[name]]), + ), ); const [row] = await c .get("db") .select( Object.fromEntries( - exposed.map(name => [name, translationColumns[name]]), + Object.keys(localizedColumns).map(name => [ + name, + translationColumns[name], + ]), ), ) .from(translationTable) @@ -284,7 +310,11 @@ export const buildContentPublicRoutes = < ) .limit(1); - return row ?? null; + // Folded back into the nested logical shape, so the projector - which reads + // a group by its own name - sees the same thing a live public read gives it. + return row + ? { ...row, ...contentColumnsToValues(localizedFields, row) } + : null; }; const list = buildRoute({ diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 9faec83ba..9ae1949a1 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -15,6 +15,7 @@ import type { ContentPublicOrderableFieldName, ContentPublicSelect, } from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentPageInfo } from "./service"; import { withPagination } from "../../api/lib/with-pagination"; @@ -23,7 +24,9 @@ import { CONTENT_PUBLIC_MAX_PAGE_SIZE, } from "../const"; import { ContentEngineError } from "../errors"; +import { isContentRelationCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; +import { groupPublicLeafPaths } from "../schemas"; import { publicationColumns, publishedCondition } from "./publication"; import { buildFilterCondition, @@ -128,16 +131,43 @@ export const createContentPublicProjector = < const exposed = publicApi.fields; const exposesId = exposed.includes("id"); + const flat = exposed.filter(name => splitContentFieldPath(name) === null); // A `user` field is never exposable, so this is only ever relations. - const exposedRelations = new Set( - exposed.filter(name => definition.fields[name]?.kind === "relation"), + const exposedToOne = new Set( + flat.filter( + name => + definition.fields[name]?.kind === "relation" && + !isContentRelationCollection(definition.fields[name]), + ), + ); + const exposedToMany = new Set( + flat.filter( + name => + definition.fields[name] !== undefined && + isContentRelationCollection(definition.fields[name]), + ), + ); + // Leaf-level privacy, resolved once: `seo` carries only the leaves the + // allowlist named, whatever else the group declares. + const containers = [...groupPublicLeafPaths(exposed)].map( + ([owner, leaves]) => ({ + leaves, + owner, + repeatable: definition.fields[owner]?.kind === "repeatable", + }), ); return row => { const projected: Record = {}; - for (const name of exposed) { - if (!exposedRelations.has(name)) { + for (const name of flat) { + if (exposedToMany.has(name)) { + const ids = row[name]; + projected[name] = Array.isArray(ids) ? ids : []; + continue; + } + + if (!exposedToOne.has(name)) { projected[name] = row[name]; continue; } @@ -146,12 +176,37 @@ export const createContentPublicProjector = < projected[name] = typeof id === "number" ? { id } : null; } + for (const { leaves, owner, repeatable } of containers) { + const value = row[owner]; + + if (repeatable) { + projected[owner] = Array.isArray(value) + ? (value as Record[]).map(child => ({ + id: child.id, + ...pick(child, leaves), + })) + : []; + continue; + } + + projected[owner] = + value === null || value === undefined + ? null + : pick(value as Record, leaves); + } + if (exposesId) projected.id = row.id; return projected as ContentPublicSelect; }; }; +const pick = ( + values: Record, + keys: readonly string[], +): Record => + Object.fromEntries(keys.map(key => [key, values[key] ?? null])); + /** * The columns a public read selects: the allowlist, plus `id` for the cursor. * @@ -165,10 +220,82 @@ export const contentPublicSelection = ( ): Record => ({ id: columns.id, ...Object.fromEntries( - definition.publicApi.fields.map(name => [name, columns[name]]), + definition.publicApi.fields + // A collection has no column, and a repeatable leaf is a column on a + // child table - both are batch-loaded after the page is fetched. A group + // leaf *is* a column, and `contentTableColumns` registers it under its + // canonical path, so `columns["seo.title"]` resolves here. + .filter(name => { + const path = splitContentFieldPath(name); + const owner = path ? path[0] : name; + const fieldValue = definition.fields[owner]; + + if (!fieldValue) return true; + if (fieldValue.kind === "repeatable") return false; + + return !isContentRelationCollection(fieldValue); + }) + .map(name => [name, columns[name]]), ), }); +/** + * The collection fields a public response actually needs. + * + * Empty unless the allowlist named one, which is what keeps a public list from + * joining every junction and child table a content type happens to have. + */ +export const contentPublicCollectionFields = ( + definition: AnyContentTypeDefinition, +): string[] => { + const named = new Set( + definition.publicApi.fields.map(name => { + const path = splitContentFieldPath(name); + + return path ? path[0] : name; + }), + ); + + return [...named].filter(name => { + const fieldValue = definition.fields[name]; + + return ( + fieldValue !== undefined && + (fieldValue.kind === "repeatable" || + isContentRelationCollection(fieldValue)) + ); + }); +}; + +/** + * Folds a row's flat leaf columns back into the nested shape. + * + * A public read selects `seo.title` as a column alias, so the raw row carries a + * key with a dot in it. Nesting happens here rather than in the projector so the + * projector stays the one place that decides *what* is public, and this stays + * the one place that decides what it *looks like*. + */ +export const nestContentPublicRow = ( + row: Record, +): Record => { + const nested: Record = {}; + + for (const [key, value] of Object.entries(row)) { + const path = splitContentFieldPath(key); + if (!path) { + nested[key] = value; + continue; + } + + const [owner, leaf] = path; + const container = (nested[owner] as Record) ?? {}; + container[leaf] = value; + nested[owner] = container; + } + + return nested; +}; + /** Public pages are smaller than admin ones, and the cap is lower too. */ export const clampContentPublicPageSize = ( value: string | undefined, @@ -202,11 +329,14 @@ export const clampContentPublicPageSize = ( export const createContentPublicService = < TDefinition extends AnyContentTypeDefinition, >({ + advanced, c, columns, definition, table, }: { + /** The collection store, or nothing for a content type that declares none. */ + advanced?: ContentAdvancedStore; c: Context; columns: Record; definition: TDefinition; @@ -237,6 +367,39 @@ export const createContentPublicService = < contentPublicSelection(definition, columns); const project = createContentPublicProjector(definition); + // Loaded only when the allowlist actually exposes one, so a public list joins + // no junction and no child table unless a public response is made of them. + const publicCollections = contentPublicCollectionFields(definition); + + /** + * Attaches the exposed collections to a page of rows. + * + * One batch per collection field for the whole page, keyed by the parent ids + * the page already fetched - never one query per row. + */ + const withCollections = async ( + rows: readonly Record[], + ): Promise[]> => { + const nested = rows.map(nestContentPublicRow); + if (publicCollections.length === 0 || nested.length === 0) return nested; + + const ids = nested + .map(row => row.id) + .filter((id): id is number => typeof id === "number"); + // Only the collections the allowlist actually exposes: querying a private + // junction table to discard its rows afterwards is work with no answer + // attached, and `publicCollections` is already exactly that list. + const loaded = await advanced?.loadMany( + ids, + c.get("db"), + publicCollections, + ); + + return nested.map(row => ({ + ...row, + ...(typeof row.id === "number" ? loaded?.get(row.id) : undefined), + })); + }; const readOne = async ( condition: SQL, @@ -248,7 +411,11 @@ export const createContentPublicService = < .where(and(publishedCondition(published), condition)) .limit(1); - return row ? project(row) : null; + if (!row) return null; + + const [projected] = await withCollections([row]); + + return project(projected); }; return { @@ -268,6 +435,7 @@ export const createContentPublicService = < contentTypeId, fields, filters, + membership: advanced?.membershipCondition, }), buildSearchCondition(searchColumns, query.search), ].filter((item): item is SQL => item !== undefined); @@ -311,7 +479,10 @@ export const createContentPublicService = < ), }); - return { edges: data.edges.map(project), pageInfo: data.pageInfo }; + return { + edges: (await withCollections(data.edges)).map(project), + pageInfo: data.pageInfo, + }; }, }; }; diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index 2659e96bf..1cd923a51 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -3,7 +3,11 @@ import type { PgColumn } from "drizzle-orm/pg-core"; import { and, eq, ilike, isNull, or } from "drizzle-orm"; -import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; +import type { + ContentFieldDescriptor, + ContentFieldMap, + ContentRelationFilter, +} from "../types"; import { CONTENT_FILTERABLE_FIELD_KINDS, @@ -12,6 +16,17 @@ import { isFilterableFieldKind, } from "../const"; import { ContentEngineError } from "../errors"; +import { + contentFieldPath, + contentInnerFields, + contentLeafColumnName, + contentStorageColumns, + contentValuesToColumns, + isContentCollectionField, + isContentRelationCollection, + readContentLeaf, + splitContentFieldPath, +} from "../paths"; /** * Escapes the `LIKE` wildcards so a search for "100%" matches the literal text @@ -61,6 +76,7 @@ export const buildFilterCondition = ({ contentTypeId, fields, filters, + membership, publication = false, }: { /** @@ -73,6 +89,18 @@ export const buildFilterCondition = ({ contentTypeId: string; fields: ContentFieldMap; filters: Record; + /** + * Compiles a to-many relation's `{ contains }` filter into an indexed + * `EXISTS` over its junction table. + * + * Injected rather than built here because it needs the generated tables, + * which this module deliberately knows nothing about - and because a caller + * that passes none simply has no to-many relation to filter on. + */ + membership?: ( + field: string, + filter: ContentRelationFilter, + ) => SQL | undefined; /** Whether `status` is a generated column and therefore filterable. */ publication?: boolean; }): SQL | undefined => { @@ -106,6 +134,24 @@ export const buildFilterCondition = ({ } const fieldValue = fields[name]; + + // A to-many relation has no column, so it is answered before the column + // lookup: `{ contains: 7 }` becomes an indexed `EXISTS` over the junction + // table rather than an equality against something that does not exist. + if (fieldValue && isContentRelationCollection(fieldValue)) { + const filter = raw as ContentRelationFilter; + if (typeof filter?.contains !== "number") { + throw new ContentEngineError( + `Filter "${name}" is a to-many relation, which takes \`{ contains: }\` rather than a value.`, + { contentTypeId }, + ); + } + + const condition = membership?.(name, filter); + if (condition) conditions.push(condition); + continue; + } + const column = columns[name]; if (!fieldValue || !column) { throw new ContentEngineError(`Unknown filter "${name}".`, { @@ -204,6 +250,105 @@ export const diffChangedFields = ( name => patch[name] !== undefined && !sameValue(current[name], patch[name]), ); +/** + * The **canonical paths** a patch actually changes, groups included. + * + * A scalar contributes its own name. A group contributes one path per leaf the + * patch names *and* moves, so `{ seo: { description } }` reports + * `["seo.description"]` and never `["seo"]` - which is what makes a changed-field + * list precise enough for a cache decision and a search decision to be made from + * it. `seo: null` reports every leaf that was not already null, because that is + * exactly what it blanks. + * + * `current` is a **column** row, as it comes back from Postgres; the patch is in + * logical shape. Comparing across the two is the whole job, and doing it here is + * what stops each service from flattening by hand. + */ +export const diffChangedPaths = ( + fields: ContentFieldMap, + current: Record, + patch: Record, +): string[] => { + const changed: string[] = []; + + for (const [name, fieldValue] of Object.entries(fields)) { + const next = patch[name]; + if (next === undefined) continue; + // A collection is not a column, so it is not this function's to diff - the + // advanced store answers for it, against the rows rather than the patch. + if (isContentCollectionField(fieldValue)) continue; + + if (fieldValue.kind !== "group") { + if (!sameValue(current[name], next)) changed.push(name); + continue; + } + + const inner = contentInnerFields(fieldValue); + + if (next === null) { + for (const leaf of Object.keys(inner)) { + const stored = readContentLeaf(current, name, leaf); + if (stored === null || stored === undefined) continue; + + changed.push(contentFieldPath(name, leaf)); + } + continue; + } + + if (typeof next !== "object") continue; + + for (const [leaf, leafValue] of Object.entries( + next as Record, + )) { + if (!(leaf in inner) || leafValue === undefined) continue; + + // `readContentLeaf` rather than a direct column read, so the same diff + // works against a database row (flattened columns) and against a logical + // one - a translation's `values` is the second, and the translation + // restore path diffs exactly that. + if (sameValue(readContentLeaf(current, name, leaf), leafValue)) continue; + + changed.push(contentFieldPath(name, leaf)); + } + } + + return changed; +}; + +/** + * The column patch for a set of changed paths. + * + * Only the leaves that moved are written, so an `UPDATE` touches `seoTitle` and + * leaves `seoDescription` alone - which is what a partial group update has to + * mean if two people editing different leaves are not to overwrite each other. + */ +export const changedPathsToColumns = ( + fields: ContentFieldMap, + patch: Record, + changedPaths: readonly string[], +): Record => { + const picked: Record = {}; + + for (const path of changedPaths) { + const parts = splitContentFieldPath(path); + if (!parts) { + if (fields[path] === undefined) continue; + + picked[path] = patch[path]; + continue; + } + + const [owner, leaf] = parts; + const container = patch[owner]; + picked[contentLeafColumnName(owner, leaf)] = + container === null + ? null + : ((container as Record | undefined)?.[leaf] ?? null); + } + + return toColumnValues(contentStorageColumns(fields), picked); +}; + /** `dateTime` values arrive as ISO strings and have to become `Date` columns. */ export const toColumnValues = ( fields: ContentFieldMap, @@ -218,3 +363,19 @@ export const toColumnValues = ( return [name, new Date(value)]; }), ); + +/** + * A whole logical value object, flattened into the columns an `INSERT` writes. + * + * {@link contentValuesToColumns} with the `dateTime` coercion applied + * afterwards, so a create writes `seo_title` from `{ seo: { title } }` and an + * ISO string still becomes a `Date`. + */ +export const toInsertColumns = ( + fields: ContentFieldMap, + values: Record, +): Record => + toColumnValues( + contentStorageColumns(fields), + contentValuesToColumns(fields, values), + ); diff --git a/packages/vitnode/src/content/server/references.ts b/packages/vitnode/src/content/server/references.ts index dd00ce03c..1ce53a731 100644 --- a/packages/vitnode/src/content/server/references.ts +++ b/packages/vitnode/src/content/server/references.ts @@ -10,6 +10,7 @@ import { alias, getTableConfig } from "drizzle-orm/pg-core"; import type { AnyContentTypeDefinition } from "../types"; import { ContentEngineError } from "../errors"; +import { isContentRelationCollection } from "../paths"; export interface ReferenceTarget { /** Aliased, so two relations pointing at the same table can both be joined. */ @@ -67,6 +68,10 @@ export const resolveReferenceTargets = ( for (const [name, fieldValue] of Object.entries(fields)) { if (fieldValue.kind !== "relation" && fieldValue.kind !== "user") continue; + // A to-many relation has no foreign key *here*: its two are on the + // generated junction table, and its picker resolves through + // `model.advancedTables` rather than through a column on this row. + if (isContentRelationCollection(fieldValue)) continue; const reference = byOwnerColumn.get(name); if (!reference) { diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 5b0f5fe56..22e52f255 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -1,12 +1,18 @@ import type { ContentRevisionSnapshot, + ContentSnapshotScalar, ContentSnapshotValue, ContentTranslationRevisionSnapshot, } from "../revisions"; -import type { AnyContentTypeDefinition } from "../types"; +import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; import { CONTENT_REVISION_SNAPSHOT_VERSION } from "../const"; import { partitionContentFields } from "../localization"; +import { + contentInnerFields, + isContentRelationCollection, + readContentLeaf, +} from "../paths"; const toIso = (value: unknown): string => { if (value instanceof Date) return value.toISOString(); @@ -29,18 +35,81 @@ const toIsoOrNull = (value: unknown): null | string => { * rather than being stringified, so a future column type cannot smuggle * `"[object Object]"` into a snapshot and have a restore write it back. */ -const toSnapshotValue = (value: unknown): ContentSnapshotValue => { +const toSnapshotValue = (value: unknown): ContentSnapshotScalar => { if (value === null || value === undefined) return null; if (value instanceof Date) return value.toISOString(); const type = typeof value; if (type === "boolean" || type === "number" || type === "string") { - return value as ContentSnapshotValue; + return value as ContentSnapshotScalar; } return null; }; +/** + * One field's value, in the **logical** shape. + * + * A group is snapshotted as the nested object it is, read out of the flattened + * columns the row actually carries - so a snapshot never mentions `seoTitle`, + * and a later rename of the column-naming rule cannot invalidate the history. A + * nullable group whose every leaf is empty is `null`, exactly as a read of it + * would be. + * + * A collection is snapshotted as **identity**: a to-many relation as its ids in + * stored order, a repeatable as its children each keyed by its own `id`. That is + * what makes a restore able to put the same rows back rather than copies of + * them, and what keeps one record's history from carrying another record's data. + */ +const toFieldSnapshot = ( + name: string, + fieldValue: ContentFieldMap[string], + values: Record, +): ContentSnapshotValue => { + if (fieldValue.kind === "group") { + const inner = contentInnerFields(fieldValue); + const leaves = Object.keys(inner); + const nested: Record = {}; + let allNull = true; + + for (const leaf of leaves) { + // Reads the flattened column on a database row and the nested value on a + // logical one - a base snapshot is taken from the first, a translation + // snapshot from the second, and both have to produce the same shape. + const snapshot = toSnapshotValue(readContentLeaf(values, name, leaf)); + if (snapshot !== null) allNull = false; + + nested[leaf] = snapshot; + } + + return fieldValue.nullable && allNull ? null : nested; + } + + if (isContentRelationCollection(fieldValue)) { + const value = values[name]; + + return Array.isArray(value) + ? value.map(id => Number(id)).filter(id => Number.isInteger(id)) + : []; + } + + if (fieldValue.kind === "repeatable") { + const value = values[name]; + if (!Array.isArray(value)) return []; + + const leaves = Object.keys(contentInnerFields(fieldValue)); + + return (value as Record[]).map(row => ({ + id: toSnapshotValue(row.id), + ...Object.fromEntries( + leaves.map(leaf => [leaf, toSnapshotValue(row[leaf])]), + ), + })); + } + + return toSnapshotValue(values[name]); +}; + /** * Builds the snapshot stored on a revision. * @@ -64,10 +133,19 @@ export const contentRevisionSnapshot = ( ): ContentRevisionSnapshot => { const values = row as Record; const fields: Record = {}; - const { sharedFields } = partitionContentFields(definition.fields); + const { collectionFields, sharedFields } = partitionContentFields( + definition.fields, + ); - for (const name of Object.keys(sharedFields)) { - fields[name] = toSnapshotValue(values[name]); + // Collections included, in declaration order alongside the shared fields: a + // record's categories and its FAQ entries are part of its editable state, so + // a history that left them out could not restore the state it claims to. + const snapshotFields: ContentFieldMap = { + ...sharedFields, + ...collectionFields, + }; + for (const [name, fieldValue] of Object.entries(snapshotFields)) { + fields[name] = toFieldSnapshot(name, fieldValue, values); } const snapshot: ContentRevisionSnapshot = { @@ -125,19 +203,77 @@ export const contentSnapshotRow = ( export const projectRevisionSnapshot = ( definition: AnyContentTypeDefinition, snapshot: ContentRevisionSnapshot, +): Record => { + const { collectionFields, sharedFields } = partitionContentFields( + definition.fields, + ); + + return projectSnapshotFields( + { ...sharedFields, ...collectionFields }, + snapshot.fields, + ); +}; + +/** + * The restorable half of a snapshot, for one set of currently declared fields. + * + * Shared by the base and translation projections rather than written twice: the + * schema-evolution rules are identical on both sides - a field or a **leaf** the + * content type has since dropped is ignored, one added since is absent - and two + * copies of that rule is the pair where a localized group ends up restoring + * something a shared one would not. + */ +const projectSnapshotFields = ( + restorable: ContentFieldMap, + stored: Record, ): Record => { const projected: Record = {}; - const { sharedFields } = partitionContentFields(definition.fields); - for (const name of Object.keys(sharedFields)) { - if (!(name in snapshot.fields)) continue; + for (const [name, fieldValue] of Object.entries(restorable)) { + if (!(name in stored)) continue; + + const value = stored[name]; - projected[name] = snapshot.fields[name]; + // A leaf the group has since dropped is ignored, for exactly the reason a + // dropped field is: the snapshot records the past, and the past is allowed + // to mention things that no longer exist. Left in, it would hit the strict + // object schema and turn every old revision into a permanent 422. + if (fieldValue.kind === "group" || fieldValue.kind === "repeatable") { + const leaves = Object.keys(contentInnerFields(fieldValue)); + + if (fieldValue.kind === "group") { + projected[name] = + value === null || typeof value !== "object" || Array.isArray(value) + ? null + : pickLeaves(value, leaves); + continue; + } + + projected[name] = Array.isArray(value) + ? (value as Record[]).map(row => ({ + // `id` is carried through so a restore can match the child rather + // than recreate it; `prepareRestore` drops the ones that are gone. + ...(typeof row.id === "number" ? { id: row.id } : {}), + ...pickLeaves(row, leaves), + })) + : []; + continue; + } + + projected[name] = value; } return projected; }; +const pickLeaves = ( + values: Record, + leaves: readonly string[], +): Record => + Object.fromEntries( + leaves.filter(leaf => leaf in values).map(leaf => [leaf, values[leaf]]), + ); + /** * Builds the snapshot stored on a *translation* revision. * @@ -159,8 +295,12 @@ export const contentTranslationRevisionSnapshot = ( const fields: Record = {}; const { localizedFields } = partitionContentFields(definition.fields); - for (const name of Object.keys(localizedFields)) { - fields[name] = toSnapshotValue(values[name]); + // The same field snapshotter the shared half uses, so a localized group is + // recorded in its canonical nested shape rather than run through the scalar + // coercion - which returns `null` for an object, and would silently record + // every localized group as absent. + for (const [name, fieldValue] of Object.entries(localizedFields)) { + fields[name] = toFieldSnapshot(name, fieldValue, values); } const snapshot: ContentTranslationRevisionSnapshot = { @@ -212,14 +352,7 @@ export const projectTranslationRevisionSnapshot = ( definition: AnyContentTypeDefinition, snapshot: ContentTranslationRevisionSnapshot, ): Record => { - const projected: Record = {}; const { localizedFields } = partitionContentFields(definition.fields); - for (const name of Object.keys(localizedFields)) { - if (!(name in snapshot.fields)) continue; - - projected[name] = snapshot.fields[name]; - } - - return projected; + return projectSnapshotFields(localizedFields, snapshot.fields); }; diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index 07f896313..192ccf3f5 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -98,11 +98,23 @@ interface Harness { * so each test drives the real Hono pipeline (validation, status codes, error * mapping) without a database. */ +/** + * The two typed collection maps every service carries, empty. + * + * None of these fixtures declares an advanced field, and the generated routes + * reach for collections only through the ordinary create/update payload - so + * the maps have to exist and have nothing in them. + */ +const noCollections = { relations: {}, repeatable: {} }; + const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { const emitted: Harness["emitted"] = []; const service = { create: vi.fn(), delete: vi.fn(), + advanced: vi.fn(), + advancedFields: vi.fn(), + findDetail: vi.fn(), findById: vi.fn(), findMany: vi.fn(), options: vi.fn(), @@ -110,7 +122,10 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { }; permissionGranted = allow; - vi.spyOn(articles, "service").mockReturnValue(service); + vi.spyOn(articles, "service").mockReturnValue({ + ...service, + ...noCollections, + }); const app = new OpenAPIHono(); @@ -145,6 +160,9 @@ const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => { const service = { create: vi.fn(), delete: vi.fn(), + advanced: vi.fn(), + advancedFields: vi.fn(), + findDetail: vi.fn(), findById: vi.fn(), findMany: vi.fn(), options: vi.fn(), @@ -154,7 +172,7 @@ const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => { }; permissionGranted = allow; - vi.spyOn(posts, "service").mockReturnValue(service); + vi.spyOn(posts, "service").mockReturnValue({ ...service, ...noCollections }); const app = new OpenAPIHono(); app.use("*", async (c, next) => { @@ -688,6 +706,11 @@ describe("generated content routes", () => { const service = { create: vi.fn(), delete: vi.fn(), + advanced: vi.fn(), + advancedFields: vi.fn(), + findDetail: vi.fn(), + relations: {}, + repeatable: {}, findById: vi.fn(), findMany: vi.fn(), options: vi.fn(), diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 13d260376..79b273af1 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -9,7 +9,7 @@ import type { ContentOrderableFieldName, ContentReferenceFieldName, } from "../types"; -import type { ContentModel } from "./model"; +import type { AnyContentModel, ContentModel } from "./model"; import { buildRoute } from "../../api/lib/route"; import { @@ -32,6 +32,7 @@ import { } from "../const"; import { partitionContentFields } from "../localization"; import { orderableColumns } from "../registry"; +import { contentSearchIndexesCollections } from "../search"; import { resolveContentActor } from "./actor"; import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; @@ -45,7 +46,7 @@ import { import { createContentPreviewToken } from "./preview-token"; import { publicationMethods } from "./publication"; import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; -import { syncContentSearch } from "./search-sync"; +import { contentSearchAdvancedValues, syncContentSearch } from "./search-sync"; import { buildContentTranslationRoutes } from "./translation-routes"; const zodLabels = z.record(z.string(), z.string().nullable()); @@ -75,6 +76,25 @@ const identifier = (c: Context): number => { * under `/admin/` so the global admin session middleware runs - both are * required for `assertStaffPermission` to have an admin to check. */ +/** + * The collections a search document is made of, or nothing. + * + * Read after the write has returned, and only when the search configuration + * actually names a collection leaf - `contentSearchAdvancedValues` owns that + * decision so every effects path answers it identically. + */ +const advancedForSearch = async ( + c: Context, + model: AnyContentModel, + row: object, +): Promise | undefined> => { + const id = (row as { id?: unknown }).id; + + return typeof id === "number" + ? await contentSearchAdvancedValues(c, model, id) + : undefined; +}; + export const buildContentRoutes = < TDefinition extends AnyContentTypeDefinition, P extends string, @@ -468,6 +488,7 @@ export const buildContentRoutes = < // computed from the row rather than assumed, the same way the Server // Action computes its cache tags. await syncContentSearch(c, definition, { + advanced: await advancedForSearch(c, model, row), operation: "create", pluginId, row, @@ -577,6 +598,7 @@ export const buildContentRoutes = < // A slug change is just a rewritten `url`: the search document is keyed by // item type and id, so there is no stale document to clean up. await syncContentSearch(c, definition, { + advanced: await advancedForSearch(c, model, result.row), changedFields: result.changedFields, operation: "update", pluginId, @@ -656,6 +678,7 @@ export const buildContentRoutes = < } await syncContentSearch(c, definition, { + advanced: await advancedForSearch(c, model, result.row), changed: result.changed, operation: action, pluginId, diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index 3f320148a..87c7c7a96 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -6,12 +6,52 @@ import { isContentTranslationPubliclyVisible, } from "../cache"; import { partitionContentFields } from "../localization"; +import { readContentPath, splitContentFieldPath } from "../paths"; import { contentSearchUrl } from "../search"; /** Collapses whitespace so a multi-line value cannot break a result heading. */ const normalize = (value: unknown): string => typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; +/** + * One configured search field, resolved to text. + * + * Three shapes, one rule - what a reader would see, in the order they would see + * it: + * + * - `"title"` is the value on the row; + * - `"seo.description"` is the leaf of a group, or nothing when the group is + * `null`; + * - `"faq.question"` is **every** child's leaf, joined in **position order**. + * Position order rather than insertion order, because position is what the + * page renders and an index that disagreed with the page would highlight the + * wrong entry. The join is a newline, so two entries never run together into a + * phrase neither of them contains. + * + * A relation is never here: `defineContentType` refuses one in every search + * slot, and indexing foreign keys as text would make a record match a number + * somebody typed into a search box. + */ +const readSearchValue = ( + values: Record, + name: string, +): string => { + const path = splitContentFieldPath(name); + if (!path) return normalize(values[name]); + + const [owner, leaf] = path; + const container = values[owner]; + + if (Array.isArray(container)) { + return (container as Record[]) + .map(child => normalize(child[leaf])) + .filter(value => value !== "") + .join("\n"); + } + + return normalize(readContentPath(values, name)); +}; + const toDate = (value: unknown): Date | undefined => { if (value instanceof Date) return value; if (typeof value !== "string") return undefined; @@ -81,7 +121,7 @@ export const contentSearchDocument = ( if (!isContentRowPublic(row)) return null; - const title = normalize(values[search.titleField]); + const title = normalize(readSearchValue(values, search.titleField)); if (title === "") return null; const url = contentSearchUrl( @@ -109,8 +149,11 @@ export const contentSearchDocument = ( return { // `SearchModel.index` strips HTML from `content` (but never from `title`, // which is why the title is normalized above). + // `readSearchValue` has already collapsed the whitespace *inside* each + // value; normalizing again here would flatten the newlines that separate + // one repeatable child's answer from the next's into single spaces. content: sources - .map(name => normalize(values[name])) + .map(name => readSearchValue(values, name)) .filter(value => value !== "") .join("\n\n"), createdAt, diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts index acee52aa9..8d4c906ef 100644 --- a/packages/vitnode/src/content/server/search-indexer.ts +++ b/packages/vitnode/src/content/server/search-indexer.ts @@ -14,11 +14,17 @@ import type { SearchIndexer, SearchIndexerPage, } from "../../api/models/search"; -import type { AnyContentTypeDefinition } from "../types"; +import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentModel } from "./model"; import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; +import { + contentColumnsToValues, + contentLeafColumnName, + splitContentFieldPath, +} from "../paths"; import { contentSearchIndexedFieldNames } from "../search"; import { listContentLanguages } from "./language-resolver"; import { @@ -40,6 +46,117 @@ const REQUIRED_COLUMNS = [ "publishedAt", ] as const; +/** + * Where each indexed value comes from. + * + * A rebuild cannot classify a search field by looking it up in the top-level + * field maps: `seo.description` is not a key in either of them, and + * `faq.question` is not a column at all. Resolving the paths once - here - is + * what lets both indexers select real columns, fold groups back into their + * logical shape, and batch the collections they actually need. + */ +interface ContentSearchSources { + /** Collection fields to batch-load for the page's parent ids. */ + collections: string[]; + /** Column names to select from the translation table. */ + localizedColumns: string[]; + /** Localized group descriptors, for folding translation columns back. */ + localizedGroups: ContentFieldMap; + /** Column names to select from the base table. */ + sharedColumns: string[]; + /** Shared group descriptors, for folding base columns back. */ + sharedGroups: ContentFieldMap; +} + +/** + * Splits the configured search fields by where their values actually live. + * + * Three destinations, and a path is the only way to tell two of them apart: + * + * - a **scalar** field is a column, on whichever table its `localized` flag says; + * - a **group leaf** is a column too, under its generated name, on the table the + * *group* moved to - localization is a property of the group, so one leaf can + * never be on the other side from its siblings; + * - a **collection leaf** is not a column anywhere. Its parent is batch-loaded. + */ +const resolveSearchSources = ( + definition: AnyContentTypeDefinition, +): ContentSearchSources => { + const { localizedFields, sharedFields } = partitionContentFields( + definition.fields, + ); + const sources: ContentSearchSources = { + collections: [], + localizedColumns: [], + localizedGroups: {}, + sharedColumns: [], + sharedGroups: {}, + }; + + for (const name of contentSearchIndexedFieldNames(definition)) { + const path = splitContentFieldPath(name); + + if (!path) { + if (localizedFields[name] !== undefined) { + sources.localizedColumns.push(name); + continue; + } + if (sharedFields[name] !== undefined) sources.sharedColumns.push(name); + continue; + } + + const [owner, leaf] = path; + const container = definition.fields[owner]; + if (!container) continue; + + if (container.kind !== "group") { + // A repeatable or a to-many relation: its rows are on their own table, so + // the parent is loaded whole rather than projected into this SELECT. + if (!sources.collections.includes(owner)) sources.collections.push(owner); + continue; + } + + const column = contentLeafColumnName(owner, leaf); + if (localizedFields[owner] !== undefined) { + sources.localizedColumns.push(column); + sources.localizedGroups[owner] = container; + continue; + } + + sources.sharedColumns.push(column); + sources.sharedGroups[owner] = container; + } + + return sources; +}; + +/** + * The shared collections for one rebuild page, in one batch per field. + * + * Keyed by parent id and deduplicated first, which is what makes the localized + * rebuild safe: a record with three published translations appears three times on + * a page, and loading its FAQ once rather than three times is the difference + * between a bounded query count and an N+1. + */ +const loadSearchCollections = async ({ + advanced, + c, + itemIds, + wanted, +}: { + advanced: ContentAdvancedStore; + c: Context; + itemIds: readonly number[]; + wanted: readonly string[]; +}): Promise>> => { + if (wanted.length === 0) return new Map(); + + const unique = [...new Set(itemIds.filter(id => Number.isInteger(id)))]; + if (unique.length === 0) return new Map(); + + return await advanced.loadMany(unique, c.get("db"), wanted); +}; + /** * A generated indexer, pinned to the modern page contract. * @@ -88,13 +205,13 @@ export const createContentSearchIndexer = < const published = publicationColumns(definition, columns); const primaryCursor = columns.id; + const sources = resolveSearchSources(definition); + const advanced = model.advanced; const selection: Record = Object.fromEntries( - [ - ...new Set([ - ...REQUIRED_COLUMNS, - ...contentSearchIndexedFieldNames(definition), - ]), - ].map(name => [name, columns[name]]), + [...new Set([...REQUIRED_COLUMNS, ...sources.sharedColumns])].map(name => [ + name, + columns[name], + ]), ); return { @@ -131,8 +248,29 @@ export const createContentSearchIndexer = < .limit(limit) .offset(offset); + // One batch for the whole page, and only the collections the search + // configuration names - never one query per document. + const collections = await loadSearchCollections({ + advanced, + c, + itemIds: rows.map(row => Number((row as Record).id)), + wanted: sources.collections, + }); + const documents = rows.flatMap(row => { - const document = contentSearchDocument(definition, row, { pluginId }); + const values = row as Record; + const document = contentSearchDocument( + definition, + { + ...values, + // Folded back into the nested logical shape the document builder + // reads: it is handed `seo.description`, and a flat `seoDescription` + // column would resolve to nothing. + ...contentColumnsToValues(sources.sharedGroups, values), + ...collections.get(Number(values.id)), + }, + { pluginId }, + ); return document ? [document] : []; }) satisfies SearchDocument[]; @@ -191,19 +329,12 @@ export const createContentLocalizedSearchIndexer = < translationColumns, ); - const { localizedFields, sharedFields } = partitionContentFields( - definition.fields, - ); - const indexed = contentSearchIndexedFieldNames(definition); + const sources = resolveSearchSources(definition); + const advanced = model.advanced; const sharedSelection = [ - ...new Set([ - ...REQUIRED_COLUMNS, - ...indexed.filter(name => sharedFields[name] !== undefined), - ]), + ...new Set([...REQUIRED_COLUMNS, ...sources.sharedColumns]), ]; - const localizedSelection = indexed.filter( - name => localizedFields[name] !== undefined, - ); + const localizedSelection = [...new Set(sources.localizedColumns)]; const visible = (): SQL | undefined => and(publishedCondition(base), publishedCondition(translation)); @@ -290,6 +421,16 @@ export const createContentLocalizedSearchIndexer = < languages.map(language => [language.id, language.locale]), ); + // Deduplicated across locales: a collection is shared, so three + // translations of one record reuse one loaded value rather than issuing + // three identical child queries. + const collections = await loadSearchCollections({ + advanced, + c, + itemIds: page.map(row => Number((row as Record).id)), + wanted: sources.collections, + }); + const documents = page.flatMap(row => { const values = row as Record; const locale = localeOf.get(values._languageId as number); @@ -298,17 +439,30 @@ export const createContentLocalizedSearchIndexer = < // code is one nothing would ever query. if (locale === undefined) return []; + const translationColumnValues = Object.fromEntries( + localizedSelection.map(name => [name, values[`t_${name}`]]), + ); + const document = contentTranslationSearchDocument( definition, { - base: values, + base: { + ...values, + ...contentColumnsToValues(sources.sharedGroups, values), + ...collections.get(Number(values.id)), + }, locale, translation: { publishedAt: values._publishedAt, status: values._status, updatedAt: values._updatedAt, - ...Object.fromEntries( - localizedSelection.map(name => [name, values[`t_${name}`]]), + ...translationColumnValues, + // Localized groups fold from the translation's own columns, so a + // localized `seo.description` reads from the language being built + // rather than from the base row. + ...contentColumnsToValues( + sources.localizedGroups, + translationColumnValues, ), }, }, diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index 5ccd529b9..2900c7dd5 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -79,7 +79,12 @@ const harness = ({ const service = { create: vi.fn(), delete: vi.fn(), + advanced: vi.fn(), + advancedFields: vi.fn(), + findDetail: vi.fn(), findById: vi.fn(), + relations: {}, + repeatable: {}, findMany: vi.fn(), options: vi.fn(), publish: vi.fn(), diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index c2cdb47c8..aacd9c746 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -7,9 +7,11 @@ import type { AnyContentTypeDefinition } from "../types"; import type { AnyContentModel } from "./model"; import { partitionContentFields } from "../localization"; +import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { contentSearchDocumentId, - contentSearchIndexedFieldNames, + contentSearchIndexedCollections, + contentSearchIndexedPaths, } from "../search"; import { listContentLanguages } from "./language-resolver"; import { @@ -23,6 +25,15 @@ export type ContentSearchOperation = "create" | "delete" | "publish" | "restore" | "unpublish" | "update"; export interface ContentSearchSyncInput { + /** + * The record's advanced collections, when the content type indexes one. + * + * Passed in rather than loaded here, because this function is deliberately + * model-free: it takes a definition and a row. The effects layer already holds + * the model and reads them once, after commit, only when + * `contentSearchIndexesCollections` says a document is made of them. + */ + advanced?: Record; /** * `publish` / `unpublish` only: `false` when the record was already in the * requested state, which means the index already agrees and there is nothing @@ -88,7 +99,7 @@ const decide = ( // the exposed slug is one of the indexed field names. if (!isPublic) return "skip"; - const indexed = new Set(contentSearchIndexedFieldNames(definition)); + const indexed = contentSearchIndexedPaths(definition); return (changedFields ?? []).some(name => indexed.has(name)) ? "upsert" @@ -146,9 +157,11 @@ export const syncContentSearch = async ( // text it was indexed with last time. const document = decided === "upsert" - ? contentSearchDocument(definition, input.row, { - pluginId: input.pluginId, - }) + ? contentSearchDocument( + definition, + { ...input.row, ...input.advanced }, + { pluginId: input.pluginId }, + ) : null; const action = decided === "upsert" && !document ? "delete" : decided; @@ -195,6 +208,18 @@ export const syncContentSearch = async ( }; export interface ContentLocalizedSearchSyncInput { + /** + * The record's shared advanced collections, when the content type indexes one. + * + * A localized document is built from three sources - the base row, the shared + * collections and one translation - and every path that builds one has to + * supply all three or the documents differ. Passed in rather than loaded here + * for the same reason the non-localized input takes it: this function is + * model-free by design, and the effects layer already holds the model. + * + * A collection is shared, so the same values feed **every** locale's document. + */ + advanced?: Record; /** * `publish` / `unpublish` only: `false` when nothing moved, which means the * index already agrees. @@ -238,6 +263,10 @@ const readTranslations = async ( if (!columns || !table) return []; const { localizedFields } = partitionContentFields(model.definition.fields); + // Flattened, so a localized group is selected as its leaf columns - and then + // folded back below, so the document builder sees the same nested shape a + // public read would. + const storage = contentStorageColumns(localizedFields); const rows = await c .get("db") @@ -247,7 +276,7 @@ const readTranslations = async ( status: columns.status, updatedAt: columns.updatedAt, ...Object.fromEntries( - Object.keys(localizedFields).map(name => [name, columns[name]]), + Object.keys(storage).map(name => [name, columns[name]]), ), }) .from(table) @@ -255,7 +284,7 @@ const readTranslations = async ( return rows.map(row => ({ languageId: row.languageId as number, - values: row, + values: { ...row, ...contentColumnsToValues(localizedFields, row) }, })); }; @@ -333,7 +362,7 @@ export const syncContentLocalizedSearch = async ( // A write that moved no indexed field changes no document. `status` is not a // declared field, so a publish never reaches this. if (input.operation === "update" || input.operation === "restore") { - const indexed = new Set(contentSearchIndexedFieldNames(definition)); + const indexed = contentSearchIndexedPaths(definition); const moved = (input.changedFields ?? []).some(name => indexed.has(name)); if (!moved) return []; } @@ -358,7 +387,15 @@ export const syncContentLocalizedSearch = async ( const document = contentTranslationSearchDocument( definition, - { base: input.row, locale, translation: translation.values }, + { + // The shared half of the document: the row *and* its shared + // collections. Omitting the second would rebuild every locale's + // document without its repeatable text - so editing one localized leaf + // would silently drop the FAQ out of the index. + base: { ...input.row, ...input.advanced }, + locale, + translation: translation.values, + }, { pluginId: input.pluginId }, ); @@ -386,6 +423,28 @@ export const syncContentLocalizedSearch = async ( return outcomes; }; +/** + * The shared collections a search document needs, or `undefined`. + * + * The one place the "does this document depend on collection values" question is + * asked, so the live path, the translation path and the base editorial path + * cannot answer it differently - which is exactly how two of them ended up + * writing documents the third would not reproduce. + * + * Loads **only** the indexed collections. A content type that indexes none - + * every Stage 1-5 one - costs a boolean check and no query. + */ +export const contentSearchAdvancedValues = async ( + c: Context, + model: AnyContentModel, + itemId: number, +): Promise | undefined> => { + const wanted = contentSearchIndexedCollections(model.definition); + if (wanted.length === 0 || itemId === 0) return undefined; + + return await model.service(c).advancedFields(itemId, wanted); +}; + /** * Runs one index write and turns a failure into an outcome rather than a throw. * diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index d802f0d37..3e5dc85c7 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -11,14 +11,23 @@ import { and, eq, ne, sql } from "drizzle-orm"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, + ContentAdvancedValues, + ContentChangedPath, ContentCreateInput, + ContentDetail, ContentFilterInput, + ContentInnerFieldsOf, ContentOrderableFieldName, ContentReferenceFieldName, + ContentRelationCollectionName, + ContentRepeatableFieldName, + ContentRepeatableInputRow, + ContentRepeatableRow, ContentSelect, - ContentSharedFieldName, ContentUpdateInput, + ContentValuesOf, } from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; import { withPagination } from "../../api/lib/with-pagination"; import { @@ -26,16 +35,24 @@ import { CONTENT_EDITORIAL_FIELDS, CONTENT_OPTIONS_LIMIT, CONTENT_PUBLICATION_FIELDS, + CONTENT_SYSTEM_FIELDS, } from "../const"; import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; +import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { orderableColumns } from "../registry"; +import { + buildContentRelationOperations, + buildContentRepeatableOperations, + contentCollectionKinds, +} from "./collection-api"; import { buildFilterCondition, buildOrderColumn, buildSearchCondition, - diffChangedFields, - toColumnValues, + changedPathsToColumns, + diffChangedPaths, + toInsertColumns, } from "./query"; import { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; import { createSlugNormalizer } from "./slugs"; @@ -84,10 +101,143 @@ export interface ContentServiceOptions { } export interface ContentUpdateResult { - changedFields: ContentSharedFieldName[]; + /** + * Canonical paths, not field names: a group reports the **leaves** that moved + * (`seo.description`), a scalar reports itself, and a collection reports + * itself whole. One vocabulary, so an event payload, a cache decision and a + * search decision are all made from the same strings. + */ + changedFields: ContentChangedPath[]; row: ContentSelect; } +/** + * The typed collection API of one content type, on the **plain** service. + * + * Every mutating method is a read-modify-write that runs inside one transaction + * with the source row locked first, so two concurrent `add` calls merge instead + * of one overwriting the other. It goes through the same `update` an ordinary + * field edit does, which is what gives it the no-op rule and the `updatedAt` + * bump for free. + * + * What it does **not** do is write a revision or emit an event - the plain + * service never has, for a field edit either. Those belong to + * `model.editorialService(c)?.relations` and + * `model.editorialService(c)?.repeatable`, which additionally require an + * `expectedVersion` and answer a stale one with a structured conflict. + */ +export interface ContentRelationMethods { + /** Adds one target. A target already present is a no-op. */ + add: ( + itemId: number, + relatedItemId: number, + options?: ContentWriteOptions, + ) => Promise | null>; + /** The current targets, in stored order. */ + get: (itemId: number, options?: ContentServiceOptions) => Promise; + /** Removes one target. A target that is not there is a no-op. */ + remove: ( + itemId: number, + relatedItemId: number, + options?: ContentWriteOptions, + ) => Promise | null>; + /** + * Rearranges the existing targets. + * + * Refuses a list that is not a permutation of what is stored - a reorder that + * silently added or dropped a target would be a `set` wearing a different + * name, and the caller would never find out which it got. + */ + reorder: ( + itemId: number, + relatedItemIds: readonly number[], + options?: ContentWriteOptions, + ) => Promise | null>; + /** Replaces the whole set. */ + set: ( + itemId: number, + relatedItemIds: readonly number[], + options?: ContentWriteOptions, + ) => Promise | null>; +} + +export interface ContentRepeatableMethods { + /** + * Appends one child. + * + * Typed from the repeatable's own leaves, so `{ question, answer }` compiles + * and `{ unknownField }` does not - the definition already carries the child + * shape, and a `Record` here would throw it away. + */ + create: ( + itemId: number, + values: ContentValuesOf>, + options?: ContentWriteOptions, + ) => Promise | null>; + /** Removes one child by its stable identifier. */ + delete: ( + itemId: number, + childId: number, + options?: ContentWriteOptions, + ) => Promise | null>; + /** The current children, in position order, each with its identifier. */ + list: ( + itemId: number, + options?: ContentServiceOptions, + ) => Promise< + ContentRepeatableRow>[] + >; + /** Rearranges the existing children. Refuses a non-permutation. */ + reorder: ( + itemId: number, + childIds: readonly number[], + options?: ContentWriteOptions, + ) => Promise | null>; + /** + * Replaces the whole list in one write - the operation an AdminCP form save + * actually needs, so saving a five-row FAQ is one request rather than five. + * + * A child with an `id` is updated in place and keeps it; one without is + * created. Anything absent is removed. + */ + set: ( + itemId: number, + rows: readonly ContentRepeatableInputRow< + ContentInnerFieldsOf + >[], + options?: ContentWriteOptions, + ) => Promise | null>; + /** + * Updates one child by its stable identifier. + * + * Partial, and over the repeatable's own leaves: naming a leaf the repeatable + * does not declare is a compile error rather than a value silently dropped by + * the strict schema at runtime. + */ + update: ( + itemId: number, + childId: number, + values: Partial>>, + options?: ContentWriteOptions, + ) => Promise | null>; +} + +/** + * Options for a collection mutation on the **plain** service. + * + * Deliberately identical to `ContentServiceOptions`: there is no + * `expectedVersion` here, because this service has no version column to guard on + * and would have had to ignore one. Concurrent writers are serialised by the + * source row's `SELECT ... FOR UPDATE` instead, so two `add` calls merge rather + * than one of them being rejected. + * + * Optimistic locking, revisions and events are the editorial service's - + * `model.editorialService(c)?.relations`, which takes a required + * `expectedVersion` and an `actor`. The two are separate objects rather than one + * that behaves differently depending on where it came from. + */ +export type ContentWriteOptions = ContentServiceOptions; + export interface ContentPublicationResult { /** * `false` when the row was already in that state: no write happened, no event @@ -132,6 +282,25 @@ export type ContentService = ContentServiceBase & : Partial, never>>); export interface ContentServiceBase { + /** The advanced collections of one record. Two queries per collection field. */ + advanced: ( + id: number, + options?: ContentServiceOptions, + ) => Promise>; + /** + * A named subset of the advanced collections, for a caller with an allowlist. + * + * The search synchronizer and the public projection each need only the + * collections their configuration actually mentions, and querying a private + * junction table to discard the rows afterwards is work with no answer + * attached. Untyped in its keys on purpose: the allowlist is derived from + * configuration at runtime, and `advanced` is the typed whole-record read. + */ + advancedFields: ( + id: number, + fields: readonly string[], + options?: ContentServiceOptions, + ) => Promise>; /** Throws a `ZodError` if `values` does not satisfy `schemas.create`. */ create: ( values: ContentCreateInput, @@ -145,6 +314,16 @@ export interface ContentServiceBase { id: number, options?: ContentServiceOptions, ) => Promise | null>; + /** + * One record with its advanced collections attached. + * + * The read an edit form makes, and the only one that loads collections: a + * list must not, or it would issue a query per row. + */ + findDetail: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; findMany: (args?: ContentFindManyArgs) => Promise<{ edges: ContentListRow[]; pageInfo: ContentPageInfo; @@ -154,6 +333,29 @@ export interface ContentServiceBase { field: ContentReferenceFieldName, search?: string, ) => Promise<{ label: string; value: number }[]>; + /** + * Typed to-many relation operations, keyed by the content type's **actual** + * relation collection names. + * + * A mapped type rather than a `Record`: with the latter, + * `service.relations.thisFieldDoesNotExist` compiled and failed at runtime. + * Empty for a content type that declares none, so `service.relations` always + * exists and every key on it is one the definition has. + */ + relations: Record< + ContentRelationCollectionName, + ContentRelationMethods + >; + /** + * Typed repeatable operations, keyed by the content type's actual repeatable + * field names - each one carrying its own child shape. + */ + repeatable: { + [K in ContentRepeatableFieldName]: ContentRepeatableMethods< + TDefinition, + K + >; + }; /** Throws a `ZodError` if `values` does not satisfy `schemas.update`. */ update: ( id: number, @@ -173,12 +375,21 @@ export interface ContentServiceBase { export const createContentService = < TDefinition extends AnyContentTypeDefinition, >({ + advanced, c, columns, definition, schemas, table, }: { + /** + * The collection store, or nothing for a content type that declares none. + * + * Optional so every existing caller - and every test that builds a service by + * hand - keeps working unchanged; a service without one simply has no + * collections to read or write. + */ + advanced?: ContentAdvancedStore; c: Context; columns: Record; definition: TDefinition; @@ -188,7 +399,15 @@ export const createContentService = < // Shared only, everywhere in this file: this service reads and writes the base // table, and a localized field is not a column on it. The translation model // owns the other half. - const fields = partitionContentFields(definition.fields).sharedFields; + const { collectionFields, sharedFields } = partitionContentFields( + definition.fields, + ); + const fields = sharedFields; + // Groups flattened, so every `SELECT`, `INSERT` and `UPDATE` below addresses + // real columns and nothing has to know what a group is. + const storageColumns = contentStorageColumns(fields); + const filterableFields = { ...fields, ...collectionFields }; + const store = advanced; const contentTypeId = definition.id; // `buildSystemColumns` always makes `id` a `serial`, which is what // `withPagination` needs to type its cursor. @@ -196,21 +415,15 @@ export const createContentService = < ColumnBaseConfig<"number", string> >; const orderable = orderableColumns(definition); - // `Object.keys` erases the key union that `ContentSharedFieldName` recovers. - // The object is the shared half of 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 ContentSharedFieldName[]; const publication = definition.publication.enabled; - const ownColumnNames = [ - "id", - "createdAt", - "updatedAt", + const generatedColumnNames = [ + ...CONTENT_SYSTEM_FIELDS, ...(publication ? CONTENT_PUBLICATION_FIELDS : []), ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), - ...fieldNames, + ]; + const ownColumnNames = [ + ...generatedColumnNames, + ...Object.keys(storageColumns), ]; const references = resolveReferenceTargets(definition, table, columns); const searchColumns = definition.admin.list.searchableFields.map( @@ -221,14 +434,52 @@ export const createContentService = < fields, ); + /** + * The keyed collection maps, assembled after the service object exists. + * + * Built as loose records and re-typed once at the boundary: the public type is + * keyed by the content type's actual collection names, which is what makes + * `service.relations.typo` a compile error - but a loop cannot prove to + * TypeScript that it filled exactly those keys. + */ + const mutableRelations: Record< + string, + ContentRelationMethods + > = {}; + const mutableRepeatables: Record< + string, + ContentRepeatableMethods + > = {}; + const db = (options?: ContentServiceOptions): ContentDatabase => options?.tx ?? c.get("db"); const ownSelection = (): Record => Object.fromEntries(ownColumnNames.map(name => [name, columns[name]])); + /** + * A database row, in the logical shape callers see. + * + * The generated columns pass straight through; the declared fields go through + * `contentColumnsToValues`, which folds `seoTitle` and `seoDescription` back + * into `seo: { title, description }` - or into `seo: null` when the group is + * nullable and every leaf is empty. For a content type with no group this is + * a copy, which is why a Stage 1-5 row comes back byte-identical. + */ + const projectRow = ( + row: Record, + ): Record => { + const projected: Record = {}; + + for (const name of generatedColumnNames) { + if (name in row) projected[name] = row[name]; + } + + return { ...projected, ...contentColumnsToValues(fields, row) }; + }; + const toRow = (row: Record): ContentSelect => - row as ContentSelect; + projectRow(row) as ContentSelect; const splitLabels = ( row: Record, @@ -244,7 +495,7 @@ export const createContentService = < values[key] = value; } - return { ...values, labels } as ContentListRow; + return { ...projectRow(values), labels } as ContentListRow; }; const readOne = async ( @@ -329,20 +580,98 @@ export const createContentService = < ), }; - const service: ContentServiceBase = { - 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; + /** + * Runs `body` in the caller's transaction, or in one opened for it. + * + * A create or update that also writes collections has to be atomic: a base row + * that committed with half its categories is worse than one that failed. A + * content type with no collections keeps the single-statement path it always + * had, because opening a transaction to run one `INSERT` is pure cost. + */ + const transact = async ( + options: ContentServiceOptions | undefined, + body: (tx: ContentDatabase) => Promise, + ): Promise => { + if (options?.tx) return await body(options.tx); + if (!store?.enabled) return await body(c.get("db")); - const [row] = await db(options) - .insert(table) - .values(toColumnValues(fields, withCreateSlugs(parsed))) - .returning(ownSelection()); + return await c.get("db").transaction(async tx => await body(tx)); + }; - return toRow(row); - }, + /** + * Always a transaction, even for a content type with no collections. + * + * `transact` skips one when there is nothing to be atomic about; a collection + * mutation is a read-modify-write and always has something, so it needs the + * stronger guarantee unconditionally. + */ + const inTransaction = async ( + options: ContentServiceOptions | undefined, + body: (tx: ContentDatabase) => Promise, + ): Promise => + options?.tx + ? await body(options.tx) + : await c.get("db").transaction(async tx => await body(tx)); + + /** + * Serialises concurrent collection writers on a **non-editorial** content + * type. + * + * There is no `version` column to guard on here, so the row lock does the job + * the guarded UPDATE does on an editorial content type: two `set` calls for + * the same record run one after the other, and the second sees the first's + * result rather than the state they both read. Records are independent because + * the lock is per row. + */ + const lockRow = async ( + tx: ContentDatabase, + id: number, + { force = false }: { force?: boolean } = {}, + ): Promise => { + // A content type with no collections has no read-modify-write to protect, so + // an ordinary `update` skips the extra statement. `force` is the collection + // path, which always needs it. + if (!force && !store?.enabled) return true; + + const [row] = await tx + .select({ id: primaryCursor }) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1) + .for("update"); + + return row !== undefined; + }; + + const service: ContentServiceBase = { + advanced: async (id, options) => + ((await store?.load(id, db(options))) ?? + {}) as ContentAdvancedValues, + + advancedFields: async (id, wanted, options) => + (await store?.load(id, db(options), wanted)) ?? {}, + + create: async (values, options) => + await transact(options, async tx => { + // 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 tx + .insert(table) + .values(toInsertColumns(fields, withCreateSlugs(parsed))) + .returning(ownSelection()); + + // In the same transaction as the row it belongs to: a create that + // committed its categories and rolled back its article would leave + // junction rows pointing at nothing. + if (store?.enabled && typeof row.id === "number") { + await store.write(tx, row.id, parsed); + } + + return toRow(row); + }), delete: async (id, options) => { const [row] = await db(options) @@ -359,16 +688,28 @@ export const createContentService = < return row ? toRow(row) : null; }, + findDetail: async (id, options) => { + const database = db(options); + const row = await readOne(id, database); + if (!row) return null; + + return { + ...toRow(row), + ...(await store?.load(id, database)), + } as ContentDetail; + }, + findMany: async ({ filters = {}, orderBy, query = {}, where } = {}) => { const conditions = [ where, buildFilterCondition({ columns, contentTypeId, - fields, + fields: filterableFields, // Typed per field for callers; the allowlist check inside stays as // defence in depth for anything that arrives from a query string. filters: filters, + membership: store?.membershipCondition, publication, }), buildSearchCondition(searchColumns, query.search), @@ -456,40 +797,160 @@ export const createContentService = < }); }, - update: async (id, values, options) => { - // Parsed before the row is even read, so an invalid payload never costs a - // query - and never reaches Drizzle. - // Normalised before the diff, so re-sending the stored slug in a - // different case counts as no change rather than as a pointless write. - const patch = withUpdateSlugs(schemas.update.parse(values)); + relations: mutableRelations, - const database = db(options); - const current = await readOne(id, database); + repeatable: mutableRepeatables as ContentService["repeatable"], + + update: async (id, values, options) => + await transact(options, async tx => { + // Parsed before the row is even read, so an invalid payload never costs + // a query - and never reaches Drizzle. Normalised before the diff, so + // re-sending the stored slug in a different case counts as no change + // rather than as a pointless write. + const patch = withUpdateSlugs(schemas.update.parse(values)); + + if (!(await lockRow(tx, id))) return null; + + return await applyPatch(tx, id, patch); + }), + }; + + /** + * Applies an already-parsed patch to a **locked** row. + * + * Split out of `update` so the collection helpers can lock, read the current + * collection and apply the result they compute from it without leaving the + * transaction - the read and the write have to be one atomic step, or two + * concurrent `add` calls each write a list that never saw the other's. + */ + const applyPatch = async ( + tx: ContentDatabase, + id: number, + patch: Record, + ): Promise | null> => { + { + const current = await readOne(id, tx); if (!current) return null; - const changedFields = diffChangedFields(fieldNames, current, patch); + const changedPaths = diffChangedPaths(fields, current, patch); + // Read before anything is written, so "nothing moved" is decided once + // and the collection write below is not a second, separate decision. + const changedCollections = (await store?.diff(tx, id, patch)) ?? []; + const changedFields = [...changedPaths, ...changedCollections]; // Nothing actually moved - skip the write so `updatedAt` and the - // `content.*.updated` event both stay honest. + // `content.*.updated` event both stay honest. A reorder to the order + // that is already stored lands here. if (changedFields.length === 0) { - return { changedFields, row: toRow(current) }; + return { + changedFields: changedFields as ContentChangedPath[], + row: toRow(current), + }; } - const [row] = await database + if (changedCollections.length > 0) await store?.write(tx, id, patch); + + // `updatedAt` has to move even when only a collection changed: it is + // what an editor sees as "last edited", and a category swap is an edit. + const [row] = await tx .update(table) .set( - toColumnValues( - fields, - Object.fromEntries(changedFields.map(key => [key, patch[key]])), - ), + changedPaths.length > 0 + ? changedPathsToColumns(fields, patch, changedPaths) + : { updatedAt: new Date() }, ) .where(eq(primaryCursor, id)) .returning(ownSelection()); - return { changedFields, row: toRow(row) }; + return { + changedFields: changedFields as ContentChangedPath[], + row: toRow(row), + }; + } + }; + + /** + * Locks the source record, reads one collection and applies what `compute` + * makes of it - all in one transaction. + * + * The order is the fix: `SELECT ... FOR UPDATE` first, *then* the read. Two + * concurrent `add` calls therefore serialise on the row rather than both + * reading the same empty list and each writing a single-element one - which is + * how one of the two additions used to disappear with nothing to show it had. + * + * The lock is the database's, not the process's: a second API instance is + * serialised by exactly the same primitive. + */ + const runCollection = async ( + itemId: number, + field: string, + compute: (current: unknown[]) => unknown[], + options: ContentServiceOptions | undefined, + ): Promise | null> => + await inTransaction(options, async tx => { + if (!(await lockRow(tx, itemId, { force: true }))) return null; + + const current = await store?.load(itemId, tx, [field]); + const value = current?.[field]; + const next = compute(Array.isArray(value) ? value : []); + + return await applyPatch( + tx, + itemId, + schemas.update.parse({ [field]: next }), + ); + }); + + const collectionApi = { + read: async (itemId: number, field: string, options: unknown) => { + const loaded = await store?.load( + itemId, + db(options as ContentServiceOptions | undefined), + [field], + ); + const value = loaded?.[field]; + + return Array.isArray(value) ? value : []; }, + run: runCollection, + write: async ( + itemId: number, + field: string, + next: readonly unknown[], + options: ContentServiceOptions | undefined, + ) => + // `set` replaces the whole collection, so it never reads and cannot lose a + // concurrent write. It still goes through `update`, which locks. + await service.update( + itemId, + { [field]: [...next] } as ContentUpdateInput, + options, + ), }; + // The operations themselves live in `collection-api.ts`: they are the same + // arithmetic on both services, and the only thing that differs is the locking + // the runner above supplies. + const { relations, repeatables } = contentCollectionKinds( + definition, + store?.fields ?? [], + ); + + for (const field of relations) { + mutableRelations[field] = buildContentRelationOperations({ + api: collectionApi, + contentTypeId, + field, + }); + } + for (const field of repeatables) { + mutableRepeatables[field] = buildContentRepeatableOperations({ + api: collectionApi, + contentTypeId, + field, + }) as ContentRepeatableMethods; + } + // `ContentService` resolves its publication half from // `TDefinition["publication"]["enabled"]`, which is still a type parameter // here - so TypeScript cannot check the object against a branch it has not diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index e28672a3a..ac2c495d3 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -28,6 +28,7 @@ import { core_users } from "../../database/users"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; +import { contentStorageColumns } from "../paths"; import { buildContentColumn, buildEditorialColumns, @@ -96,7 +97,9 @@ const resolveReference = ( const thunk = references[name]; if (!thunk) { throw new ContentEngineError( - `Relation field "${name}" has no entry in \`references\`. Add \`${name}: () => .id\`.`, + fieldValue.self + ? `Self-relation "${name}" should not have an entry in \`references\` - the engine resolves it from the table it is building. This is an internal error.` + : `Relation field "${name}" has no entry in \`references\`. Add \`${name}: () => .id\`.`, { contentTypeId }, ); } @@ -144,8 +147,12 @@ export const createContentTable = < const { id: contentTypeId, indexes, tableName } = definition; // Shared only: a localized field's column lives on the generated translation - // table, and `createContentTranslationTable` puts it there. - const fields = partitionContentFields(definition.fields).sharedFields; + // table, and `createContentTranslationTable` puts it there. Then flattened, so + // a group contributes its leaf columns and the two collection kinds - which + // have tables of their own - contribute nothing. + const fields = contentStorageColumns( + partitionContentFields(definition.fields).sharedFields, + ); const referenceThunks = references as Record; const columns: Record = { @@ -163,8 +170,12 @@ export const createContentTable = < }); } + // Checked against the *declared* fields rather than the flattened columns: a + // to-many relation needs a reference thunk for its junction table's foreign + // key, and it has no column here to be found by. + const declaredFields = definition.fields; const unknownReference = Object.keys(referenceThunks).find( - name => fields[name]?.kind !== "relation", + name => declaredFields[name]?.kind !== "relation", ); if (unknownReference !== undefined) { throw new ContentEngineError( @@ -211,7 +222,16 @@ export const assertContentReferences = (table: PgTable): void => { } }; -/** Column name -> Drizzle column, for allowlisted filters and ordering. */ +/** + * Column name -> Drizzle column, for allowlisted filters and ordering. + * + * A group's leaves appear twice, under the generated column name *and* under the + * canonical path: `columns["seoTitle"]` and `columns["seo.title"]` are the same + * `PgColumn`. That alias is what lets a filter, an `orderBy`, a search + * projection and an index all be configured in one vocabulary - paths - without + * every one of them learning the column-naming rule. There is still exactly one + * mapping, and it is the one `contentLeafColumnName` defines. + */ export const contentTableColumns = < TDefinition extends AnyContentTypeDefinition, >( @@ -219,17 +239,22 @@ export const contentTableColumns = < table: ContentTableFor, ): Record, PgColumn> => { const source = table as unknown as Record; + const { sharedFields } = partitionContentFields(definition.fields); const names = [ "id", "createdAt", "updatedAt", ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), - ...Object.keys(partitionContentFields(definition.fields).sharedFields), + ...Object.keys(contentStorageColumns(sharedFields)), ]; - return Object.fromEntries(names.map(name => [name, source[name]])) as Record< - ContentColumnName, - PgColumn - >; + return { + ...Object.fromEntries(names.map(name => [name, source[name]])), + ...Object.fromEntries( + definition.advanced.leaves + .filter(leaf => !leaf.localized) + .map(leaf => [leaf.path, source[leaf.columnName]]), + ), + } as Record, PgColumn>; }; diff --git a/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts new file mode 100644 index 000000000..b665761f9 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts @@ -0,0 +1,562 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testAdvancedLocalizedContentType } from "@/tests/content-fixtures"; + +import type { ContentTranslationRevisionSnapshot } from "../revisions"; +import type { ContentTranslationModel } from "./translation-model"; + +import { ContentRevisionNotRestorable } from "../errors"; +import { + contentTranslationRevisionSnapshot, + projectTranslationRevisionSnapshot, +} from "./revision-snapshot"; +import { createContentTranslationEditorialService } from "./translation-editorial-service"; + +/** + * Localized groups in translation revisions. + * + * Stage 6 taught the *base* snapshot about groups and left the translation one + * running every localized field through the scalar coercion - which returns + * `null` for an object. So a translation revision recorded `seo: null` for every + * record that had SEO, and restoring one blanked it. Every test here fails on + * that implementation. + */ + +const PLUGIN_ID = "@vitnode/example"; +const ACTOR = { type: "staff" as const, userId: 1 }; +const definition = testAdvancedLocalizedContentType; + +const captured: { + changedFields: readonly string[]; + operation: string; + snapshot: ContentTranslationRevisionSnapshot; + version: number; +}[] = []; + +let storedRevision: ContentTranslationRevisionSnapshot | null = null; +let nextRevisionId = 100; + +vi.mock("./revisions-model", () => ({ + CONTENT_REVISIONS_DEFAULT_PAGE_SIZE: 25, + CONTENT_REVISIONS_MAX_PAGE_SIZE: 100, + createContentRevisionsModel: ({ + languageId, + }: { + languageId?: null | number; + }) => ({ + capture: ( + _tx: unknown, + input: { + changedFields: readonly string[]; + operation: string; + snapshot: ContentTranslationRevisionSnapshot; + version: number; + }, + ) => { + captured.push(input); + nextRevisionId += 1; + + return nextRevisionId; + }, + findById: (_itemId: number, revisionId: number) => + storedRevision !== null && languageId === 2 + ? { + actorName: null, + actorType: "staff" as const, + actorUserId: 1, + changedFields: [], + createdAt: new Date(), + id: revisionId, + operation: "update" as const, + restoredFromRevisionId: null, + snapshot: storedRevision, + version: 1, + } + : null, + latest: () => null, + list: () => ({ + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }), + }), +})); + +/** One translation row, in the **logical** shape the model returns. */ +const row = (values: Record, overrides = {}) => + ({ + createdAt: new Date("2026-01-01T00:00:00Z"), + itemId: 7, + languageId: 2, + locale: "pl", + publishedAt: null, + status: "draft", + updatedAt: new Date("2026-01-01T00:00:00Z"), + values: { seo: null, slug: "witaj", title: "Witaj", ...values }, + version: 1, + ...overrides, + }) as never; + +const translations = () => { + const model = { + create: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + findByLanguageId: vi.fn(), + findByLocale: vi.fn(), + findManyForItem: vi.fn(), + publish: vi.fn(), + resolveDefaultLanguage: vi.fn(), + resolveLanguage: vi.fn((locale: string) => ({ + id: locale === "en" ? 1 : 2, + isDefault: locale === "en", + isEnabled: true, + locale, + })), + unpublish: vi.fn(), + update: vi.fn(), + }; + + return model as unknown as ContentTranslationModel & + typeof model; +}; + +const service = (model: ReturnType) => { + const schemas = definition.schemas.translation; + if (!schemas) throw new Error("fixture is not localized"); + + return createContentTranslationEditorialService({ + c: { + get: () => ({ + transaction: async (body: (tx: unknown) => Promise) => + await body({}), + }), + } as never, + definition, + pluginId: PLUGIN_ID, + schemas, + translations: model, + }); +}; + +beforeEach(() => { + captured.length = 0; + storedRevision = null; + nextRevisionId = 100; +}); + +describe("snapshotting a localized group", () => { + it("records the nested logical shape", () => { + const snapshot = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + seo: { description: "SEO description", title: "SEO title" }, + slug: "article", + title: "Article", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + + expect(snapshot.fields).toStrictEqual({ + seo: { description: "SEO description", title: "SEO title" }, + slug: "article", + title: "Article", + }); + }); + + it("records a nullable group that is empty as null", () => { + const snapshot = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + seo: null, + slug: "article", + title: "Article", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + + expect(snapshot.fields.seo).toBeNull(); + }); + + it("never mentions a generated column name", () => { + const snapshot = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + seo: { description: "D", title: "T" }, + slug: "article", + title: "Article", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + + // The flattened names are an internal mapping. A history that recorded one + // would be invalidated by a rename that changed nothing anybody wrote. + expect(Object.keys(snapshot.fields)).not.toContain("seoTitle"); + expect(Object.keys(snapshot.fields)).not.toContain("seoDescription"); + expect(JSON.stringify(snapshot)).not.toContain("seoTitle"); + }); + + it("also reads a flattened database row", () => { + // The base snapshotter is handed columns and the translation one is handed + // logical values; both have to produce the same shape, or a revision written + // by one path would restore differently from one written by the other. + const snapshot = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + seoDescription: "D", + seoTitle: "T", + slug: "article", + title: "Article", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + + expect(snapshot.fields.seo).toStrictEqual({ + description: "D", + title: "T", + }); + }); + + it("keeps publication state out of the restorable fields", () => { + const snapshot = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + publishedAt: new Date("2026-02-01T00:00:00Z"), + seo: { description: "D", title: "T" }, + slug: "article", + status: "published", + title: "Article", + updatedAt: new Date(), + version: 3, + }, + { languageId: 2, locale: "pl" }, + ); + + expect(snapshot.publication).toStrictEqual({ + publishedAt: "2026-02-01T00:00:00.000Z", + status: "published", + }); + expect(snapshot.fields).not.toHaveProperty("status"); + expect( + projectTranslationRevisionSnapshot(definition, snapshot), + ).not.toHaveProperty("status"); + }); +}); + +describe("projecting a localized group for restore", () => { + const snapshotOf = ( + fields: Record, + ): ContentTranslationRevisionSnapshot => + ({ + contentTypeId: definition.id, + createdAt: "2026-01-01T00:00:00.000Z", + fields, + itemId: 7, + languageId: 2, + locale: "pl", + schemaVersion: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + version: 1, + }) as ContentTranslationRevisionSnapshot; + + it("projects the whole nested group", () => { + expect( + projectTranslationRevisionSnapshot( + definition, + snapshotOf({ + seo: { description: "D", title: "T" }, + slug: "a", + title: "A", + }), + ), + ).toStrictEqual({ + seo: { description: "D", title: "T" }, + slug: "a", + title: "A", + }); + }); + + it("ignores a leaf the group no longer declares", () => { + // The past is allowed to mention things that no longer exist. Left in, the + // strict object schema would turn every old revision into a permanent 422. + expect( + projectTranslationRevisionSnapshot( + definition, + snapshotOf({ seo: { gone: "x", title: "T" } }), + ), + ).toStrictEqual({ seo: { title: "T" } }); + }); + + it("projects a null group as null rather than as an empty object", () => { + expect( + projectTranslationRevisionSnapshot(definition, snapshotOf({ seo: null })), + ).toStrictEqual({ seo: null }); + }); + + it("leaves a field added since the snapshot absent", () => { + // Absent, not defaulted: the record keeps whatever it holds now. + expect( + projectTranslationRevisionSnapshot( + definition, + snapshotOf({ title: "A" }), + ), + ).toStrictEqual({ title: "A" }); + }); +}); + +describe("restoring a localized group", () => { + it("writes the whole group back through the model", async () => { + const model = translations(); + storedRevision = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + seo: { description: "Historical description", title: "Historical" }, + slug: "witaj", + title: "Witaj", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + + model.findByLanguageId.mockResolvedValue( + row({ seo: { description: "Current description", title: "Current" } }), + ); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["seo.title", "seo.description"], + row: row( + { + seo: { description: "Historical description", title: "Historical" }, + }, + { version: 2 }, + ), + version: 2, + }); + + const outcome = await service(model).restore(7, "pl", 101, { + actor: ACTOR, + expectedVersion: 1, + }); + + expect(outcome?.changed).toBe(true); + // The group is written whole - the projected snapshot is its complete + // historical value, and writing one leaf of it would restore half a state. + expect(model.update.mock.calls[0][2]).toStrictEqual({ + seo: { description: "Historical description", title: "Historical" }, + }); + // One new immutable revision, stamped `restore`. + expect(captured).toHaveLength(1); + expect(captured[0].operation).toBe("restore"); + expect(captured[0].snapshot.fields.seo).toStrictEqual({ + description: "Historical description", + title: "Historical", + }); + }); + + it("restores one leaf and preserves its unchanged sibling", async () => { + const model = translations(); + storedRevision = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + // Only the description differs from what is stored now. + seo: { description: "Old description", title: "Same title" }, + slug: "witaj", + title: "Witaj", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + + model.findByLanguageId.mockResolvedValue( + row({ seo: { description: "New description", title: "Same title" } }), + ); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["seo.description"], + row: row( + { seo: { description: "Old description", title: "Same title" } }, + { version: 2 }, + ), + version: 2, + }); + + const outcome = await service(model).restore(7, "pl", 101, { + actor: ACTOR, + expectedVersion: 1, + }); + + // Canonical paths, and only the leaf that moved. + expect(outcome?.changedFields).toStrictEqual(["seo.description"]); + expect(model.update.mock.calls[0][2]).toStrictEqual({ + seo: { description: "Old description", title: "Same title" }, + }); + }); + + it("is a no-op when the group already matches", async () => { + const model = translations(); + const current = { seo: { description: "D", title: "T" } }; + storedRevision = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + slug: "witaj", + title: "Witaj", + updatedAt: new Date(), + version: 1, + ...current, + }, + { languageId: 2, locale: "pl" }, + ); + model.findByLanguageId.mockResolvedValue(row(current)); + + const outcome = await service(model).restore(7, "pl", 101, { + actor: ACTOR, + expectedVersion: 1, + }); + + // A scalar diff would compare the two `seo` objects by identity and report a + // change here, writing a revision that restored nothing. + expect(outcome?.changed).toBe(false); + expect(outcome?.changedFields).toStrictEqual([]); + expect(model.update).not.toHaveBeenCalled(); + expect(captured).toHaveLength(0); + }); + + it("touches only this locale's localized values", async () => { + const model = translations(); + storedRevision = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + seo: { description: "D", title: "T" }, + slug: "witaj", + title: "Witaj", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + model.findByLanguageId.mockResolvedValue(row({ seo: null })); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["seo.title", "seo.description"], + row: row({ seo: { description: "D", title: "T" } }, { version: 2 }), + version: 2, + }); + + await service(model).restore(7, "pl", 101, { + actor: ACTOR, + expectedVersion: 1, + }); + + // One locale, by construction: the update is addressed to `"pl"` and the + // payload carries localized fields only - never `featured`, never `faq`. + expect(model.update.mock.calls[0][1]).toBe("pl"); + const payload = model.update.mock.calls[0][2] as Record; + expect(Object.keys(payload)).toStrictEqual(["seo"]); + expect(payload).not.toHaveProperty("featured"); + expect(payload).not.toHaveProperty("faq"); + expect(payload).not.toHaveProperty("status"); + }); + + it("keeps the translation's publication state", async () => { + const model = translations(); + storedRevision = contentTranslationRevisionSnapshot( + definition, + { + createdAt: new Date(), + itemId: 7, + // The snapshot was taken while the translation was a draft... + publishedAt: null, + seo: { description: "D", title: "T" }, + slug: "witaj", + status: "draft", + title: "Witaj", + updatedAt: new Date(), + version: 1, + }, + { languageId: 2, locale: "pl" }, + ); + // ...and it is published now. A field restore must not take it down. + model.findByLanguageId.mockResolvedValue( + row({ seo: null }, { publishedAt: new Date(), status: "published" }), + ); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["seo.title", "seo.description"], + row: row( + { seo: { description: "D", title: "T" } }, + { publishedAt: new Date(), status: "published", version: 2 }, + ), + version: 2, + }); + + const outcome = await service(model).restore(7, "pl", 101, { + actor: ACTOR, + expectedVersion: 1, + }); + + expect(model.update.mock.calls[0][2]).not.toHaveProperty("status"); + expect((outcome?.row as unknown as { status: string }).status).toBe( + "published", + ); + // The version still moves forward, and the new revision records the state. + expect(outcome?.version).toBe(2); + expect(captured[0].snapshot.publication?.status).toBe("published"); + }); + + it("rejects a snapshot the current schema refuses", async () => { + const model = translations(); + storedRevision = { + contentTypeId: definition.id, + createdAt: "2026-01-01T00:00:00.000Z", + // `seo.title` is `maxLength: 200`. A snapshot taken before the limit was + // tightened cannot be restored into today's schema. + fields: { seo: { description: null, title: "x".repeat(201) } }, + itemId: 7, + languageId: 2, + locale: "pl", + schemaVersion: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + version: 1, + }; + model.findByLanguageId.mockResolvedValue(row({})); + + await expect( + service(model).restore(7, "pl", 101, { + actor: ACTOR, + expectedVersion: 1, + }), + ).rejects.toBeInstanceOf(ContentRevisionNotRestorable); + + // All or nothing: nothing was written and no revision claims otherwise. + expect(model.update).not.toHaveBeenCalled(); + expect(captured).toHaveLength(0); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts index d3a68b53b..f67dd7e7c 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -28,7 +28,12 @@ import { ContentTranslationVersionConflict, } from "../errors"; import { partitionContentFields } from "../localization"; -import { diffChangedFields } from "./query"; +import { + contentFieldPath, + contentInnerFields, + splitContentFieldPath, +} from "../paths"; +import { diffChangedPaths } from "./query"; import { contentTranslationRevisionSnapshot, projectTranslationRevisionSnapshot, @@ -197,8 +202,21 @@ export const createContentTranslationEditorialService = < } const { localizedFields } = partitionContentFields(definition.fields); - const localizedNames = Object.keys( - localizedFields, + /** + * Every canonical path this locale owns: a scalar by its own name, a group by + * each of its leaves. + * + * What a create "changed", and the vocabulary the base half already reports - + * `seo.title` rather than `seo`, so a listener, a cache decision and the search + * synchronizer all read the same strings whichever half moved. + */ + const localizedPaths = Object.entries(localizedFields).flatMap( + ([name, fieldValue]) => + fieldValue.kind === "group" + ? Object.keys(contentInnerFields(fieldValue)).map(leaf => + contentFieldPath(name, leaf), + ) + : [name], ) as ContentLocalizedFieldName[]; // The localized slug, if there is one. A content type may declare at most one @@ -386,7 +404,7 @@ export const createContentTranslationEditorialService = < actor: options.actor, // Everything is new, so every localized field "changed" - which is // what the history should say about a create. - changedFields: localizedNames, + changedFields: localizedPaths, languageId: row.languageId, locale: row.locale, operation: "create", @@ -396,7 +414,7 @@ export const createContentTranslationEditorialService = < return { changed: true, - changedFields: localizedNames, + changedFields: localizedPaths, languageId: row.languageId, locale: row.locale, operation: "create" as const, @@ -513,11 +531,14 @@ export const createContentTranslationEditorialService = < const patch = withUpdateSlugs(parsed.data); const currentValues = current.values as Record; - const changedFields = diffChangedFields( - localizedNames, + // Canonical paths, and group-aware: `current.values` is the *logical* + // shape, so a scalar diff would compare two `seo` objects by identity and + // report every restore as a change even when nothing moved. + const changedFields = diffChangedPaths( + localizedFields, currentValues, patch, - ); + ) as ContentLocalizedFieldName[]; if (changedFields.length === 0) { return { @@ -533,8 +554,17 @@ export const createContentTranslationEditorialService = < const result = await translations.update( itemId, target.locale, + // Keyed by the *owner* of each changed path, so a group is written + // whole: the projected snapshot is the group's complete historical + // value, and writing one leaf of it would restore half a state. Object.fromEntries( - changedFields.map(key => [key, patch[key]]), + [ + ...new Set( + changedFields.map( + path => splitContentFieldPath(path)?.[0] ?? path, + ), + ), + ].map(key => [key, patch[key]]), ) as ContentLocalizedUpdateValues, { expectedVersion: options.expectedVersion, tx }, ); diff --git a/packages/vitnode/src/content/server/translation-effects.ts b/packages/vitnode/src/content/server/translation-effects.ts index 0226e5ea7..748809bca 100644 --- a/packages/vitnode/src/content/server/translation-effects.ts +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -3,13 +3,15 @@ import type { Context } from "hono"; import type { EventEmitResult } from "../../api/models/events"; import type { ContentEventAction } from "../events"; import type { AnyContentTypeDefinition } from "../types"; -import type { ContentTranslationEditorialOutcome } from "./translation-editorial-service"; - import type { AnyContentModel } from "./model"; import type { ContentSearchSyncOutcome } from "./search-sync"; +import type { ContentTranslationEditorialOutcome } from "./translation-editorial-service"; import { emitContentEvent } from "./emit"; -import { syncContentLocalizedSearch } from "./search-sync"; +import { + contentSearchAdvancedValues, + syncContentLocalizedSearch, +} from "./search-sync"; /** One translation operation, one event. Never `updated` - see `events.ts`. */ const EVENT_ACTION: Record< @@ -72,13 +74,6 @@ export interface ContentTranslationEffectsOptions { } export interface ContentTranslationEffectsResult { - /** - * What the index write reported, or `null` when there was none to do - a - * content type without `search`, or a no-op outcome. - * - * A one-element array at most: a translation mutation is one language. - */ - search?: ContentSearchSyncOutcome[]; /** * What the event transport reported, or `null` for a no-op outcome. * @@ -88,6 +83,13 @@ export interface ContentTranslationEffectsResult { * transaction is closed - which is exactly why the caller gets to see it. */ event: EventEmitResult | null; + /** + * What the index write reported, or `null` when there was none to do - a + * content type without `search`, or a no-op outcome. + * + * A one-element array at most: a translation mutation is one language. + */ + search?: ContentSearchSyncOutcome[]; } /** @@ -141,6 +143,10 @@ export const contentTranslationEffects = async ( // Scoped to the locale that moved. Omitting it would rewrite every other // language's document for a change none of them contains. search: await syncContentLocalizedSearch(c, model, { + // A translation mutation rewrites this locale's whole document, so it has + // to carry the shared collections as well: changing `seo.description` + // must not silently remove the indexed `faq.question` and `faq.answer`. + advanced: await contentSearchAdvancedValues(c, model, outcome.row.itemId), changed: outcome.changed, changedFields: outcome.changedFields, locale: outcome.locale, diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index e9a73a21f..e1e39caa4 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -28,6 +28,11 @@ import { ContentTranslationVersionConflict, } from "../errors"; import { partitionContentFields } from "../localization"; +import { + contentColumnsToValues, + contentStorageColumns, + contentValuesToColumns, +} from "../paths"; import { contentDatabase, findContentLanguage, @@ -254,11 +259,22 @@ export const createContentTranslationModel = < } const { localizedFields } = partitionContentFields(definition.fields); + // Flattened, so a localized group is selected, written and diffed as its leaf + // columns - and folded back into its nested shape by `toRow`. + const localizedColumns = contentStorageColumns(localizedFields); const localizedNames = Object.keys( - localizedFields, + localizedColumns, ) as ContentLocalizedFieldName[]; const { defaultLocale } = definition.localization; + // Generated column -> canonical path, so a translation reports `seo.title` + // where it stores `seoTitle`. + const pathByColumn = new Map( + definition.advanced.leaves + .filter(leaf => leaf.localized) + .map(leaf => [leaf.columnName, leaf.path]), + ); + const itemColumn = columns.itemId; const languageColumn = columns.languageId; const versionColumn = columns.version; @@ -275,7 +291,7 @@ export const createContentTranslationModel = < // consequence would be `/en/my-post` and `/pl/my_post`. const { withCreateSlugs, withUpdateSlugs } = createSlugNormalizer( contentTypeId, - localizedFields, + localizedColumns, ); const metaSelection = (): Record => @@ -349,8 +365,10 @@ export const createContentTranslationModel = < row: Record, locale: string, ): ContentTranslationRow => { - const values: Record = {}; - for (const name of localizedNames) values[name] = row[name]; + // Nested, as the caller declared it: `seoTitle` and `seoDescription` become + // `seo: { title, description }`, or `seo: null` when the group is nullable + // and both leaves are empty. + const values = contentColumnsToValues(localizedFields, row); return { ...toMeta(row, locale), @@ -518,7 +536,7 @@ export const createContentTranslationModel = < const [row] = await database .insert(translationTable) .values({ - ...withCreateSlugs(parsed), + ...withCreateSlugs(contentValuesToColumns(localizedFields, parsed)), itemId, languageId: target.id, ...(initialVersion === undefined ? {} : { version: initialVersion }), @@ -685,12 +703,21 @@ export const createContentTranslationModel = < // query. Slugs are normalised before the diff, so re-sending the stored // slug in a different case counts as no change rather than a pointless // write. - const patch = withUpdateSlugs(schemas.update.parse(values)); + const parsed = schemas.update.parse(values); + const patch = withUpdateSlugs( + contentValuesToColumns(localizedFields, parsed), + ); const current = await readOne(itemId, target.id, database); if (!current) return null; - const changedFields = diffChangedFields(localizedNames, current, patch); + // Diffed in **columns**, reported in canonical paths: `seo.description` + // rather than `seoDescription`, so a translation's changed-field list + // speaks the same vocabulary the base row's does. + const changedColumns = diffChangedFields(localizedNames, current, patch); + const changedFields = changedColumns.map( + name => pathByColumn.get(name) ?? name, + ) as typeof changedColumns; // A no-op is a successful write that changed nothing: it must not bump the // version, must not move `updatedAt`, and must not fail on a stale @@ -708,7 +735,7 @@ export const createContentTranslationModel = < const [row] = await database .update(translationTable) .set({ - ...Object.fromEntries(changedFields.map(key => [key, patch[key]])), + ...Object.fromEntries(changedColumns.map(key => [key, patch[key]])), version: sql`${versionColumn} + 1`, }) .where( diff --git a/packages/vitnode/src/content/server/translation-table.ts b/packages/vitnode/src/content/server/translation-table.ts index 09e40ea7b..712979fcc 100644 --- a/packages/vitnode/src/content/server/translation-table.ts +++ b/packages/vitnode/src/content/server/translation-table.ts @@ -20,6 +20,7 @@ import { import { ContentEngineError } from "../errors"; import { contentTranslationPrimaryKeyName } from "../indexes"; import { partitionContentFields } from "../localization"; +import { contentStorageColumns } from "../paths"; import { buildContentColumn, buildTranslationPublicationColumns, @@ -58,7 +59,11 @@ export const createContentTranslationTable = < ); } - const { localizedFields } = partitionContentFields(definition.fields); + // Flattened, so a localized group contributes its leaf columns here exactly + // as a shared one does on the base table. + const localizedFields = contentStorageColumns( + partitionContentFields(definition.fields).localizedFields, + ); const baseColumns = table as unknown as Record; const columns: Record = { @@ -120,11 +125,18 @@ export const contentTranslationTableColumns = < ...(definition.publication.enabled ? CONTENT_TRANSLATION_PUBLICATION_FIELDS : []), - ...Object.keys(localizedFields), + ...Object.keys(contentStorageColumns(localizedFields)), ]; - return Object.fromEntries(names.map(name => [name, source[name]])) as Record< - ContentTranslationColumnName, - PgColumn - >; + return { + ...Object.fromEntries(names.map(name => [name, source[name]])), + // Canonical paths as aliases of the generated leaf columns, exactly as + // `contentTableColumns` registers them for the base table - so a filter or a + // search configured in paths resolves on either side of the join. + ...Object.fromEntries( + definition.advanced.leaves + .filter(leaf => leaf.localized) + .map(leaf => [leaf.path, source[leaf.columnName]]), + ), + } as Record, PgColumn>; }; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 751f19158..5139fa726 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -18,6 +18,7 @@ import type { import type { ContentEditorialField, ContentFieldsOf, + ContentLeafPath, ContentLocalizedFieldName, ContentPublicationField, ContentSharedFieldName, @@ -166,12 +167,12 @@ export type ContentTranslationTable< * `AnyContentTypeDefinition` - whose localized name union is `never` - resolves * to an empty record instead of to `never`. */ -type LocalizedFieldsOf = { +type LocalizedFieldsOf = ContentStorageFields<{ [ K in ContentLocalizedFieldName & keyof ContentFieldsOf ]: ContentFieldsOf[K]; -}; +}>; /** * The translation table for one definition. @@ -239,6 +240,73 @@ type SharedFieldsOf = { ]: ContentFieldsOf[K]; }; +/** + * The type-level twin of `contentStorageColumns`. + * + * A field map, flattened into the columns it actually generates: scalars keep + * their names, a group contributes `seoTitle` per leaf, and the two collection + * kinds vanish because neither is a column here. Spelled out in the type system + * as well as at runtime because `$inferSelect` and `$inferInsert` are what a + * plugin's own hand-written queries are checked against - a table type that + * still said `seo: ` would type-check code Postgres then rejects. + */ +export type ContentStorageFields = GroupLeafColumnsOf & + ScalarFieldsOf; + +type ScalarFieldsOf = { + [ + K in keyof TFields as TFields[K] extends { kind: "group" | "repeatable" } + ? never + : TFields[K] extends { kind: "relation"; multiple: true } + ? never + : K + ]: TFields[K]; +}; + +/** + * Every group leaf column of a field map, keyed by its generated column name. + * + * Written as **one** mapped type over the union of canonical paths rather than + * as a per-group union folded back with the usual `UnionToIntersection` trick. + * That trick puts the union in a function-parameter position, which makes + * `TDefinition` contravariant in `ContentTableFor` and therefore invariant in + * `ContentModel` - and an invariant `ContentModel` is no longer assignable to + * `ContentModel`, which every route builder and every + * registry needs. It is the same trap `ResolvedContentAdminConfig` documents, + * reached from a different direction. + */ +type GroupLeafColumnsOf = { + [ + TPath in GroupLeafPathsOf as ColumnNameOfPath + ]: LeafDescriptorAt; +}; + +/** `"seo.title" | "seo.description"`, over a field map's groups. */ +type GroupLeafPathsOf = { + [K in keyof TFields]: TFields[K] extends { + fields: infer TInner; + kind: "group"; + } + ? `${K & string}.${keyof TInner & string}` + : never; +}[keyof TFields]; + +/** The type-level twin of `contentLeafColumnName`. */ +type ColumnNameOfPath = TPath extends `${infer TOwner}.${infer TLeaf}` + ? `${TOwner}${Capitalize}` + : never; + +type LeafDescriptorAt = + TPath extends `${infer TOwner}.${infer TLeaf}` + ? TOwner extends keyof TFields + ? TFields[TOwner] extends { fields: infer TInner } + ? TLeaf extends keyof TInner + ? TInner[TLeaf] + : never + : never + : never + : never; + /** * The base `pgTable` for one definition. * @@ -250,7 +318,12 @@ export type ContentTableFor = TDefinition extends { publication: { enabled: infer TPublication extends boolean }; tableName: infer TName extends string; } - ? ContentTable, TPublication, TEditorial> + ? ContentTable< + TName, + ContentStorageFields>, + TPublication, + TEditorial + > : never; /** @@ -258,10 +331,16 @@ export type ContentTableFor = TDefinition extends { * * Shared fields only: a localized field is a column on the translation table, * and {@link ContentTranslationColumnName} is the union that names those. + * + * A group appears as its generated leaf columns **and** under its canonical + * paths: `contentTableColumns` registers `seo.title` as an alias of `seoTitle`, + * so a filter, an `orderBy` or a search that was configured in paths resolves + * without every one of them learning the mapping. */ export type ContentColumnName = - | ContentSharedFieldName + | ContentLeafPath> | ContentSystemField + | (keyof ContentStorageFields> & string) | (TDefinition extends { editorial: { enabled: true } } ? ContentEditorialField : never) @@ -273,13 +352,77 @@ export type ContentColumnName = * One thunk per `relation` field, resolving to the target table's `id`. Missing * or extra keys are a compile error, and the thunk keeps circular content type * references safe - Drizzle resolves it lazily, at serialization time. + * + * A **to-many** relation needs one too: its foreign key is `relatedItemId` on + * the generated junction table rather than a column on the row, but the target + * it points at is exactly as much a fact the database module has to supply. + * + * A **self**-relation does not, and must not: `() => thisContent.table.id` + * would reference the model inside its own initializer, and TypeScript resolves + * that by widening the model to `any` - silently taking every typed service, + * schema and column map with it. `createContentModel` resolves it from the + * table it is building, which is the only place that reference exists anyway. */ export type ContentReferences = { [ - K in keyof TFields as TFields[K] extends { kind: "relation" } ? K : never + K in keyof TFields as TFields[K] extends { kind: "relation"; self: true } + ? never + : TFields[K] extends { kind: "relation" } + ? K + : never ]: () => AnyIdColumn; }; +/** + * The generated junction table for one to-many relation field. + * + * `string` for the table name for the same reason + * {@link ContentTranslationTableFor} uses one: the name is derived at runtime + * and clamped with a fingerprint, and re-deriving that clamp in the type system + * would be a second implementation of it. + */ +export type ContentJunctionTable = PgTableWithColumns<{ + columns: BuildColumns< + string, + { + createdAt: NotNull>>; + itemId: NotNull>; + position: NotNull>; + relatedItemId: NotNull>; + }, + "pg" + >; + dialect: "pg"; + name: string; + schema: undefined; +}>; + +/** The generated child table for one repeatable field. */ +export type ContentRepeatableChildTable = PgTableWithColumns<{ + columns: BuildColumns< + string, + { + [K in keyof TFields]: ContentColumnBuilder; + } & { + createdAt: NotNull>>; + id: PgSerialBuilderInitial; + itemId: NotNull>; + position: NotNull>; + updatedAt: NotNull>>; + }, + "pg" + >; + dialect: "pg"; + name: string; + schema: undefined; +}>; + +/** Every generated collection table of one content type, by field name. */ +export interface ContentAdvancedTables { + junctions: Record; + repeatables: Record>; +} + // Loosest shape a foreign key target can take; the FK itself is validated by // Postgres, and by `getTableConfig` in the table tests. type AnyIdColumn = Parameters< diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 43eb6e7a1..9a7538a7d 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -186,22 +186,126 @@ export interface ContentUserField< onDelete: ContentOnDelete; } +/** + * A reference to another content type's rows. + * + * `multiple: false` is the Stage 1 shape: one nullable-or-not foreign key column + * on the base table. `multiple: true` moves the reference off the row entirely + * and into a generated junction table - see {@link ContentRelationJunction} - + * because a column cannot hold a set. + * + * `target` is a thunk, which is also what makes a **self-relation** ordinary + * rather than special: `target: () => articleContentType` inside + * `articleContentType` is a forward reference resolved on first read, exactly + * like two content types pointing at each other. + */ export interface ContentRelationField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, + TMultiple extends boolean = boolean, + TOrdered extends boolean = boolean, + TSelf extends boolean = boolean, > extends ContentFieldShared { kind: "relation"; + /** + * Many targets instead of one. + * + * Literal rather than optional-boolean for the same reason `localized` is: + * every partition in this file keys off `{ multiple: true }`, and a widened + * `boolean` would resolve a to-many relation to the to-one branch - which is + * a foreign-key column that does not exist. + */ + multiple: TMultiple; onDelete: ContentOnDelete; - /** Thunk so two content types can reference each other. */ + /** + * The author's order is the order the value comes back in. + * + * Only meaningful with `multiple: true`. Without it the set is stored in + * ascending target-id order, which is still deterministic - it is simply not + * something the author chose. + */ + ordered: TOrdered; + /** + * The target is **this** content type. + * + * `self: true` rather than `target: () => thisContentType`, and the reason is + * the type system rather than taste: a definition whose own field map + * mentions its own inferred type is circular, and TypeScript resolves that by + * quietly widening the whole definition to `any`. Every nested value type, + * every allowlist check and every compile-time guarantee in this file would + * disappear - silently, because `any` is not an error. + * + * `defineContentType` rebinds the thunk to the finished definition, so + * everything downstream sees an ordinary relation pointing at an ordinary + * content type. + * + * Literal, like `multiple` and `ordered`: `ContentReferences` subtracts a + * self-relation from the reference map it demands, and a widened `boolean` + * would leave it demanding the one thunk nobody can write. + */ + self: TSelf; + /** Thunk so two content types can refer to each other. */ target: () => AnyContentTypeDefinition; } +/** + * A reusable structured group: several leaves under one logical name. + * + * The value stays nested (`seo.title`), and the storage stays relational - each + * leaf becomes an ordinary column on the base or translation table, called + * `seoTitle`. There is no JSONB here: a + * flattened column is indexable, constrainable and queryable, and a group is a + * fixed set of leaves rather than an open bag. + * + * Localization is a property of the **group**, not of its leaves: `localized: + * true` moves the whole group into the translation table. Marking a single leaf + * would split one logical value across two tables with two different revision + * histories and two different permissions, which is exactly the drift + * `partitionContentFields` exists to prevent. + */ +export interface ContentGroupField< + TFields = ContentLeafFieldMap, + TRequired extends boolean = boolean, + TNullable extends boolean = boolean, + TLocalized extends boolean = boolean, +> extends ContentFieldShared { + /** Leaves, in declaration order. Scalar kinds only - groups do not nest. */ + fields: TFields; + kind: "group"; + localized: TLocalized; +} + +/** + * A repeatable structured group: zero or more ordered child rows. + * + * Stored in a generated child table (`example_articles_faq`) with a `serial` + * primary key of its own, so a child has a **stable identity** that survives a + * reorder - which is what makes "edit row 3" mean something and what lets a + * revision restore put the same row back rather than a copy of it. + * + * Never nullable and never required: the value is an array, and the empty array + * is the natural "nothing here". Never localized either - see + * `apps/docs/.../repeatable-fields.mdx` for why that is a later stage. + */ +export interface ContentRepeatableField< + TFields = ContentLeafFieldMap, +> extends ContentFieldShared { + fields: TFields; + kind: "repeatable"; + /** Upper bound on child rows. Enforced by the generated schema. */ + max?: number; + /** Lower bound on child rows. Enforced by the generated schema. */ + min?: number; +} + export type ContentFieldDescriptor = | ContentBooleanField | ContentDateTimeField | ContentEnumField + | ContentGroupField | ContentNumberField | ContentRelationField + | ContentRepeatableField | ContentSlugField | ContentTextareaField | ContentTextField @@ -209,8 +313,28 @@ export type ContentFieldDescriptor = export type ContentFieldKind = ContentFieldDescriptor["kind"]; +/** + * The kinds a group leaf or a repeatable leaf may be. + * + * Scalars only. A nested group would need a second level of column naming and a + * second level of partial-update merging for no modelling gain; a `slug` inside + * a group would need its uniqueness scoped to something; and a `relation` or + * `user` inside one would put a foreign key in a place the relation services do + * not look. All four are definition-time errors. + */ +export type ContentLeafFieldDescriptor = + | ContentBooleanField + | ContentDateTimeField + | ContentEnumField + | ContentNumberField + | ContentTextareaField + | ContentTextField; + export type ContentFieldMap = Record; +/** A group's or repeatable's inner field map. Scalars only. */ +export type ContentLeafFieldMap = Record; + /** * Type-parameter constraint for a field map - deliberately shallow. * @@ -248,42 +372,128 @@ type ApplyNullable = TField extends { nullable: true } ? null | TValue : TValue; +/** The scalar half of {@link ContentFieldValue}, before nullability. */ +type ScalarFieldValue = TField extends { kind: "boolean" } + ? boolean + : TField extends { kind: "dateTime" } + ? Date + : TField extends { values: readonly (infer TValue)[] } + ? TValue + : TField extends { kind: "number" | "relation" | "user" } + ? number + : string; + +/** The scalar half of {@link ContentFieldInput}. `dateTime` crosses as ISO. */ +type ScalarFieldInput = TField extends { kind: "boolean" } + ? boolean + : TField extends { kind: "dateTime" } + ? string + : TField extends { values: readonly (infer TValue)[] } + ? TValue + : TField extends { kind: "number" | "relation" | "user" } + ? number + : string; + +/** Every leaf of a group, as it comes back. Nested, never flattened. */ +type ContentGroupValue = Prettify<{ + [K in keyof TFields]: ContentFieldValue; +}>; + +/** + * One repeatable child row. + * + * `id` is the child table's own primary key and is always present on a read: + * it is what a later `update`, `delete` or `reorder` addresses, and what a + * revision restore matches an historical row against. + */ +export type ContentRepeatableRow = Prettify< + { + [K in keyof TFields]: ContentFieldValue; + } & { id: number } +>; + +/** + * A create-shaped object over every key of a field map. + * + * Exported so the service can type a repeatable's child input from the leaves + * the definition already declares, rather than falling back to + * `Record` and losing every one of them. + */ +export type ContentValuesOf = CreateValuesOf; + +/** The inner field map of one group or repeatable, by name. */ +export type ContentInnerFieldsOf = + ContentFieldsOf[keyof ContentFieldsOf & + TName] extends { + fields: infer TInner; + } + ? TInner + : never; + +/** + * One repeatable child row as it is written. + * + * `id` is optional and is the whole write protocol: present means "update this + * existing child", absent means "create a new one". Position comes from the + * array order, so nothing carries it explicitly. + */ +export type ContentRepeatableInputRow = Prettify< + CreateValuesOf & { id?: number } +>; + /** * The value as it comes back from the API (`select`). * * Structural on purpose: `TField` is unconstrained so this also works with the * shallow {@link ContentFieldsConstraint}. + * + * The three advanced kinds resolve before the scalar branch, because a `group` + * has no scalar value at all and a to-many `relation` is a set of identifiers + * rather than one. */ -export type ContentFieldValue = ApplyNullable< - TField extends { kind: "boolean" } - ? boolean - : TField extends { kind: "dateTime" } - ? Date - : TField extends { values: readonly (infer TValue)[] } - ? TValue - : TField extends { kind: "number" | "relation" | "user" } - ? number - : string, - TField ->; +export type ContentFieldValue = TField extends { + fields: infer TInner; + kind: "group"; +} + ? ApplyNullable, TField> + : TField extends { fields: infer TInner; kind: "repeatable" } + ? ContentRepeatableRow[] + : TField extends { kind: "relation"; multiple: true } + ? number[] + : ApplyNullable, TField>; /** * The value as it is sent to the API. Identical to the select value except for * `dateTime`, which crosses the wire (and the AutoForm) as an ISO 8601 string - * `z.toJSONSchema` throws on `z.date()`, so a form schema can never hold one. */ -export type ContentFieldInput = ApplyNullable< - TField extends { kind: "boolean" } - ? boolean - : TField extends { kind: "dateTime" } - ? string - : TField extends { values: readonly (infer TValue)[] } - ? TValue - : TField extends { kind: "number" | "relation" | "user" } - ? number - : string, - TField ->; +export type ContentFieldInput = TField extends { + fields: infer TInner; + kind: "group"; +} + ? ApplyNullable, TField> + : TField extends { fields: infer TInner; kind: "repeatable" } + ? ContentRepeatableInputRow[] + : TField extends { kind: "relation"; multiple: true } + ? number[] + : ApplyNullable, TField>; + +/** + * The value a **partial** update may send for one field. + * + * Identical to {@link ContentFieldInput} everywhere except a group, where every + * leaf becomes optional: `{ seo: { description } }` must be able to move one + * leaf without restating the others, and without blanking them. + */ +export type ContentFieldPatch = TField extends { + fields: infer TInner; + kind: "group"; +} + ? ApplyNullable< + Prettify>>, + TField + > + : ContentFieldInput; /** * Whether the generated column carries a Postgres default. Drives `hasDefault` @@ -323,6 +533,129 @@ type SharedFieldKeys = Exclude< LocalizedFieldKeys >; +/** + * Fields whose value is **not** a column on either generated table: a to-many + * relation, which lives in a junction table, and a repeatable, which lives in a + * child table. + * + * Everything that addresses a column - the admin list, an index, an equality + * filter, `orderBy`, `ContentSelect` - subtracts these. Everything that + * addresses a *value* - the create payload, the update patch, `changedFields` - + * keeps them. That split is the whole of Stage 6's "opt-in" promise: a content + * type that declares none of them has an empty subtraction and behaves exactly + * as it did in Stage 5. + */ +type CollectionFieldKeys = { + [K in keyof TFields]: TFields[K] extends { kind: "repeatable" } + ? K + : TFields[K] extends { kind: "relation"; multiple: true } + ? K + : never; +}[keyof TFields]; + +/** Shared fields that are actually stored on the base table. */ +type ColumnFieldKeys = Exclude< + SharedFieldKeys, + CollectionFieldKeys +>; + +type GroupFieldKeys = { + [K in keyof TFields]: TFields[K] extends { kind: "group" } ? K : never; +}[keyof TFields]; + +/** + * Shared fields that are **one** column: a scalar, not a group. + * + * A group occupies several columns under generated names, so it is not + * something a list cell, an `orderBy` or an equality filter can address. Its + * *leaves* are, under their canonical paths - see {@link ContentLeafPath}. + */ +type ScalarColumnFieldKeys = Exclude< + ColumnFieldKeys, + GroupFieldKeys +>; + +/** + * The canonical dotted path of every group leaf: `"seo.title"`. + * + * One representation, used by `changedFields`, validation errors, index + * declarations, `publicApi.fields`, `search.contentFields` and revision + * diagnostics alike. The generated column name (`seoTitle`) is an internal + * mapping and never appears in any of them. + */ +export type ContentLeafPath = string & + { + [K in GroupFieldKeys]: TFields[K] extends { + fields: infer TInner; + } + ? `${K & string}.${keyof TInner & string}` + : never; + }[GroupFieldKeys]; + +/** The canonical dotted path of every repeatable leaf: `"faq.question"`. */ +export type ContentRepeatableLeafPath = string & + { + [K in keyof TFields]: TFields[K] extends { + fields: infer TInner; + kind: "repeatable"; + } + ? `${K & string}.${keyof TInner & string}` + : never; + }[keyof TFields]; + +/** + * Everything `changedFields` may name, and everything a nested validation error + * is keyed by: a scalar field, a group **leaf** path, or a collection name. + * + * A group never appears whole - `seo` moving is always one or more of + * `seo.title`, `seo.description`. A collection always appears whole: which + * child of `faq` moved is a question the revision snapshot answers, not + * something a cache tag or an event payload branches on. + */ +export type ContentChangedPath = + | (CollectionFieldKeys> & string) + | ContentLeafPath> + | (ScalarColumnFieldKeys> & string); + +/** Field names of a to-many relation. */ +export type ContentRelationCollectionName = string & + { + [ + K in keyof ContentFieldsOf + ]: ContentFieldsOf[K] extends { + kind: "relation"; + multiple: true; + } + ? K + : never; + }[keyof ContentFieldsOf]; + +/** Field names of a repeatable group. */ +export type ContentRepeatableFieldName = string & + { + [ + K in keyof ContentFieldsOf + ]: ContentFieldsOf[K] extends { kind: "repeatable" } + ? K + : never; + }[keyof ContentFieldsOf]; + +/** + * The advanced collections of one record, loaded on demand. + * + * Deliberately **not** part of {@link ContentSelect}: a to-many relation and a + * repeatable are each an extra query, and an admin list that returned them + * would issue one per row. They are batch-loaded for a detail read and for the + * public projections that ask for them, and nowhere else. + */ +export type ContentAdvancedValues = Prettify<{ + [ + K in + | ContentRelationCollectionName + | ContentRepeatableFieldName + ]: ContentFieldValue[K]>; +}>; + /** * A create-shaped object over a subset of the field map: required fields stay * required, everything else is optional, and each value is inferred from its own @@ -378,7 +711,7 @@ type ContentAddressableColumn< | ContentEditorialColumn | ContentPublicationColumn | ContentSystemField - | SharedFieldKeys; + | ScalarColumnFieldKeys; export interface ContentAdminListConfig< TFields = ContentFieldMap, @@ -393,9 +726,9 @@ export interface ContentAdminListConfig< * Allowlist for `orderBy`. System columns - and the publication columns when * enabled - are always allowed and need no entry here. */ - orderableFields?: SharedFieldKeys[]; + orderableFields?: ScalarColumnFieldKeys[]; /** Only shared `text` and `textarea` fields may be searched. */ - searchableFields?: SharedFieldKeys[]; + searchableFields?: ScalarColumnFieldKeys[]; } export interface ContentAdminConfig< @@ -419,7 +752,7 @@ export interface ContentAdminConfig< * naming one here would make a toast depend on whose locale the reader is in; * Stage 5B gives the AdminCP a locale-aware title of its own. */ - titleField?: SharedFieldKeys; + titleField?: ScalarColumnFieldKeys; } /** @@ -465,11 +798,28 @@ export interface ContentIndexInput< unique?: boolean; } +/** + * Everything an index may name. + * + * The addressable columns, plus every **group leaf** by its canonical path. A + * leaf is an ordinary column under a generated name, so `{ on: ["seo.title"] }` + * compiles to exactly the index `{ on: ["title"] }` would have. + * + * Repeatable leaves and to-many relations are deliberately absent: neither is a + * column on the base table, so an index over one would have to be an index on a + * different table - which the child and junction tables already carry. Naming + * one is a compile error here and a definition-time error at runtime, rather + * than something silently dropped. + */ type ContentIndexColumn< TFields, TPublication extends boolean, TEditorial extends boolean, -> = ContentAddressableColumn & string; +> = ( + | ContentAddressableColumn + | ContentLeafPath +) & + string; /** * Stored shape. Non-generic for the same reason as @@ -531,9 +881,38 @@ type ContentPublicationColumns = TDefinition extends { // Public API // --------------------------------------------------------------------------- -/** Everything `publicApi.fields` may name: declared fields plus a few columns. */ +/** + * Everything `publicApi.fields` may name. + * + * Scalar fields and a few generated columns, as before - plus, in Stage 6, + * **leaf paths**. A group or a repeatable is never exposed whole: naming `seo` + * would publish `seo.indexable` because somebody wanted `seo.title`, and that is + * exactly the accident leaf-level allowlisting exists to prevent. A to-many + * relation *is* named whole, because its value is a list of identifiers and + * there is no sub-part of one to keep private. + */ export type ContentPublicExposableField = - (typeof CONTENT_PUBLIC_EXPOSABLE_COLUMNS)[number] | (keyof TFields & string); + | (typeof CONTENT_PUBLIC_EXPOSABLE_COLUMNS)[number] + | ContentLeafPath + | ContentRepeatableLeafPath + | (ExposableFlatFieldKeys & string); + +/** + * Field names `publicApi.fields` may name directly, localized ones included: a + * public localized read joins the translation it is serving, so where a value is + * stored is a fact about the query rather than about the response. + * + * Groups and repeatables are subtracted because they are exposed leaf by leaf. + */ +type ExposableFlatFieldKeys = Exclude< + keyof TFields, + | GroupFieldKeys + | { + [K in keyof TFields]: TFields[K] extends { kind: "repeatable" } + ? K + : never; + }[keyof TFields] +>; /** * Opts a content type into a generated, read-only public API. @@ -598,6 +977,31 @@ type ContentFieldNamesOfKind = string & [K in keyof TFields]: TFields[K] extends { kind: TKind } ? K : never; }[keyof TFields]; +/** + * Leaf paths of one or more kinds, inside the named container kind. + * + * `TContainer` is what separates "a group leaf, which is a column on the row" + * from "a repeatable leaf, which is a column on a child row": a search title has + * to be one value, so it may come from the first and never from the second. + */ +type ContentLeafPathsOfKind< + TFields, + TKind extends string, + TContainer extends string, +> = string & + { + [K in keyof TFields]: TFields[K] extends { + fields: infer TInner; + kind: TContainer; + } + ? { + [L in keyof TInner]: TInner[L] extends { kind: TKind } + ? `${K & string}.${L & string}` + : never; + }[keyof TInner] + : never; + }[keyof TFields]; + /** Field names of one or more kinds that also accept no `null`. */ type ContentNonNullableFieldNamesOfKind< TFields, @@ -628,23 +1032,74 @@ export type ContentSearchTitleField< > >; +/** + * The generated tables a to-many relation and a repeatable field each get. + * + * Resolved once by `defineContentType` and read by the table generator, the + * services and the migration docs alike, so the name a migration creates and the + * name a query addresses can never drift. Names are clamped to Postgres' + * 63-character identifier limit with a deterministic fingerprint, the same way + * index and translation table names already are. + */ +export interface ContentRelationJunction { + /** The source field this junction belongs to. */ + field: string; + /** `(itemId, position)`, so an ordered relation has no duplicate slots. */ + positionIndexName: string; + /** `
__pk` on `(itemId, relatedItemId)`. */ + primaryKeyName: string; + /** `
__related_idx` - the reverse lookup and the FK's index. */ + relatedIndexName: string; + tableName: string; +} + +export interface ContentRepeatableTable { + field: string; + /** `(itemId, position)`, unique: two children cannot share a slot. */ + positionIndexName: string; + tableName: string; +} + export type ContentSearchDescriptionField< TFields, TPublicField extends string, > = Extract< TPublicField, - ContentFieldNamesOfKind< - TFields, - (typeof CONTENT_SEARCH_DESCRIPTION_KINDS)[number] - > + | ContentFieldNamesOfKind< + TFields, + (typeof CONTENT_SEARCH_DESCRIPTION_KINDS)[number] + > + | ContentLeafPathsOfKind< + TFields, + (typeof CONTENT_SEARCH_DESCRIPTION_KINDS)[number], + "group" + > >; +/** + * Field names and leaf paths `search.contentFields` accepts. + * + * Widest of the three, and the only one that reaches into a **repeatable**: + * `faq.question` is many values rather than one, which rules it out as a + * heading but makes it exactly the kind of prose a body should carry. The + * values are concatenated in position order - see `contentSearchText`. + */ export type ContentSearchTextField< TFields, TPublicField extends string, > = Extract< TPublicField, - ContentFieldNamesOfKind + | ContentFieldNamesOfKind + | ContentLeafPathsOfKind< + TFields, + (typeof CONTENT_SEARCH_TEXT_KINDS)[number], + "group" + > + | ContentLeafPathsOfKind< + TFields, + (typeof CONTENT_SEARCH_TEXT_KINDS)[number], + "repeatable" + > >; /** @@ -1060,6 +1515,43 @@ export type LocalizedContentTypeDefinition = AnyContentTypeDefinition & { localization: { enabled: true }; }; +/** + * Everything Stage 6 resolves once, at definition time. + * + * Empty arrays for a content type that declares no advanced field, which is what + * makes "Stage 6 is opt-in" true rather than merely intended: every generator + * below loops over these, and an empty loop generates nothing. + */ +export interface ResolvedContentAdvancedConfig { + /** One generated junction table per to-many relation field. */ + junctions: ContentRelationJunction[]; + /** + * Every group leaf, by canonical path, with the column it compiles to. + * + * The single field-path mapping the whole engine reads: table generation, + * schemas, service reads and writes, revisions, the public projection, search + * and the AdminCP all take the column name from here rather than re-deriving + * it, so there is exactly one place the two representations meet. + */ + leaves: ContentLeafColumn[]; + /** One generated child table per repeatable field. */ + repeatables: ContentRepeatableTable[]; +} + +/** One group leaf: its canonical path and the column it is stored in. */ +export interface ContentLeafColumn { + /** `seoTitle` - the generated column, in the same camelCase every other one uses. */ + columnName: string; + /** The owning group's name. */ + group: string; + /** The leaf's own name inside the group. */ + leaf: string; + /** Whether the owning group is `localized: true`. */ + localized: boolean; + /** `seo.title`. */ + path: string; +} + export interface ContentTypeDefinition< TId extends string = string, TFields = ContentFieldMap, @@ -1073,6 +1565,8 @@ export interface ContentTypeDefinition< TLocalizationEnabled extends boolean = boolean, > { admin: ResolvedContentAdminConfig; + /** Generated junction tables, child tables and the leaf-path mapping. */ + advanced: ResolvedContentAdvancedConfig; /** Editorial workflow, or the disabled default when `editorial` is omitted. */ editorial: ResolvedContentEditorialConfig< TEditorialEnabled, @@ -1132,18 +1626,41 @@ export type ContentFieldsOf = TDefinition extends { export type ContentSelect = Prettify< ContentEditorialColumns & ContentPublicationColumns & { - [K in SharedFieldKeys>]: ContentFieldValue< + [K in ColumnFieldKeys>]: ContentFieldValue< ContentFieldsOf[K] >; } & { createdAt: Date; id: number; updatedAt: Date } >; +/** + * One record with its advanced collections attached. + * + * What a detail read returns and what an editorial mutation echoes back. Two + * extra queries per record rather than per row, and only where a caller asked + * for the whole thing. + */ +export type ContentDetail = Prettify< + ContentAdvancedValues & ContentSelect +>; + /** The base-table half of a create payload. See {@link ContentSharedValues}. */ export type ContentCreateInput = ContentSharedValues; -export type ContentUpdateInput = Prettify< - Partial> ->; +/** + * A partial update. + * + * Partial one level deeper than `Partial` would be: a group + * value may name a subset of its leaves, so `{ seo: { description } }` moves one + * leaf and leaves `seo.title` exactly where it was. A collection is replaced + * whole - `categories: [2, 5, 9]` is the complete new set - because a partial + * set has no meaning that is not either "add" or "remove", and both of those are + * their own service call. + */ +export type ContentUpdateInput = Prettify<{ + [K in SharedFieldKeys>]?: ContentFieldPatch< + ContentFieldsOf[K] + >; +}>; /** * Every field name the content type declares, localized ones included. @@ -1164,13 +1681,13 @@ export type ContentFieldName = keyof ContentFieldsOf & type FieldNamesOfKind = string & { [ - K in SharedFieldKeys> + K in ScalarColumnFieldKeys> ]: ContentFieldsOf[K] extends { kind: TKind; } ? K : never; - }[SharedFieldKeys>]; + }[ScalarColumnFieldKeys>]; /** * Kinds the generated filter schema understands, derived from the one runtime @@ -1187,7 +1704,7 @@ export type FilterableContentFieldName = FieldNamesOfKind< >; /** {@link FieldNamesOfKind} over every field, shared and localized alike. */ -type AnyFieldNamesOfKind = string & +type AnyFieldNamesOfKind = Exclude< { [ K in keyof ContentFieldsOf @@ -1196,7 +1713,10 @@ type AnyFieldNamesOfKind = string & } ? K : never; - }[keyof ContentFieldsOf]; + }[keyof ContentFieldsOf], + CollectionFieldKeys> +> & + string; /** * Field names a **public** filter may name. @@ -1217,14 +1737,30 @@ export type PublicFilterableContentFieldName = AnyFieldNamesOfKind< * field - plus `status` once publication is enabled, which is a generated * column rather than a declared field. */ +/** + * The one filter a to-many relation accepts: "this record is related to *that* + * row". + * + * An object rather than a bare identifier so it can never be confused with the + * equality filter a to-one relation takes, and so the SQL it compiles to - an + * indexed `EXISTS` over the junction table - is chosen by the shape of the value + * rather than by looking up the descriptor twice. There is deliberately no + * `containsAll`, no `containsAny` and no traversal: that is a query language, + * and a hand-written route is the better answer to it. + */ +export interface ContentRelationFilter { + contains: number; +} + export type ContentFilterInput = Partial< - (TDefinition extends { publication: { enabled: true } } - ? { status: ContentPublicationStatus } - : Record) & { - [K in FilterableContentFieldName]: ContentFieldInput< - ContentFieldsOf[K] - >; - } + Record, ContentRelationFilter> & + (TDefinition extends { publication: { enabled: true } } + ? { status: ContentPublicationStatus } + : Record) & { + [K in FilterableContentFieldName]: ContentFieldInput< + ContentFieldsOf[K] + >; + } >; /** @@ -1294,13 +1830,62 @@ type ContentPublicValue = TName extends "id" : TName extends "publishedAt" ? Date | null : TName extends keyof TFields - ? TFields[TName] extends { kind: "relation" } - ? TFields[TName] extends { nullable: true } - ? ContentPublicRelation | null - : ContentPublicRelation - : ContentFieldValue + ? TFields[TName] extends { kind: "relation"; multiple: true } + ? number[] + : TFields[TName] extends { kind: "relation" } + ? TFields[TName] extends { nullable: true } + ? ContentPublicRelation | null + : ContentPublicRelation + : ContentFieldValue : never; +/** The dotted paths in an allowlist, grouped by the field they belong to. */ +type PublicPathOwner = TName extends `${infer TOwner}.${string}` + ? TOwner + : never; + +type PublicPathLeaf< + TName, + TOwner extends string, +> = TName extends `${TOwner}.${infer TLeaf}` ? TLeaf : never; + +/** The names in an allowlist that are plain fields rather than leaf paths. */ +type PublicFlatName = TName extends `${string}.${string}` + ? never + : TName; + +/** + * The nested half of a public response: one key per group or repeatable that + * has at least one exposed leaf, holding **only** the leaves that were exposed. + * + * Leaf-level privacy falls straight out of this: `seo.indexable` is absent from + * the type and absent from the generated `SELECT`, however many other `seo.*` + * paths the allowlist names. + */ +type ContentPublicNested = { + [TOwner in keyof TFields & PublicPathOwner]: TFields[TOwner] extends { + fields: infer TInner; + kind: "repeatable"; + } + ? Prettify< + { + [ + TLeaf in keyof TInner & PublicPathLeaf + ]: ContentFieldValue; + } & { id: number } + >[] + : TFields[TOwner] extends { fields: infer TInner; kind: "group" } + ? ApplyNullable< + Prettify<{ + [ + TLeaf in keyof TInner & PublicPathLeaf + ]: ContentFieldValue; + }>, + TFields[TOwner] + > + : never; +}; + /** * One public row: exactly the allowlisted fields, and not one key more. * @@ -1309,12 +1894,15 @@ type ContentPublicValue = TName extends "id" * leaves Postgres. Adding a field to the content type does not add it here. */ export type ContentPublicSelect = Prettify< - ContentPublicLocaleColumn & { - [K in ContentPublicFieldName]: ContentPublicValue< + ContentPublicLocaleColumn & + ContentPublicNested< ContentFieldsOf, - K - >; - } + ContentPublicFieldName + > & { + [ + K in PublicFlatName> + ]: ContentPublicValue, K>; + } >; /** @@ -1354,13 +1942,26 @@ export type ContentPublicListRow = * `publicApi.filterableFields` array is not recoverable as a type, so the * narrower check is the runtime allowlist. */ -export type ContentPublicFilterInput = Partial<{ - [ - K in ContentPublicFieldName & - PublicFilterableContentFieldName - ]: ContentFieldInput[K]>; -}>; +export type ContentPublicFilterInput = Partial< + Record< + ContentPublicFieldName & + ContentRelationCollectionName, + ContentRelationFilter + > & { + [ + K in ContentPublicFieldName & + PublicFilterableContentFieldName + ]: ContentFieldInput[K]>; + } +>; -/** Columns the public list may be ordered by. */ +/** + * Columns the public list may be ordered by. + * + * Flat names only. A leaf path is a column and could in principle be ordered by, + * but a repeatable leaf and a to-many relation are not, and one union that + * accepts all three would put two of them past the compiler and into a runtime + * allowlist error. + */ export type ContentPublicOrderableFieldName = - "publishedAt" | ContentPublicFieldName; + "publishedAt" | PublicFlatName>; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 1f10efef0..80eb8b581 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -466,6 +466,17 @@ "boolean": { "on": "Yes", "off": "No" + }, + "group": { + "enabled": "Has a value" + }, + "list": { + "add": "Add {label}", + "empty": "Nothing here yet.", + "move_down": "Move entry {position} down", + "move_up": "Move entry {position} up", + "position": "{label} {position} of {total}", + "remove": "Remove entry {position}" } }, "conflict": { diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index c32e8ce15..6b6b2c893 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -409,3 +409,77 @@ export const testLocalizedSearchPageContentType = defineContentType({ list: { columns: ["featured", "status"] }, }, }); + +/** + * The Stage 6 localized fixture: a localized group and a **shared** repeatable + * on one searchable, localized content type. + * + * It exists for the three places Stage 6 and Stage 5 meet and can disagree: + * + * 1. a translation revision has to record `seo` in its nested logical shape, + * not run it through the scalar coercion that turns an object into `null`; + * 2. a localized search document is built from three sources at once - the base + * row, the shared collections and one translation - and every path that + * builds one has to supply all three or the documents differ; + * 3. the rebuild has to reproduce exactly what live synchronization wrote. + */ +export const testAdvancedLocalizedContentType = defineContentType({ + id: "test.advanced-localized", + tableName: "test_advanced_localized", + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + publication: { enabled: true }, + editorial: { enabled: true }, + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + // Localized whole: both leaves live on the translation table, and a + // translation revision has to record them nested. + seo: field.group({ + localized: true, + nullable: true, + fields: { + title: field.text({ nullable: true, maxLength: 200 }), + description: field.textarea({ nullable: true }), + }, + }), + featured: field.boolean({ defaultValue: false }), + // Shared, like every Stage 6 repeatable - so every locale's document is + // built from the same children, and all of them have to contain them. + faq: field.repeatable({ + fields: { + question: field.text({ required: true, maxLength: 200 }), + answer: field.textarea({ required: true }), + }, + }), + }, + publicApi: { + enabled: true, + path: "advanced-localized", + fields: [ + "title", + "slug", + "seo.title", + "seo.description", + "faq.question", + "faq.answer", + "featured", + "publishedAt", + ], + searchableFields: ["title"], + orderableFields: ["publishedAt"], + }, + search: { + enabled: true, + titleField: "title", + descriptionField: "seo.description", + contentFields: ["title", "seo.description", "faq.question", "faq.answer"], + pathTemplate: "/{locale}/advanced-localized/{slug}", + }, + admin: { + label: { + plural: "Test Advanced Localized", + singular: "Test Advanced Localized", + }, + list: { columns: ["featured", "status"] }, + }, +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx index 23b302765..2a607d269 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx @@ -42,10 +42,52 @@ const Value = ({ options?: Record; value: ContentSnapshotValue | undefined; }) => { + // Narrowed once, so the scalar branches below can use `String(...)` without + // every one of them having to prove the value is not an object. if (value === null || value === undefined || value === "") { return ; } + // The three Stage 6 shapes reach here as objects and arrays rather than + // scalars, and `String(...)` on any of them is `[object Object]`. Each gets a + // summary a person can read: a group as its leaves, a to-many relation as its + // targets, a repeatable as how many entries it holds. + if (Array.isArray(value)) { + if (value.length === 0) return ; + + if (typeof value[0] === "number") { + return ( + + {(value as number[]) + .map(id => labels[String(id)] ?? `#${id}`) + .join(", ")} + + ); + } + + return ( + + {emptyLabel === "" ? value.length : `× ${value.length}`} + + ); + } + + if (typeof value === "object") { + const leaves = Object.entries(value as Record).filter( + ([, leaf]) => leaf !== null && leaf !== "", + ); + + if (leaves.length === 0) return ; + + return ( + + {leaves + .map(([leaf, leafValue]) => `${leaf}: ${String(leafValue)}`) + .join(", ")} + + ); + } + switch (kind) { case "boolean": return {value === true ? "✓" : "—"}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/advanced-fields.test.tsx b/packages/vitnode/src/views/admin/views/content/lib/advanced-fields.test.tsx new file mode 100644 index 000000000..4ed234434 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/advanced-fields.test.tsx @@ -0,0 +1,376 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } 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, values?: Record) => + values ? `${key}:${Object.values(values).join(",")}` : key, +})); + +/** + * The three Stage 6 editors, driven through the real AutoForm seam. + * + * Every assertion is about what the *form value* becomes, because that value is + * exactly what the API takes: a group controls a nested object, a to-many + * relation controls a list of identifiers, and a repeatable controls a list of + * rows where an existing child keeps its `id` and a new one has none. If the + * shapes here are right, nothing has to be converted on submit. + */ + +let latest: FieldValues = {}; + +const Harness = ({ + initial, + loadOptions = async () => Promise.resolve([]), + spec, +}: { + initial?: unknown; + loadOptions?: (args: { + field: string; + search: string; + }) => Promise<{ label: string; value: string }[]>; + spec: ContentFormFieldSpec; +}) => { + const form = useForm({ defaultValues: { value: initial } as FieldValues }); + latest = form.watch(); + + return ( + // The to-many picker wraps the async combobox, which fetches through + // TanStack Query - so the harness has to provide a client the way the real + // AdminCP layout does. + +
+ ( + + )} + /> + +
+ ); +}; + +const seoSpec: ContentFormFieldSpec = { + fields: [ + { + kind: "text", + label: "SEO title", + name: "title", + nullable: true, + required: false, + }, + { + kind: "textarea", + label: "SEO description", + name: "description", + nullable: true, + required: false, + }, + ], + kind: "group", + label: "SEO", + name: "seo", + nullable: true, + required: false, +}; + +const faqSpec: ContentFormFieldSpec = { + fields: [ + { + kind: "text", + label: "Question", + name: "question", + nullable: false, + required: true, + }, + { + kind: "textarea", + label: "Answer", + name: "answer", + nullable: false, + required: true, + }, + ], + kind: "repeatable", + label: "FAQ", + maxItems: 2, + name: "faq", + nullable: false, + required: false, +}; + +const categoriesSpec: ContentFormFieldSpec = { + kind: "relation", + label: "Categories", + multiple: true, + name: "categories", + nullable: false, + required: false, +}; + +describe("group editor", () => { + it("renders a labelled section with one input per leaf", () => { + render( + , + ); + + // A `fieldset`/`legend`, so a screen reader announces "SEO" with every leaf + // - which is what tells `SEO / Title` from `Article / Title`. + expect(screen.getByRole("group", { name: "SEO" })).toBeTruthy(); + expect(screen.getByText("SEO title")).toBeTruthy(); + expect(screen.getByText("SEO description")).toBeTruthy(); + }); + + it("writes one leaf without disturbing the others", async () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText(/SEO title/), { + target: { value: "New" }, + }); + + await waitFor(() => { + expect(latest.value).toStrictEqual({ + description: "Kept", + title: "New", + }); + }); + }); + + it("turns a nullable group off as null, not as an empty object", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("switch")); + + await waitFor(() => { + // `null` is the group's absence, and it is a different stored state from + // every leaf happening to be empty. + expect(latest.value).toBeNull(); + }); + }); + + it("shows no switch for a group that cannot be null", () => { + render( + , + ); + + expect(screen.queryByRole("switch")).toBeNull(); + }); +}); + +describe("repeatable editor", () => { + it("says so when there is nothing yet", () => { + render(); + + expect(screen.getByText("list.empty")).toBeTruthy(); + }); + + it("adds a row with no id, which is what marks it new", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /list\.add/ })); + + await waitFor(() => { + expect(latest.value).toStrictEqual([{}]); + }); + }); + + it("stops at the declared maximum", () => { + render( + , + ); + + const add = screen.getByRole("button", { name: /list\.add/ }); + + expect(add.hasAttribute("disabled")).toBe(true); + fireEvent.click(add); + + expect((latest.value as unknown[]).length).toBe(2); + }); + + it("reorders with labelled buttons rather than only by dragging", async () => { + render( + , + ); + + // Every control is reachable and named, which drag-and-drop alone is not. + fireEvent.click(screen.getByRole("button", { name: "list.move_down:1" })); + + await waitFor(() => { + expect( + (latest.value as { id: number }[]).map(row => row.id), + ).toStrictEqual([2, 1]); + }); + }); + + it("disables the reorder button that would do nothing", () => { + render( + , + ); + + expect( + screen + .getByRole("button", { name: "list.move_up:1" }) + .hasAttribute("disabled"), + ).toBe(true); + expect( + screen + .getByRole("button", { name: "list.move_down:2" }) + .hasAttribute("disabled"), + ).toBe(true); + }); + + it("removes a row and keeps every other identity", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "list.remove:1" })); + + await waitFor(() => { + expect(latest.value).toStrictEqual([ + { answer: "B", id: 2, question: "Two" }, + ]); + }); + }); + + it("keeps an existing child's id when its values are edited", async () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Question/), { + target: { value: "One!" }, + }); + + await waitFor(() => { + // Identity survives the edit, so the service updates the child in place + // rather than deleting it and creating a copy. + expect(latest.value).toStrictEqual([ + { answer: "A", id: 11, question: "One!" }, + ]); + }); + }); +}); + +describe("to-many relation picker", () => { + const loadOptions = async () => + Promise.resolve([ + { label: "News", value: "1" }, + { label: "Guides", value: "2" }, + ]); + + it("holds identifiers rather than combobox options", () => { + render( + , + ); + + // Exactly what the API takes - nothing to convert on submit, which is what + // `contentFormValuesToPayload` skipping a `multiple` relation relies on. + expect(latest.value).toStrictEqual([1, 2]); + // Falls back to the identifier until the picker has resolved a name for it. + expect(screen.getByText("1")).toBeTruthy(); + }); + + it("removes a target without touching the others", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "list.remove:1" })); + + await waitFor(() => { + expect(latest.value).toStrictEqual([2]); + }); + }); + + it("offers no reorder controls for an unordered relation", () => { + render( + , + ); + + // The engine stores an unordered set in ascending target-id order whatever + // the editor does, so buttons here would visibly do nothing. + expect(screen.queryByRole("button", { name: /list\.move_/ })).toBeNull(); + }); + + it("offers them for an ordered one", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "list.move_down:1" })); + + await waitFor(() => { + expect(latest.value).toStrictEqual([2, 1]); + }); + }); +}); 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 index c0ba1f0bf..dbbd83226 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -12,6 +12,10 @@ import { AutoFormSelect } from "@/components/form/fields/select"; import { AutoFormSwitch } from "@/components/form/fields/switch"; import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { ContentGroupField } from "./group-field"; +import { ContentRelationSetField } from "./relation-set-field"; +import { ContentRepeatableField } from "./repeatable-field"; + export type ContentOptionsLoader = (args: { field: string; search: string; @@ -58,6 +62,13 @@ export const ContentField = ({ ); } + // The three Stage 6 editors. Each one controls the nested value the API + // takes, so nothing is flattened on submit and nothing re-nested on load. + case "group": + return ( + + ); + case "number": // A nullable number needs the "no value" toggle; a plain one does not. return spec.nullable ? ( @@ -80,6 +91,17 @@ export const ContentField = ({ ); case "relation": + if (spec.multiple) { + return ( + + ); + } + + // eslint-disable-next-line no-fallthrough case "user": return ( ); + case "repeatable": + return ( + + ); + case "textarea": return ; diff --git a/packages/vitnode/src/views/admin/views/content/lib/group-field.tsx b/packages/vitnode/src/views/admin/views/content/lib/group-field.tsx new file mode 100644 index 000000000..58ea1d97d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/group-field.tsx @@ -0,0 +1,100 @@ +import { useTranslations } from "next-intl"; +import React from "react"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; + +import { Switch } from "@/components/ui/switch"; + +import type { ContentOptionsLoader } from "./field-component"; + +import { ContentLeafField } from "./leaf-field"; + +export interface ContentGroupFieldProps extends ItemAutoFormComponentProps { + loadOptions: ContentOptionsLoader; + spec: ContentFormFieldSpec; +} + +/** + * A structured group, rendered as a labelled section of ordinary inputs. + * + * A `fieldset` with a `legend`, not a `div` with a heading: a screen reader + * announces the group name with every leaf inside it, which is the difference + * between "Title" appearing twice on a form and "SEO / Title" and "Article / + * Title" being told apart. + * + * The value it controls is the nested object the API takes - `{ title, + * description }` - so nothing has to be flattened on submit and nothing has to + * be re-nested on load. + */ +export const ContentGroupField = ({ + field, + loadOptions, + spec, + ...props +}: ContentGroupFieldProps) => { + const t = useTranslations("core.content.form"); + const leaves = spec.fields ?? []; + const value = field.value as null | Record | undefined; + const isNull = spec.nullable && value === null; + const legendId = `content-group-${spec.name}`; + + const setLeaf = (leaf: string, next: unknown) => { + field.onChange({ ...(value ?? {}), [leaf]: next }); + }; + + return ( +
+ + {spec.label} + + + {!!spec.description && ( +

+ {spec.description} +

+ )} + + {spec.nullable && ( +
+ {t("group.enabled")} + { + // `null` is the whole group's absence, and it is a different + // state from every leaf happening to be empty - which is exactly + // why a nullable group requires nullable leaves. + field.onChange( + checked + ? Object.fromEntries(leaves.map(leaf => [leaf.name, null])) + : null, + ); + }} + /> +
+ )} + + {!isNull && ( +
+ {leaves.map(leaf => ( + { + setLeaf(leaf.name, next); + }} + otherProps={props.otherProps} + spec={leaf} + value={value?.[leaf.name]} + /> + ))} +
+ )} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/leaf-field.tsx b/packages/vitnode/src/views/admin/views/content/lib/leaf-field.tsx new file mode 100644 index 000000000..f7b7fa560 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/leaf-field.tsx @@ -0,0 +1,69 @@ +import type { ControllerRenderProps, FieldValues } from "react-hook-form"; + +import React from "react"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; + +import type { ContentOptionsLoader } from "./field-component"; + +import { ContentField } from "./field-component"; + +export interface ContentLeafFieldProps { + loadOptions: ContentOptionsLoader; + /** The dotted react-hook-form path, for `id` and label association. */ + name: string; + onChange: (value: unknown) => void; + otherProps: ItemAutoFormComponentProps["otherProps"]; + spec: ContentFormFieldSpec; + value: unknown; +} + +/** + * One leaf of a group or a repeatable row, rendered by the ordinary field + * component. + * + * The adapter is the whole point: `ContentField` expects react-hook-form's + * `ControllerRenderProps`, and a leaf is not registered with react-hook-form at + * all - its parent is. Handing it a synthetic controller keeps every leaf input + * identical to the one a top-level field of the same kind renders, which is + * what stops Stage 6 from growing a second form system. + */ +export const ContentLeafField = ({ + loadOptions, + name, + onChange, + otherProps, + spec, + value, +}: ContentLeafFieldProps) => { + const controller = React.useMemo>( + () => ({ + disabled: false, + name, + onBlur: () => undefined, + onChange: (next: unknown) => { + // Both shapes an input can hand back: a DOM event, or the value itself. + const unwrapped = + next !== null && typeof next === "object" && "target" in next + ? (next as { target: { checked?: boolean; value?: unknown } }) + .target.value + : next; + + onChange(unwrapped); + }, + ref: () => undefined, + value: value ?? "", + }), + [name, onChange, value], + ); + + return ( + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx b/packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx new file mode 100644 index 000000000..861efe371 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx @@ -0,0 +1,160 @@ +import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; + +import { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { Button } from "@/components/ui/button"; + +import type { ContentOptionsLoader } from "./field-component"; + +export interface ContentRelationSetFieldProps extends ItemAutoFormComponentProps { + /** Labels the row already resolved, keyed by target id. */ + labels?: Record; + loadOptions: ContentOptionsLoader; + spec: ContentFormFieldSpec; +} + +/** + * The picker for a to-many relation. + * + * The combobox already exists and already knows how to search a target content + * type through a server action, so this is a list *around* it rather than a + * second picker: choose one, it appends; choose another, it appends again. + * + * Reorder controls appear only for an `ordered: true` relation. For an + * unordered one the engine stores the set in ascending target-id order whatever + * the editor does, and offering buttons that visibly do nothing would be worse + * than offering none. + */ +export const ContentRelationSetField = ({ + field, + labels = {}, + loadOptions, + spec, + ...props +}: ContentRelationSetFieldProps) => { + const t = useTranslations("core.content.form"); + const selected = Array.isArray(field.value) ? (field.value as number[]) : []; + // Labels resolved by the picker this session, on top of the ones the row + // arrived with - so a target chosen a moment ago still reads as its name. + const [resolved, setResolved] = React.useState>({}); + const legendId = `content-relation-${spec.name}`; + + const labelFor = (id: number): string => + resolved[id] ?? labels[id] ?? String(id); + + const move = (from: number, to: number) => { + if (to < 0 || to >= selected.length) return; + + const next = [...selected]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + field.onChange(next); + }; + + return ( +
+ + {spec.label} + + + {!!spec.description && ( +

{spec.description}

+ )} + + {selected.length === 0 && ( +

{t("list.empty")}

+ )} + +
    + {selected.map((id, index) => ( +
  • + {labelFor(id)} + +
    + {spec.ordered === true && ( + <> + + + + )} + +
    +
  • + ))} +
+ +
+ + // The picker searches the whole target content type; the ones + // already chosen are filtered out so the list cannot offer a + // duplicate the service would reject. + (await loadOptions({ field: spec.name, search })).filter( + option => !selected.includes(Number(option.value)), + ) + } + field={{ + ...field, + onChange: (option: unknown) => { + const picked = option as null | { label: string; value: string }; + if (!picked?.value) return; + + const id = Number(picked.value); + if (!Number.isInteger(id) || selected.includes(id)) return; + + setResolved(current => ({ ...current, [id]: picked.label })); + field.onChange([...selected, id]); + }, + // Always empty: the combobox is an "add one" control here, not the + // thing that holds the value. + value: undefined, + }} + id={`content-${spec.name}-add`} + label={t("list.add", { label: spec.label })} + otherProps={props.otherProps} + placeholder={t("relation.placeholder")} + searchPlaceholder={t("relation.search_placeholder")} + /> +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/repeatable-field.tsx b/packages/vitnode/src/views/admin/views/content/lib/repeatable-field.tsx new file mode 100644 index 000000000..b1414a248 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/repeatable-field.tsx @@ -0,0 +1,198 @@ +import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; +import type { ContentFormFieldSpec } from "@/content/admin/spec"; + +import { Button } from "@/components/ui/button"; + +import type { ContentOptionsLoader } from "./field-component"; + +import { ContentLeafField } from "./leaf-field"; + +export interface ContentRepeatableFieldProps extends ItemAutoFormComponentProps { + loadOptions: ContentOptionsLoader; + spec: ContentFormFieldSpec; +} + +/** One row as the editor holds it: the API's shape plus a key for React. */ +interface EditorRow { + /** + * A key that is stable for the row's whole life in the editor. + * + * Separate from `id`, and deliberately: `id` belongs to the database and a + * row that has not been saved yet does not have one. Keying React off `id` + * would give every unsaved row the key `undefined`, and React would reuse one + * input's DOM state for another row's value. + */ + key: string; + values: Record; +} + +const toRows = (value: unknown): EditorRow[] => + Array.isArray(value) + ? (value as Record[]).map((values, index) => ({ + key: + typeof values.id === "number" + ? `saved-${values.id}` + : `draft-${index}`, + values, + })) + : []; + +/** + * A repeatable field's editor: add, edit, remove, move up, move down. + * + * Reorder is buttons, not drag-and-drop. Drag-and-drop may be added on top, but + * it can never be the only way: a keyboard user and a screen-reader user both + * need a control they can reach and a label that says what it does, and "drag + * the third item above the second" is neither. + * + * The whole list is one form value, so saving five rows is one request with one + * `expectedVersion` - not five mutations racing each other's version. + */ +export const ContentRepeatableField = ({ + field, + loadOptions, + spec, + ...props +}: ContentRepeatableFieldProps) => { + const t = useTranslations("core.content.form"); + const leaves = spec.fields ?? []; + const rows = toRows(field.value); + const max = spec.maxItems ?? Number.MAX_SAFE_INTEGER; + const legendId = `content-repeatable-${spec.name}`; + + // Monotonic, so a row added and removed and added again never reuses a key. + const nextKeyRef = React.useRef(0); + + const commit = (next: EditorRow[]) => { + field.onChange(next.map(row => row.values)); + }; + + const move = (from: number, to: number) => { + if (to < 0 || to >= rows.length) return; + + const next = [...rows]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + commit(next); + }; + + return ( +
+ + {spec.label} + + + {!!spec.description && ( +

{spec.description}

+ )} + + {rows.length === 0 && ( +

{t("list.empty")}

+ )} + +
    + {rows.map((row, index) => { + const position = t("list.position", { + label: spec.label, + position: index + 1, + total: rows.length, + }); + + return ( +
  • +
    + + {position} + + +
    + + + +
    +
    + +
    + {leaves.map(leaf => ( + { + commit( + rows.map((item, at) => + at === index + ? { + ...item, + values: { ...item.values, [leaf.name]: next }, + } + : item, + ), + ); + }} + otherProps={props.otherProps} + spec={leaf} + value={row.values[leaf.name]} + /> + ))} +
    +
  • + ); + })} +
+ + +
+ ); +}; diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts index 09f0f5c2a..10af525ed 100644 --- a/plugins/example/src/api/modules/admin/admin.module.ts +++ b/plugins/example/src/api/modules/admin/admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@vitnode/core/api/lib/module"; import { buildContentAdminModule } from "@vitnode/core/content/server"; import { CONFIG_PLUGIN } from "@/const"; +import { advancedArticleContent } from "@/database/advanced-articles"; import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; import { localizedArticleContent } from "@/database/localized-articles"; @@ -26,7 +27,12 @@ export const adminModule = buildModule({ // could not edit `title` in any language would be a worse thing to ship // than no form at all. Stage 5B adds the locale tabs and registers it in // `config.tsx` alongside the others. - contentTypes: [articleContent, categoryContent, localizedArticleContent], + contentTypes: [ + advancedArticleContent, + articleContent, + categoryContent, + localizedArticleContent, + ], }), ], }); diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index 20d9f122a..943b6fbb2 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -3,6 +3,7 @@ import { buildContentPublicModule } from "@vitnode/core/content/server"; import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; +import { advancedArticleContent } from "@/database/advanced-articles"; import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; import { localizedArticleContent } from "@/database/localized-articles"; @@ -31,6 +32,7 @@ export const exampleApiPlugin = () => buildContentPublicModule({ pluginId: CONFIG_PLUGIN.pluginId, contentTypes: [ + advancedArticleContent, articleContent, categoryContent, localizedArticleContent, diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 7dfd4cb47..7e7ff31b6 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -29,4 +29,10 @@ export const EXAMPLE_MIGRATIONS = [ // `DEFAULT 'draft'`, so every translation written while Stage 5A was current // becomes a draft rather than being silently published. "0030_add_translation_editorial.sql", + // Stage 6: the advanced-modeling fixture. One base table with a flattened + // shared group, one translation table with a flattened localized group, two + // junction tables (one of them a self-relation) and one repeatable child + // table - each with the constraints that make its ordering and its integrity + // facts about the database rather than about the service. + "0031_add_example_advanced_articles.sql", ]; diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts new file mode 100644 index 000000000..b3f02af42 --- /dev/null +++ b/plugins/example/src/content/advanced-article.ts @@ -0,0 +1,191 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "./category"; + +/** + * The Stage 6 reference: every advanced modeling shape on one content type. + * + * - **`categories`** - an unordered to-many relation. Its values live in + * `example_advanced_articles_categories`, a junction table with a real + * foreign key at each end; `onDelete: "restrict"` means Postgres itself + * refuses to delete a category that is still in use, rather than a check in + * service code that a direct `DELETE` would walk past. + * - **`relatedArticles`** - an **ordered self-relation**, declared with + * `self: true` rather than `target: () => advancedArticleContentType`. The + * difference is not stylistic: a definition whose field map mentions its own + * inferred type is circular, and TypeScript resolves that by widening the + * whole definition to `any` - silently taking every nested value type with + * it. `ordered: true` keeps the author's order, and `UNIQUE (itemId, + * position)` is what makes that order a fact rather than a hope. + * - **`seo`** - a **localized** group. Its leaves are stored as `seoTitle` and + * `seoDescription` on the *translation* table, so every language gets its own + * SEO copy - and the value stays nested (`row.seo.title`) whatever the columns + * are called. + * - **`syndication`** - a **shared** group. Same mechanics, on the base table. + * Kept separate from `seo` on purpose: localization is a property of the whole + * group, so a group cannot have one localized leaf and one shared one. Two + * groups is the shape that says which is which. + * - **`faq`** - a repeatable. Its children live in + * `example_advanced_articles_faq`, each with a `serial` primary key of its own + * so identity survives a reorder, and `search.contentFields` indexes their + * prose in position order. + * + * What is deliberately **not** here is the combination Stage 6 refuses: + * `field.repeatable({ localized: true })`, and a `localized: true` leaf inside + * either kind. See `apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx`. + */ +export const advancedArticleContentType = defineContentType({ + id: "example.advanced-article", + tableName: "example_advanced_articles", + + localization: { + enabled: true, + defaultLocale: "en", + fallback: "default", + }, + + publication: { enabled: true }, + + editorial: { + enabled: true, + revisions: { retention: 20 }, + preview: { enabled: true, expiresInMinutes: 30 }, + }, + + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 200, + }), + slug: field.slug({ localized: true, source: "title" }), + + // Unordered: a set of categories has no natural first one, so the engine + // stores it in ascending target-id order and `set([9, 2])` and `set([2, 9])` + // are the same state rather than two writes. + categories: field.relation({ + multiple: true, + onDelete: "restrict", + target: () => categoryContentType, + }), + + // A self-relation, and an ordered one: "read next" is a sequence somebody + // chose. `onDelete: "cascade"` drops the reference when the target article + // goes, which is the honest analogue of nulling a column here. + relatedArticles: field.relation({ + multiple: true, + onDelete: "cascade", + ordered: true, + self: true, + }), + + // Localized whole: both leaves move to the translation table together, with + // one revision history and one permission between them. + seo: field.group({ + localized: true, + nullable: true, + fields: { + // Nullable because the group is: `seo: null` has to be able to blank + // every leaf, and it cannot do that to a NOT NULL column. + title: field.text({ nullable: true, maxLength: 200 }), + description: field.textarea({ nullable: true, maxLength: 500 }), + }, + }), + + // Shared: whether a search engine should index the article, and how + // important it is, are properties of the article rather than of a language. + syndication: field.group({ + fields: { + indexable: field.boolean({ defaultValue: true }), + priority: field.number({ + integer: true, + min: 0, + max: 10, + defaultValue: 5, + }), + }, + }), + + // Shared, like every repeatable in Stage 6. + faq: field.repeatable({ + max: 20, + fields: { + question: field.text({ required: true, minLength: 3, maxLength: 200 }), + answer: field.textarea({ required: true }), + }, + }), + }, + + /** + * Leaf-level allowlisting. + * + * `seo.title` and `seo.description` are public; `syndication.priority` is + * public and `syndication.indexable` is **not**, which is the whole point of + * naming leaves rather than groups: exposing one leaf must not expose its + * neighbours, and a leaf added later stays private until somebody says + * otherwise. + * + * `categories` is exposed as identifiers. Not as expanded rows: a category has + * its own public API, its own allowlist and its own publication state, and + * publishing another content type's data because two records are related is + * not a decision this allowlist gets to make. `relatedArticles` is private + * altogether. + */ + publicApi: { + enabled: true, + path: "advanced-articles", + fields: [ + "title", + "slug", + "categories", + "seo.title", + "seo.description", + "syndication.priority", + "faq.question", + "faq.answer", + "publishedAt", + ], + searchableFields: ["title", "seo.title"], + orderableFields: ["publishedAt", "syndication.priority"], + filterableFields: ["categories", "slug"], + defaultOrderBy: "publishedAt", + defaultOrder: "desc", + }, + + /** + * A document per published translation, built from three kinds of value at + * once: a plain localized field, a localized group leaf, and a repeatable's + * children joined in position order. + */ + search: { + enabled: true, + titleField: "title", + descriptionField: "seo.description", + contentFields: ["title", "seo.description", "faq.question", "faq.answer"], + pathTemplate: "/{locale}/advanced-articles/{slug}", + }, + + // Leaf paths, materialised against the generated columns: this compiles to an + // index on `syndicationPriority`, exactly as `{ on: ["priority"] }` would have + // if `priority` were a top-level field. + indexes: [{ on: ["syndication.priority"] }], + + admin: { + label: { + plural: "Example Advanced Articles", + singular: "Example Advanced Article", + }, + list: { + // Scalar columns only. A group is several columns, and a collection is on + // another table - naming either is a compile error as well as a runtime + // one, because a list that loaded them would issue a query per row. + columns: ["status", "updatedAt"], + }, + form: { + // The form *does* carry them: this is the surface where a group renders as + // a section and a collection as an editor. + fields: ["categories", "relatedArticles", "syndication", "faq"], + }, + }, +}); diff --git a/plugins/example/src/database/advanced-articles.ts b/plugins/example/src/database/advanced-articles.ts new file mode 100644 index 000000000..dfce66d13 --- /dev/null +++ b/plugins/example/src/database/advanced-articles.ts @@ -0,0 +1,37 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { advancedArticleContentType } from "@/content/advanced-article"; + +import { example_categories } from "./categories"; + +export const advancedArticleContent = createContentModel( + advancedArticleContentType, + { + references: { + // A to-many relation needs a reference thunk exactly as a to-one does: + // its foreign key is `related_item_id` on the generated junction table + // rather than a column on the row, but the target is just as much a fact + // this module has to supply. + categories: () => example_categories.id, + // `relatedArticles` is deliberately absent. It is a `self: true` + // relation, and the engine resolves it from the table it is building: + // writing `() => advancedArticleContent.table.id` here would reference + // the model inside its own initializer, and TypeScript resolves that by + // widening the whole model to `any` - silently taking every typed + // service, schema and column map with it. + }, + }, +); + +// Five exports, not one. Drizzle Kit discovers each table from its export when +// it globs the built `dist/src/database/*.js`, so a junction or child table +// without one would simply be missing from the migration. +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; diff --git a/plugins/example/src/database/advanced-postgres.test.ts b/plugins/example/src/database/advanced-postgres.test.ts new file mode 100644 index 000000000..632cacc54 --- /dev/null +++ b/plugins/example/src/database/advanced-postgres.test.ts @@ -0,0 +1,1595 @@ +import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { Context } from "hono"; + +import { + ContentAdvancedInputError, + ContentRevisionNotRestorable, + ContentVersionConflict, +} from "@vitnode/core/content"; +import { + contentTranslationEffects, + createContentLocalizedSearchIndexer, + syncContentLocalizedSearch, +} from "@vitnode/core/content/server"; +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, beforeEach, describe, expect, it } from "vitest"; + +import { CONFIG_PLUGIN, EXAMPLE_MIGRATIONS } from "@/const"; +import { advancedArticleContentType } from "@/content/advanced-article"; + +import { advancedArticleContent } from "./advanced-articles"; +import { categoryContent } from "./categories"; + +/** + * Stage 6 against real Postgres. + * + * Everything here is about what the *database* enforces, not what the service + * intends: a duplicate junction row is a `23505`, a category still in use is a + * `23503`, a reorder that would collide is either atomic or it is not. None of + * that can be shown with a mock, and all of it is what the generated + * constraints exist for. + * + * Runs only with `DATABASE_TEST_URL` set, and **wipes** the database it points + * at - so the URL has to name one with "test" in it: + * + * ```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)); + +const migrationSql = (files: readonly string[]): string => + files + .map(file => + readFileSync( + resolve(here, "../../../../apps/docs/migrations", file), + "utf8", + ), + ) + .join("\n--> statement-breakpoint\n"); + +const CORE_STUBS = ` + CREATE TABLE "core_users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL + ); + CREATE TABLE "core_queue" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "name" varchar(100) NOT NULL, + "queue" varchar(100) DEFAULT 'default' NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "priority" integer DEFAULT 0 NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 3 NOT NULL, + "availableAt" timestamp DEFAULT now() NOT NULL, + "reservedAt" timestamp, + "lastError" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp + ); + CREATE TABLE "core_languages" ( + "id" serial PRIMARY KEY NOT NULL, + "code" varchar(32) NOT NULL, + "name" varchar(255) NOT NULL, + "default" boolean DEFAULT false NOT NULL, + "protected" boolean DEFAULT false NOT NULL, + CONSTRAINT "core_languages_code_unique" UNIQUE("code") + ); +`; + +const ACTOR = { type: "staff" as const, userId: null }; + +let sql: ReturnType; +let db: ReturnType; +let context: Context; +/** + * The Postgres major, for the one assertion whose SQLSTATE moved. + * + * Postgres 18 reports an explicit `ON DELETE RESTRICT` as `23001` + * (restrict_violation) where earlier majors reported the generic `23503` + * (foreign_key_violation). The version decides which is correct rather than the + * assertion accepting either - "one of these two" would still pass if a future + * major stopped refusing the delete at all. + */ +let serverMajor = 0; + +/** + * A second connection, for the tests that need two writers at once. + * + * The main client is `max: 1`, which serialises everything through one backend + * - useless for a race, because the second statement would be waiting on the + * first to finish being sent. + */ +let rival: ReturnType; +let rivalContext: 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; +}; + +/** Categories every test can point at. Recreated per test, ids move. */ +let categoryIds: number[] = []; + +/** + * Whatever the search engine was asked to do, in order. + * + * A recorder rather than a real engine: what these tests are about is the + * *document* the engine is handed - and above all whether the rebuild hands it + * the same one live synchronization did. + */ +const indexed: SearchDocument[] = []; +const deleted: { itemId: number; itemType: string; locale?: string }[] = []; + +const createArticle = async ( + values: Record = {}, +): Promise<{ id: number; version: number }> => { + const outcome = await advancedArticleContent + .editorialService?.(context, { pluginId: CONFIG_PLUGIN.pluginId }) + .create({ ...values }, { actor: ACTOR }); + if (!outcome) throw new Error("create returned nothing"); + + return { id: outcome.row.id, version: outcome.version }; +}; + +const editorial = (target: Context = context) => + advancedArticleContent.editorialService?.(target, { + pluginId: CONFIG_PLUGIN.pluginId, + }); + +const service = (target: Context = context) => + advancedArticleContent.service(target); + +describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { + beforeAll(async () => { + 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 }); + + const [{ version }] = await sql<{ version: number }[]>` + SELECT current_setting('server_version_num')::int AS version + `; + serverMajor = Math.floor(version / 10_000); + + await sql.unsafe(` + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + `); + await sql.unsafe(CORE_STUBS); + await sql` + INSERT INTO "core_languages" ("code", "name", "default") VALUES + ('en', 'English', true), + ('pl', 'Polski', false) + `; + + for (const statement of migrationSql(EXAMPLE_MIGRATIONS).split( + "--> statement-breakpoint", + )) { + const trimmed = statement.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + + db = drizzle(sql, { casing: "camelCase" }); + rival = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + + const buildContext = (handle: ReturnType) => + ({ + get: (key: string) => { + if (key === "db") return handle; + if (key === "search") { + return { + delete: async ( + itemType: string, + itemId: number, + locale?: string, + ) => { + deleted.push({ itemId, itemType, locale }); + + return Promise.resolve(); + }, + index: async (document: SearchDocument) => { + indexed.push(document); + + return Promise.resolve(); + }, + }; + } + if (key === "events") { + return { emit: async () => Promise.resolve({ failures: [] }) }; + } + if (key === "log") { + return { error: async () => Promise.resolve() }; + } + if (key === "core") { + return { + contentModels: [ + { + model: advancedArticleContent, + pluginId: CONFIG_PLUGIN.pluginId, + }, + { model: categoryContent, pluginId: CONFIG_PLUGIN.pluginId }, + ], + i18n: { + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + }, + }; + } + + return undefined; + }, + }) as unknown as Context; + + context = buildContext(db); + rivalContext = buildContext(drizzle(rival, { casing: "camelCase" })); + }, 60_000); + + afterAll(async () => { + await sql?.end(); + await rival?.end(); + }); + + beforeEach(async () => { + await sql`DELETE FROM "example_advanced_articles"`; + await sql`DELETE FROM "core_content_revisions"`; + await sql`DELETE FROM "example_categories"`; + + const rows = await sql<{ id: number }[]>` + INSERT INTO "example_categories" ("name") + VALUES ('News'), ('Guides'), ('Releases') + RETURNING "id" + `; + categoryIds = rows.map(row => row.id); + indexed.length = 0; + deleted.length = 0; + }); + + // ------------------------------------------------------------------------- + // Constraints + // ------------------------------------------------------------------------- + + describe("generated constraints", () => { + it("refuses two junction rows for the same pair", async () => { + const article = await createArticle(); + + await sql` + INSERT INTO "example_advanced_articles_categories" + ("itemId", "relatedItemId", "position") + VALUES (${article.id}, ${categoryIds[0]}, 0) + `; + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "example_advanced_articles_categories" + ("itemId", "relatedItemId", "position") + VALUES (${article.id}, ${categoryIds[0]}, 1) + `, + ); + + expect(code).toBe("23505"); + }); + + it("refuses two junction rows in the same position", async () => { + const article = await createArticle(); + + await sql` + INSERT INTO "example_advanced_articles_categories" + ("itemId", "relatedItemId", "position") + VALUES (${article.id}, ${categoryIds[0]}, 0) + `; + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "example_advanced_articles_categories" + ("itemId", "relatedItemId", "position") + VALUES (${article.id}, ${categoryIds[1]}, 0) + `, + ); + + expect(code).toBe("23505"); + }); + + it("refuses a junction row pointing at nothing", async () => { + const article = await createArticle(); + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "example_advanced_articles_categories" + ("itemId", "relatedItemId", "position") + VALUES (${article.id}, 999999, 0) + `, + ); + + expect(code).toBe("23503"); + }); + + it("refuses to delete a category that is still related", async () => { + const article = await createArticle({ categories: [categoryIds[0]] }); + + const code = await pgErrorCode( + async () => + await sql`DELETE FROM "example_categories" WHERE "id" = ${categoryIds[0]}`, + ); + + // `onDelete: "restrict"` on the field, enforced by Postgres rather than by + // a check in service code that a direct DELETE would walk past. + expect(code).toBe(serverMajor >= 18 ? "23001" : "23503"); + // The code says *how* it was refused; this says the reference survived, + // which is what the constraint is actually for. + await expect( + service().relations.categories.get(article.id), + ).resolves.toStrictEqual([categoryIds[0]]); + }); + + it("takes the junction and child rows with the record", async () => { + const article = await createArticle({ + categories: [categoryIds[0]], + faq: [{ answer: "A", question: "Question?" }], + }); + + await sql`DELETE FROM "example_advanced_articles" WHERE "id" = ${article.id}`; + + const [junction] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "example_advanced_articles_categories" + `; + const [children] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "example_advanced_articles_faq" + `; + + expect(junction.count).toBe(0); + expect(children.count).toBe(0); + }); + + it("refuses two children in the same position", async () => { + const article = await createArticle({ + faq: [{ answer: "A", question: "Question?" }], + }); + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "example_advanced_articles_faq" + ("itemId", "position", "question", "answer") + VALUES (${article.id}, 0, 'Second', 'Also') + `, + ); + + expect(code).toBe("23505"); + }); + + it("scopes a localized slug to its language", async () => { + const localized = advancedArticleContent.localizedService?.(context, { + pluginId: CONFIG_PLUGIN.pluginId, + }); + if (!localized) throw new Error("no localized service"); + + await localized.create({ + shared: {}, + translation: { title: "Shared Title" }, + }); + + const second = await localized.create({ + shared: {}, + translation: { title: "Another" }, + }); + + const translations = advancedArticleContent.translationService?.(context); + if (!translations) throw new Error("no translation service"); + + // `/en/shared-title` already exists, so a second English one is a clash - + // while the same slug in Polish is not. + await expect( + translations.update( + second.row.id, + "en", + { slug: "shared-title" }, + { expectedVersion: 1 }, + ), + ).rejects.toThrow(); + + await expect( + translations.create(second.row.id, "pl", { + slug: "shared-title", + title: "Polski", + }), + ).resolves.toBeTruthy(); + }); + }); + + // ------------------------------------------------------------------------- + // Reads and writes + // ------------------------------------------------------------------------- + + describe("relations", () => { + it("stores an unordered set in ascending target order", async () => { + const article = await createArticle(); + + await service().relations.categories.set(article.id, [ + categoryIds[2], + categoryIds[0], + categoryIds[1], + ]); + + await expect( + service().relations.categories.get(article.id), + ).resolves.toStrictEqual([...categoryIds].sort((a, b) => a - b)); + }); + + it("keeps the author's order for an ordered relation", async () => { + const first = await createArticle(); + const second = await createArticle(); + const third = await createArticle(); + + await editorial()?.update( + first.id, + { relatedArticles: [third.id, second.id] }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + await expect( + service().relations.relatedArticles.get(first.id), + ).resolves.toStrictEqual([third.id, second.id]); + }); + + it("reorders without ever colliding on a position", async () => { + const article = await createArticle({ categories: categoryIds }); + const current = await service().relations.relatedArticles.get(article.id); + + expect(current).toStrictEqual([]); + + const others = [await createArticle(), await createArticle()]; + const [a, b] = others.map(row => row.id); + + const set = await editorial()?.update( + article.id, + { relatedArticles: [a, b] }, + { actor: ACTOR, expectedVersion: 1 }, + ); + if (!set) throw new Error("update returned nothing"); + + // The interesting direction: every row moves at once, which a naive + // per-row UPDATE would break against `UNIQUE (itemId, position)`. + await editorial()?.update( + article.id, + { relatedArticles: [b, a] }, + { actor: ACTOR, expectedVersion: set.version }, + ); + + await expect( + service().relations.relatedArticles.get(article.id), + ).resolves.toStrictEqual([b, a]); + }); + + it("refuses a target that does not exist", async () => { + const article = await createArticle(); + + await expect( + service().relations.categories.set(article.id, [999999]), + ).rejects.toBeInstanceOf(ContentAdvancedInputError); + }); + + it("treats a reorder to the current order as a no-op", async () => { + const article = await createArticle({ categories: categoryIds }); + const stored = await service().relations.categories.get(article.id); + + const result = await service().relations.categories.reorder( + article.id, + stored, + ); + + expect(result?.changedFields).toStrictEqual([]); + + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_advanced_articles" WHERE "id" = ${article.id} + `; + // No version bump, so no revision and no event either. + expect(row.version).toBe(1); + }); + + it("refuses a reorder that is not a permutation", async () => { + const article = await createArticle({ categories: categoryIds }); + + await expect( + service().relations.categories.reorder(article.id, [categoryIds[0]]), + ).rejects.toThrow(/exactly the target ids/); + }); + + it("filters by membership with an indexed EXISTS", async () => { + const matching = await createArticle({ categories: [categoryIds[1]] }); + await createArticle({ categories: [categoryIds[0]] }); + + const found = await service().findMany({ + filters: { categories: { contains: categoryIds[1] } }, + }); + + expect(found.edges.map(edge => edge.id)).toStrictEqual([matching.id]); + }); + }); + + describe("repeatables", () => { + it("gives every child a stable id that survives a reorder", async () => { + const article = await createArticle({ + faq: [ + { answer: "First answer", question: "First?" }, + { answer: "Second answer", question: "Second?" }, + ], + }); + + const before = await service().repeatable.faq.list(article.id); + const ids = before.map(row => row.id); + + await service().repeatable.faq.reorder(article.id, [ids[1], ids[0]]); + + const after = await service().repeatable.faq.list(article.id); + + expect(after.map(row => row.id)).toStrictEqual([ids[1], ids[0]]); + expect(after.map(row => row.question)).toStrictEqual([ + "Second?", + "First?", + ]); + }); + + it("replaces the whole list in one write", async () => { + const article = await createArticle({ + faq: [ + { answer: "A", question: "Keep?" }, + { answer: "B", question: "Drop?" }, + ], + }); + + const current = await service().repeatable.faq.list(article.id); + const keptId = current[0].id; + + const result = await service().repeatable.faq.set(article.id, [ + { answer: "A revised", id: keptId, question: "Keep?" }, + { answer: "C", question: "New?" }, + ]); + + expect(result?.changedFields).toStrictEqual(["faq"]); + + const after = await service().repeatable.faq.list(article.id); + + expect(after).toHaveLength(2); + // Identity preserved for the kept row, fresh for the new one. + expect(after[0].id).toBe(keptId); + expect(after[0].answer).toBe("A revised"); + expect(after[1].question).toBe("New?"); + expect(after[1].id).not.toBe(keptId); + }); + + it("refuses a child that belongs to another record", async () => { + const mine = await createArticle({ + faq: [{ answer: "A", question: "Question?" }], + }); + const theirs = await createArticle({ + faq: [{ answer: "B", question: "Rival?" }], + }); + const [stolen] = await service().repeatable.faq.list(theirs.id); + + await expect( + service().repeatable.faq.set(mine.id, [ + { answer: "A", id: stolen.id, question: "Question?" }, + ]), + ).rejects.toBeInstanceOf(ContentAdvancedInputError); + }); + + it("treats identical values, order and identities as a no-op", async () => { + const article = await createArticle({ + faq: [{ answer: "A", question: "Question?" }], + }); + const current = await service().repeatable.faq.list(article.id); + + const result = await service().repeatable.faq.set( + article.id, + current.map(row => ({ ...row })), + ); + + expect(result?.changedFields).toStrictEqual([]); + + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_advanced_articles" WHERE "id" = ${article.id} + `; + expect(row.version).toBe(1); + }); + + it("keeps positions contiguous from zero", async () => { + const article = await createArticle({ + faq: [ + { answer: "A", question: "One?" }, + { answer: "B", question: "Two?" }, + { answer: "C", question: "Three?" }, + ], + }); + + const rows = await sql<{ position: number }[]>` + SELECT "position" FROM "example_advanced_articles_faq" + WHERE "itemId" = ${article.id} ORDER BY "position" + `; + + expect(rows.map(row => row.position)).toStrictEqual([0, 1, 2]); + }); + }); + + describe("structured groups", () => { + it("moves one leaf without disturbing its neighbours", async () => { + const article = await createArticle({ + syndication: { indexable: false, priority: 9 }, + }); + + const result = await editorial()?.update( + article.id, + { syndication: { priority: 3 } }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + expect(result?.changedFields).toStrictEqual(["syndication.priority"]); + + const row = await service().findById(article.id); + + expect(row?.syndication).toStrictEqual({ + indexable: false, + priority: 3, + }); + }); + + it("stores the leaves as real, queryable columns", async () => { + await createArticle({ syndication: { priority: 8 } }); + await createArticle({ syndication: { priority: 2 } }); + + const rows = await sql<{ syndicationPriority: number }[]>` + SELECT "syndicationPriority" FROM "example_advanced_articles" + WHERE "syndicationPriority" > 5 + `; + + expect(rows).toHaveLength(1); + expect(rows[0].syndicationPriority).toBe(8); + }); + + it("reads a nullable localized group back as null when it is empty", async () => { + const localized = advancedArticleContent.localizedService?.(context, { + pluginId: CONFIG_PLUGIN.pluginId, + }); + if (!localized) throw new Error("no localized service"); + + const created = await localized.create({ + shared: {}, + translation: { title: "No SEO Here" }, + }); + + const translation = await advancedArticleContent + .translationService?.(context) + .findByLocale(created.row.id, "en"); + + expect(translation?.values.seo).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Concurrency + // ------------------------------------------------------------------------- + + describe("concurrency", () => { + it("lets exactly one of two racing relation writers win", async () => { + const article = await createArticle({ categories: [categoryIds[0]] }); + + const results = await Promise.allSettled([ + editorial()?.update( + article.id, + { categories: [categoryIds[1]] }, + { actor: ACTOR, expectedVersion: article.version }, + ), + editorial(rivalContext)?.update( + article.id, + { categories: [categoryIds[2]] }, + { actor: ACTOR, expectedVersion: article.version }, + ), + ]); + + const won = results.filter(result => result.status === "fulfilled"); + const lost = results.filter(result => result.status === "rejected"); + + expect(won).toHaveLength(1); + expect(lost).toHaveLength(1); + expect(lost[0].reason).toBeInstanceOf(ContentVersionConflict); + + // The loser wrote nothing at all: exactly one category, from one writer. + const stored = await service().relations.categories.get(article.id); + expect(stored).toHaveLength(1); + expect([categoryIds[1], categoryIds[2]]).toContain(stored[0]); + }); + + it("lets a relation write and a scalar write race for the same version", async () => { + const article = await createArticle(); + + const results = await Promise.allSettled([ + editorial()?.update( + article.id, + { categories: [categoryIds[0]] }, + { actor: ACTOR, expectedVersion: article.version }, + ), + editorial(rivalContext)?.update( + article.id, + { syndication: { priority: 1 } }, + { actor: ACTOR, expectedVersion: article.version }, + ), + ]); + + expect( + results.filter(result => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter(result => result.status === "rejected"), + ).toHaveLength(1); + + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_advanced_articles" WHERE "id" = ${article.id} + `; + // One increment, not two: a lost update is a lost update whichever kind + // of value it would have written. + expect(row.version).toBe(2); + }); + + it("lets exactly one of two racing repeatable writers win", async () => { + const article = await createArticle({ + faq: [{ answer: "A", question: "Question?" }], + }); + + const results = await Promise.allSettled([ + editorial()?.update( + article.id, + { faq: [{ answer: "Mine", question: "Mine?" }] }, + { actor: ACTOR, expectedVersion: 1 }, + ), + editorial(rivalContext)?.update( + article.id, + { faq: [{ answer: "Theirs", question: "Theirs?" }] }, + { actor: ACTOR, expectedVersion: 1 }, + ), + ]); + + expect( + results.filter(result => result.status === "fulfilled"), + ).toHaveLength(1); + + const stored = await service().repeatable.faq.list(article.id); + + // No partial child mutation survived: one writer's whole list, not a mix. + expect(stored).toHaveLength(1); + expect(["Mine?", "Theirs?"]).toContain(stored[0].question); + }); + + it("never leaves two children in the same position after racing reorders", async () => { + const article = await createArticle({ + faq: [ + { answer: "A", question: "One?" }, + { answer: "B", question: "Two?" }, + { answer: "C", question: "Three?" }, + ], + }); + // `list` now returns the repeatable's own child shape, so the rows go + // straight back into `update` with no coercion in between. + const stored = await service().repeatable.faq.list(article.id); + const at = (index: number) => stored[index]; + + await Promise.allSettled([ + editorial()?.update( + article.id, + { faq: [at(2), at(1), at(0)] }, + { actor: ACTOR, expectedVersion: 1 }, + ), + editorial(rivalContext)?.update( + article.id, + { faq: [at(1), at(0), at(2)] }, + { actor: ACTOR, expectedVersion: 1 }, + ), + ]); + + const rows = await sql<{ position: number }[]>` + SELECT "position" FROM "example_advanced_articles_faq" + WHERE "itemId" = ${article.id} ORDER BY "position" + `; + + expect(rows.map(row => row.position)).toStrictEqual([0, 1, 2]); + }); + + /** + * The plain service merges; the editorial service arbitrates. + * + * Both are correct, and they are different: the plain API has no version to + * guard on, so two additions to the same relation both survive. The + * editorial API guards, so one of two writers holding the same expected + * version is told it lost. A single API doing both depending on where it came + * from would be the thing nobody could reason about. + */ + describe("plain service serialises without losing an update", () => { + it("keeps both concurrent relation additions", async () => { + const article = await createArticle(); + + await Promise.all([ + service().relations.categories.add(article.id, categoryIds[0]), + service(rivalContext).relations.categories.add( + article.id, + categoryIds[1], + ), + ]); + + // The read used to happen before `update` took the row lock, so both + // writers computed their next list from the same empty one and the second + // overwrote the first. Now the lock comes first and the loser reads the + // winner's state. + await expect( + service().relations.categories.get(article.id), + ).resolves.toStrictEqual( + [categoryIds[0], categoryIds[1]].sort((a, b) => a - b), + ); + }); + + it("does not lose a removal against a concurrent addition", async () => { + const article = await createArticle({ categories: [categoryIds[0]] }); + + await Promise.all([ + service().relations.categories.remove(article.id, categoryIds[0]), + service(rivalContext).relations.categories.add( + article.id, + categoryIds[1], + ), + ]); + + const stored = await service().relations.categories.get(article.id); + + // Whichever order they ran in, the surviving state reflects both: the + // removal removed and the addition added. + expect(stored).toStrictEqual([categoryIds[1]]); + }); + + it("keeps both concurrently created repeatable children", async () => { + const article = await createArticle(); + + await Promise.all([ + service().repeatable.faq.create(article.id, { + answer: "First answer", + question: "First?", + }), + service(rivalContext).repeatable.faq.create(article.id, { + answer: "Second answer", + question: "Second?", + }), + ]); + + const children = await service().repeatable.faq.list(article.id); + + expect(children).toHaveLength(2); + expect(children.map(child => child.question).sort()).toStrictEqual([ + "First?", + "Second?", + ]); + // Contiguous from zero, and no duplicate slot survived. + const rows = await sql<{ position: number }[]>` + SELECT "position" FROM "example_advanced_articles_faq" + WHERE "itemId" = ${article.id} ORDER BY "position" + `; + expect(rows.map(row => row.position)).toStrictEqual([0, 1]); + }); + + it("does not lose a child edit against a concurrent creation", async () => { + const article = await createArticle({ + faq: [{ answer: "Original", question: "Kept?" }], + }); + const [existing] = await service().repeatable.faq.list(article.id); + + await Promise.all([ + service().repeatable.faq.update(article.id, existing.id, { + answer: "Edited", + }), + service(rivalContext).repeatable.faq.create(article.id, { + answer: "Added answer", + question: "Added?", + }), + ]); + + const children = await service().repeatable.faq.list(article.id); + + expect(children).toHaveLength(2); + // The edit survived *and* kept its identity, and the creation survived. + const kept = children.find(child => child.id === existing.id); + expect(kept?.answer).toBe("Edited"); + expect(children.some(child => child.question === "Added?")).toBe(true); + }); + + it("keeps positions contiguous under concurrent reorders", async () => { + const article = await createArticle({ + faq: [ + { answer: "A", question: "One?" }, + { answer: "B", question: "Two?" }, + { answer: "C", question: "Three?" }, + ], + }); + const ids = (await service().repeatable.faq.list(article.id)).map( + child => child.id, + ); + + await Promise.all([ + service().repeatable.faq.reorder(article.id, [ + ids[2], + ids[1], + ids[0], + ]), + service(rivalContext).repeatable.faq.reorder(article.id, [ + ids[1], + ids[0], + ids[2], + ]), + ]); + + const rows = await sql<{ id: number; position: number }[]>` + SELECT "id", "position" FROM "example_advanced_articles_faq" + WHERE "itemId" = ${article.id} ORDER BY "position" + `; + + expect(rows.map(row => row.position)).toStrictEqual([0, 1, 2]); + // Stable identity: a reorder never recreates a child. + expect(rows.map(row => row.id).sort((a, b) => a - b)).toStrictEqual( + [...ids].sort((a, b) => a - b), + ); + }); + }); + + describe("editorial service arbitrates instead of merging", () => { + const editorialFor = (target: Context = context) => { + const value = editorial(target); + if (!value) throw new Error("no editorial service"); + + return value; + }; + + it("lets exactly one of two racing `add` calls win", async () => { + const article = await createArticle(); + + const results = await Promise.allSettled([ + editorialFor().relations.categories.add(article.id, categoryIds[0], { + actor: ACTOR, + expectedVersion: article.version, + }), + editorialFor(rivalContext).relations.categories.add( + article.id, + categoryIds[1], + { actor: ACTOR, expectedVersion: article.version }, + ), + ]); + + expect( + results.filter(result => result.status === "fulfilled"), + ).toHaveLength(1); + const lost = results.filter( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ); + expect(lost).toHaveLength(1); + expect(lost[0].reason).toBeInstanceOf(ContentVersionConflict); + + // Exactly one addition, exactly one version increment, and no silent + // retry that would have overwritten the winner. + const stored = await service().relations.categories.get(article.id); + expect(stored).toHaveLength(1); + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_advanced_articles" + WHERE "id" = ${article.id} + `; + expect(row.version).toBe(2); + }); + + it("writes exactly one revision per real collection mutation", async () => { + const article = await createArticle(); + + const outcome = await editorialFor().relations.categories.add( + article.id, + categoryIds[0], + { actor: ACTOR, expectedVersion: article.version }, + ); + + expect(outcome?.changed).toBe(true); + expect(outcome?.changedFields).toStrictEqual(["categories"]); + expect(outcome?.revisionId).not.toBeNull(); + + const [revisions] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_revisions" + WHERE "itemId" = ${article.id} AND "operation" = 'update' + `; + expect(revisions.count).toBe(1); + }); + + it("writes no revision for a no-op collection mutation", async () => { + const article = await createArticle({ categories: [categoryIds[0]] }); + const before = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_revisions" + WHERE "itemId" = ${article.id} + `; + + // Adding a target that is already there computes the list that is + // already stored, so the diff finds nothing. + const outcome = await editorialFor().relations.categories.add( + article.id, + categoryIds[0], + { actor: ACTOR, expectedVersion: 1 }, + ); + + expect(outcome?.changed).toBe(false); + expect(outcome?.changedFields).toStrictEqual([]); + expect(outcome?.revisionId).toBeNull(); + + const after = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_revisions" + WHERE "itemId" = ${article.id} + `; + expect(after[0].count).toBe(before[0].count); + + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_advanced_articles" + WHERE "id" = ${article.id} + `; + expect(row.version).toBe(1); + }); + + it("refuses a collection mutation with no expected version", async () => { + const article = await createArticle(); + + await expect( + editorialFor().relations.categories.add(article.id, categoryIds[0], { + actor: ACTOR, + } as never), + ).rejects.toThrow(/needs `\{ actor, expectedVersion \}`/); + }); + }); + + it("keeps two different records independent", async () => { + const first = await createArticle(); + const second = await createArticle(); + + const results = await Promise.all([ + editorial()?.update( + first.id, + { categories: [categoryIds[0]] }, + { actor: ACTOR, expectedVersion: first.version }, + ), + editorial(rivalContext)?.update( + second.id, + { categories: [categoryIds[1]] }, + { actor: ACTOR, expectedVersion: second.version }, + ), + ]); + + expect(results.every(result => result?.changed)).toBe(true); + await expect( + service().relations.categories.get(first.id), + ).resolves.toStrictEqual([categoryIds[0]]); + await expect( + service().relations.categories.get(second.id), + ).resolves.toStrictEqual([categoryIds[1]]); + }); + }); + + // ------------------------------------------------------------------------- + // Search + // ------------------------------------------------------------------------- + + describe("localized search", () => { + const localized = () => { + const value = advancedArticleContent.localizedService?.(context, { + pluginId: CONFIG_PLUGIN.pluginId, + }); + if (!value) throw new Error("no localized service"); + + return value; + }; + + const translationEditorial = () => { + const value = advancedArticleContent.translationEditorialService?.( + context, + { pluginId: CONFIG_PLUGIN.pluginId }, + ); + if (!value) throw new Error("no translation editorial service"); + + return value; + }; + + /** + * One published record, in two languages, with a shared FAQ. + * + * The FAQ is the point: it is shared, so both documents are built from it - + * and every path that builds one has to load it or the two disagree. + */ + const publishedInTwoLocales = async (title: string) => { + const created = await localized().create({ + shared: { + faq: [ + { answer: "First answer", question: "First question?" }, + { answer: "Second answer", question: "Second question?" }, + ], + syndication: { indexable: true, priority: 5 }, + }, + translation: { + seo: { description: `${title} EN description`, title: `${title} EN` }, + title, + }, + }); + const itemId = created.row.id; + + await translations().create(itemId, "pl", { + seo: { description: `${title} PL opis`, title: `${title} PL` }, + title: `${title} PL`, + }); + + await editorial()?.publish(itemId, { actor: ACTOR }); + await translationEditorial().publish(itemId, "en", { actor: ACTOR }); + await translationEditorial().publish(itemId, "pl", { actor: ACTOR }); + + return itemId; + }; + + const translations = () => { + const value = advancedArticleContent.translationService?.(context); + if (!value) throw new Error("no translation service"); + + return value; + }; + + /** Every document the rebuild produces, in one pass. */ + const rebuild = async (): Promise => { + const indexer = createContentLocalizedSearchIndexer( + advancedArticleContent, + { pluginId: CONFIG_PLUGIN.pluginId }, + ); + const documents: SearchDocument[] = []; + let offset = 0; + + for (;;) { + const page = await indexer.load(context, offset, 10); + documents.push(...page.documents); + if (page.itemsRead === 0) break; + offset += page.itemsRead; + } + + return documents; + }; + + /** Re-runs live synchronization for a record and returns what it wrote. */ + const liveSync = async (itemId: number): Promise => { + indexed.length = 0; + const row = await advancedArticleContent + .service(context) + .findById(itemId); + if (!row) throw new Error("no row"); + + await syncContentLocalizedSearch(context, advancedArticleContent, { + advanced: await advancedArticleContent + .service(context) + .advancedFields(itemId, ["faq"]), + operation: "publish", + changed: true, + pluginId: CONFIG_PLUGIN.pluginId, + row, + }); + + return [...indexed]; + }; + + const byUrl = (documents: readonly SearchDocument[]) => + [...documents].sort((a, b) => (a.url ?? "").localeCompare(b.url ?? "")); + + it("indexes repeatable text in every published locale", async () => { + const itemId = await publishedInTwoLocales("Repeatable Live"); + const live = byUrl(await liveSync(itemId)); + + expect(live).toHaveLength(2); + for (const document of live) { + // The bug: `syncContentLocalizedSearch` was handed the base row only, so + // a document made of `faq.question` and `faq.answer` contained neither. + expect(document.content).toContain("First question?"); + expect(document.content).toContain("Second answer"); + } + }); + + it("indexes repeatable values in position order", async () => { + const itemId = await publishedInTwoLocales("Repeatable Order"); + const [document] = byUrl(await liveSync(itemId)); + + expect(document.content.indexOf("First question?")).toBeLessThan( + document.content.indexOf("Second question?"), + ); + }); + + it("reproduces the live documents on a rebuild", async () => { + const itemId = await publishedInTwoLocales("Rebuild Parity"); + const live = byUrl(await liveSync(itemId)); + const rebuilt = byUrl(await rebuild()); + + // The whole invariant: a rebuild has to produce the document live + // synchronization already wrote. The rebuild classified `seo.description` + // and `faq.*` by looking them up in the top-level field maps, found + // neither, and silently omitted both. + expect(rebuilt).toStrictEqual(live); + }); + + it("writes one document per published translation", async () => { + await publishedInTwoLocales("One Per Locale"); + const rebuilt = byUrl(await rebuild()); + + expect(rebuilt.map(document => document.languageCode)).toStrictEqual([ + "en", + "pl", + ]); + expect(rebuilt.map(document => document.url)).toStrictEqual([ + expect.stringContaining("/en/"), + expect.stringContaining("/pl/"), + ]); + }); + + it("does not index a draft translation", async () => { + const created = await localized().create({ + shared: { faq: [{ answer: "A", question: "Draft question?" }] }, + translation: { seo: null, title: "Draft Locale" }, + }); + await translations().create(created.row.id, "pl", { + seo: null, + title: "Draft Locale PL", + }); + await editorial()?.publish(created.row.id, { actor: ACTOR }); + // English published, Polish left a draft. + await translationEditorial().publish(created.row.id, "en", { + actor: ACTOR, + }); + + const rebuilt = await rebuild(); + + expect(rebuilt).toHaveLength(1); + expect(rebuilt[0].languageCode).toBe("en"); + }); + + it("indexes nothing while the record itself is a draft", async () => { + const created = await localized().create({ + shared: { faq: [{ answer: "A", question: "Hidden question?" }] }, + translation: { seo: null, title: "Draft Record" }, + }); + // The translation is published but the record is not: visibility is + // subordinate, so neither is readable and neither is indexed. + await translationEditorial().publish(created.row.id, "en", { + actor: ACTOR, + }); + + await expect(rebuild()).resolves.toStrictEqual([]); + }); + + it("keeps repeatable text when only a localized leaf changes", async () => { + const itemId = await publishedInTwoLocales("Translation Rewrite"); + indexed.length = 0; + + const outcome = await translationEditorial().update( + itemId, + "pl", + { seo: { description: "Nowy opis" } }, + { actor: ACTOR, expectedVersion: 2 }, + ); + await contentTranslationEffects( + context, + advancedArticleContentType, + outcome as never, + { model: advancedArticleContent, pluginId: CONFIG_PLUGIN.pluginId }, + ); + + // One locale rewritten... + expect(indexed).toHaveLength(1); + expect(indexed[0].languageCode).toBe("pl"); + expect(indexed[0].content).toContain("Nowy opis"); + // ...and its FAQ still in it. Without the collections the rewrite would + // have replaced a complete document with one missing every answer. + expect(indexed[0].content).toContain("First question?"); + expect(indexed[0].content).toContain("Second answer"); + }); + + it("rewrites every locale when the shared FAQ changes", async () => { + const itemId = await publishedInTwoLocales("Shared Rewrite"); + indexed.length = 0; + + const [row] = await sql<{ version: number }[]>` + SELECT "version" FROM "example_advanced_articles" WHERE "id" = ${itemId} + `; + await editorial()?.update( + itemId, + { faq: [{ answer: "Rewritten answer", question: "Rewritten?" }] }, + { actor: ACTOR, expectedVersion: row.version }, + ); + + const live = byUrl(await liveSync(itemId)); + + expect(live).toHaveLength(2); + for (const document of live) { + expect(document.content).toContain("Rewritten?"); + expect(document.content).not.toContain("First question?"); + } + + // And the rebuild still agrees. + expect(byUrl(await rebuild())).toStrictEqual(live); + }); + + it("removes a locale's document when its translation is unpublished", async () => { + const itemId = await publishedInTwoLocales("Unpublish Locale"); + deleted.length = 0; + + const outcome = await translationEditorial().unpublish(itemId, "pl", { + actor: ACTOR, + }); + await contentTranslationEffects( + context, + advancedArticleContentType, + outcome as never, + { model: advancedArticleContent, pluginId: CONFIG_PLUGIN.pluginId }, + ); + + // Scoped to the one language: taking the Polish copy down must leave the + // English document exactly where it is. + expect(deleted).toStrictEqual([ + { + itemId, + itemType: "example.advanced-article", + locale: "pl", + }, + ]); + expect( + byUrl(await rebuild()).map(document => document.languageCode), + ).toStrictEqual(["en"]); + }); + }); + + // ------------------------------------------------------------------------- + // Revisions and restore + // ------------------------------------------------------------------------- + + describe("revisions and restore", () => { + it("records relation identity and repeatable children, never expanded rows", async () => { + const article = await createArticle({ + categories: [categoryIds[0], categoryIds[1]], + faq: [{ answer: "A", question: "Question?" }], + }); + + const latest = await editorial()?.revisions.latest(article.id); + if (!latest) throw new Error("no revision"); + + const revision = await editorial()?.revisions.findById( + article.id, + latest.id, + ); + const snapshot = revision?.snapshot as unknown as { + fields: Record; + }; + + expect(snapshot.fields.categories).toStrictEqual( + [categoryIds[0], categoryIds[1]].sort((a, b) => a - b), + ); + expect(snapshot.fields.faq).toStrictEqual([ + { answer: "A", id: expect.any(Number), question: "Question?" }, + ]); + // Nested, never the flattened column names. + expect(snapshot.fields.syndication).toStrictEqual({ + indexable: true, + priority: 5, + }); + }); + + it("restores an ordered relation to its historical order", async () => { + const article = await createArticle(); + const others = [await createArticle(), await createArticle()]; + const [a, b] = others.map(row => row.id); + + const first = await editorial()?.update( + article.id, + { relatedArticles: [a, b] }, + { actor: ACTOR, expectedVersion: 1 }, + ); + if (!first) throw new Error("update returned nothing"); + + const target = first.revisionId; + if (target === null) throw new Error("no revision"); + + const second = await editorial()?.update( + article.id, + { relatedArticles: [b, a] }, + { actor: ACTOR, expectedVersion: first.version }, + ); + if (!second) throw new Error("update returned nothing"); + + await editorial()?.restore(article.id, target, { + actor: ACTOR, + expectedVersion: second.version, + }); + + await expect( + service().relations.relatedArticles.get(article.id), + ).resolves.toStrictEqual([a, b]); + }); + + it("recreates a repeatable child that was removed since", async () => { + const article = await createArticle({ + faq: [ + { answer: "A", question: "One?" }, + { answer: "B", question: "Two?" }, + ], + }); + const original = await editorial()?.revisions.latest(article.id); + if (!original) throw new Error("no revision"); + + const removed = await editorial()?.update( + article.id, + { faq: [] }, + { actor: ACTOR, expectedVersion: 1 }, + ); + if (!removed) throw new Error("update returned nothing"); + + await expect( + service().repeatable.faq.list(article.id), + ).resolves.toStrictEqual([]); + + await editorial()?.restore(article.id, original.id, { + actor: ACTOR, + expectedVersion: removed.version, + }); + + const restored = await service().repeatable.faq.list(article.id); + + expect(restored.map(row => row.question)).toStrictEqual(["One?", "Two?"]); + // Recreated rather than matched: the ids are gone, the values are not. + expect(restored.every(row => typeof row.id === "number")).toBe(true); + }); + + it("refuses to restore a relation whose target is gone", async () => { + const article = await createArticle({ categories: [categoryIds[0]] }); + const original = await editorial()?.revisions.latest(article.id); + if (!original) throw new Error("no revision"); + + const cleared = await editorial()?.update( + article.id, + { categories: [] }, + { actor: ACTOR, expectedVersion: 1 }, + ); + if (!cleared) throw new Error("update returned nothing"); + + await sql`DELETE FROM "example_categories" WHERE "id" = ${categoryIds[0]}`; + + await expect( + editorial()?.restore(article.id, original.id, { + actor: ACTOR, + expectedVersion: cleared.version, + }), + ).rejects.toBeInstanceOf(ContentRevisionNotRestorable); + + // Nothing was partially applied. + await expect( + service().relations.categories.get(article.id), + ).resolves.toStrictEqual([]); + }); + + it("restores a nested group leaf without touching its neighbour", async () => { + const article = await createArticle({ + syndication: { indexable: true, priority: 7 }, + }); + const original = await editorial()?.revisions.latest(article.id); + if (!original) throw new Error("no revision"); + + const changed = await editorial()?.update( + article.id, + { syndication: { indexable: false, priority: 1 } }, + { actor: ACTOR, expectedVersion: 1 }, + ); + if (!changed) throw new Error("update returned nothing"); + + await editorial()?.restore(article.id, original.id, { + actor: ACTOR, + expectedVersion: changed.version, + }); + + const row = await service().findById(article.id); + + expect(row?.syndication).toStrictEqual({ + indexable: true, + priority: 7, + }); + }); + }); + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + describe("public projection", () => { + const publish = async (id: number, version: number) => + await editorial()?.publish(id, { + actor: ACTOR, + expectedVersion: version, + }); + + it("exposes the named leaves and nothing else", async () => { + const localized = advancedArticleContent.localizedService?.(context, { + pluginId: CONFIG_PLUGIN.pluginId, + }); + if (!localized) throw new Error("no localized service"); + + const created = await localized.create({ + shared: { + categories: [categoryIds[0]], + faq: [{ answer: "Public answer", question: "Public question?" }], + syndication: { indexable: false, priority: 4 }, + }, + translation: { + seo: { description: "Public description", title: "Public SEO" }, + title: "Public Article", + }, + }); + + // `localizedService.create` is typed against the erased definition, so + // its row comes back widened - narrowed here rather than at every use. + const itemId = created.row.id; + + await publish(itemId, created.row.version); + await advancedArticleContent + .translationEditorialService?.(context, { + pluginId: CONFIG_PLUGIN.pluginId, + }) + .publish(itemId, "en", { actor: ACTOR }); + + const row = await advancedArticleContent + .publicService?.(context) + .findById(itemId, { locale: "en" }); + + expect(row).toMatchObject({ + categories: [categoryIds[0]], + faq: [{ answer: "Public answer", question: "Public question?" }], + locale: "en", + seo: { description: "Public description", title: "Public SEO" }, + syndication: { priority: 4 }, + title: "Public Article", + }); + + // The private leaf is absent from the response *and* from the SELECT. + expect( + (row as unknown as { syndication: Record }) + .syndication, + ).not.toHaveProperty("indexable"); + // A private collection is absent altogether. + expect(row).not.toHaveProperty("relatedArticles"); + }); + }); +}); diff --git a/plugins/example/src/database/advanced-routes.test.ts b/plugins/example/src/database/advanced-routes.test.ts new file mode 100644 index 000000000..9c0782d4b --- /dev/null +++ b/plugins/example/src/database/advanced-routes.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import type { Context, MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { + buildContentPublicRoutes, + buildContentRoutes, +} from "@vitnode/core/content/server"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { advancedArticleContent } from "./advanced-articles"; + +/** + * The generated routes for a content type with every advanced shape on it. + * + * Registration is the assertion: `OpenAPIHono` walks each route's request and + * response schemas and converts them to JSON Schema when it mounts them, so a + * nested group or a repeatable array that Zod could not describe would throw + * here rather than at the first request. The form schema is checked the same + * way, because `AutoForm` runs `z.toJSONSchema` on it in the browser - and Zod + * v4 throws on a `z.date()` anywhere inside. + */ +describe("advanced article: generated routes", () => { + const mount = ( + routes: readonly { + handler: Parameters[1]; + route: Parameters[0]; + }[], + ): OpenAPIHono => { + const app = new OpenAPIHono(); + const context: MiddlewareHandler = async (c, next) => { + c.set("admin", null); + await next(); + }; + app.use("*", context); + + for (const { handler, route } of routes) app.openapi(route, handler); + + return app; + }; + + it("mounts the admin routes", () => { + expect(() => + mount( + buildContentRoutes(advancedArticleContent, { + pluginId: CONFIG_PLUGIN.pluginId, + }), + ), + ).not.toThrow(); + }); + + it("mounts the public routes", () => { + expect(() => + mount( + buildContentPublicRoutes(advancedArticleContent, { + pluginId: CONFIG_PLUGIN.pluginId, + }), + ), + ).not.toThrow(); + }); + + it("describes the whole OpenAPI document", () => { + const app = mount( + buildContentRoutes(advancedArticleContent, { + pluginId: CONFIG_PLUGIN.pluginId, + }), + ); + + const document = app.getOpenAPI31Document({ + info: { title: "test", version: "1" }, + openapi: "3.1.0", + }); + + expect(Object.keys(document.paths ?? {}).length).toBeGreaterThan(0); + }); + + it("converts the AutoForm schema to JSON Schema", () => { + // A group is a nested object and a repeatable is an array of objects - + // both have to survive the conversion `AutoForm` performs on every render. + const json = z.toJSONSchema(advancedArticleContent.schemas.form) as { + properties: Record; + }; + + expect(json.properties.syndication.type).toBe("object"); + expect(json.properties.faq.type).toBe("array"); + expect(json.properties.faq.items).toBeTruthy(); + expect(json.properties.categories.type).toBe("array"); + }); + + it("shapes the public response from the allowlist and nothing else", () => { + // Read off the Zod object rather than a JSON Schema: a response schema + // carries `z.date()` for `publishedAt`, which `z.toJSONSchema` refuses - + // Hono serializes it, and the browser never sees this schema. + const shape = advancedArticleContent.schemas.publicSelectObject.shape; + + expect(Object.keys(shape).sort()).toStrictEqual([ + "categories", + "faq", + "locale", + "publishedAt", + "seo", + "slug", + "syndication", + "title", + ]); + // A private collection is absent from the contract as well as from the + // response - and `syndication` carries only the leaf that was exposed. + expect(shape.relatedArticles).toBeUndefined(); + expect( + Object.keys( + (shape.syndication as unknown as { shape: Record }) + .shape, + ), + ).toStrictEqual(["priority"]); + }); +}); diff --git a/plugins/example/src/database/advanced-tables.test.ts b/plugins/example/src/database/advanced-tables.test.ts new file mode 100644 index 000000000..5e06c8774 --- /dev/null +++ b/plugins/example/src/database/advanced-tables.test.ts @@ -0,0 +1,196 @@ +import { getTableName } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { + example_advanced_articles, + example_advanced_articles_categories, + example_advanced_articles_faq, + example_advanced_articles_related_articles, + example_advanced_articles_translations, +} from "./advanced-articles"; + +/** + * What Stage 6 actually generates, read off Drizzle's own table metadata. + * + * The same shape of test `tables.test.ts` runs for the base and translation + * tables, and for the same reason: the migration is generated from these + * objects, so an assertion here is an assertion about the SQL - one that fails + * at `pnpm test` rather than at `drizzle-kit generate` three commits later. + */ + +const base = getTableConfig(example_advanced_articles); +const junction = getTableConfig(example_advanced_articles_categories); +const selfJunction = getTableConfig(example_advanced_articles_related_articles); +const faq = getTableConfig(example_advanced_articles_faq); + +const translations = (() => { + if (!example_advanced_articles_translations) { + throw new Error("example.advanced-article generated no translation table."); + } + + return getTableConfig(example_advanced_articles_translations); +})(); + +const columnNames = (config: { columns: { name: string }[] }): string[] => + config.columns.map(column => column.name).sort(); + +const indexNames = (config: { + indexes: { config: { name?: string } }[]; +}): string[] => config.indexes.map(item => item.config.name ?? "").sort(); + +describe("advanced article: generated tables", () => { + it("names the collection tables after the field", () => { + expect(getTableName(example_advanced_articles_categories)).toBe( + "example_advanced_articles_categories", + ); + expect(getTableName(example_advanced_articles_related_articles)).toBe( + "example_advanced_articles_related_articles", + ); + expect(getTableName(example_advanced_articles_faq)).toBe( + "example_advanced_articles_faq", + ); + }); + + it("flattens a shared group into columns on the base table", () => { + expect(columnNames(base)).toContain("syndicationIndexable"); + expect(columnNames(base)).toContain("syndicationPriority"); + // The group itself is not a column, and neither collection is either. + expect(columnNames(base)).not.toContain("syndication"); + expect(columnNames(base)).not.toContain("categories"); + expect(columnNames(base)).not.toContain("faq"); + }); + + it("flattens a localized group onto the translation table", () => { + expect(columnNames(translations)).toContain("seoTitle"); + expect(columnNames(translations)).toContain("seoDescription"); + // A localized group moves whole: neither leaf stays behind on the base row. + expect(columnNames(base)).not.toContain("seoTitle"); + expect(columnNames(base)).not.toContain("seoDescription"); + }); + + it("indexes a group leaf declared by its canonical path", () => { + expect(indexNames(base)).toContain( + "example_advanced_articles_syndication_priority_idx", + ); + }); + + describe("junction table", () => { + it("carries exactly the four generated columns", () => { + expect(columnNames(junction)).toStrictEqual([ + "createdAt", + "itemId", + "position", + "relatedItemId", + ]); + }); + + it("keys on the pair, so one target cannot be related twice", () => { + const [primaryKey] = junction.primaryKeys; + + expect(primaryKey.name).toBe("example_advanced_articles_categories_pk"); + expect(primaryKey.columns.map(column => column.name)).toStrictEqual([ + "itemId", + "relatedItemId", + ]); + }); + + it("makes duplicate positions impossible", () => { + const unique = junction.indexes.find(item => item.config.unique); + + expect(unique?.config.name).toBe( + "example_advanced_articles_categories_position_key", + ); + expect( + unique?.config.columns.map(column => + "name" in column ? column.name : "", + ), + ).toStrictEqual(["itemId", "position"]); + }); + + it("indexes the reverse direction, which ON DELETE RESTRICT reads", () => { + expect(indexNames(junction)).toContain( + "example_advanced_articles_categories_related_item_id_idx", + ); + }); + + it("cascades from the source and restricts from the target", () => { + const fromItem = junction.foreignKeys.find(key => + key.reference().columns.some(column => column.name === "itemId"), + ); + const fromTarget = junction.foreignKeys.find(key => + key.reference().columns.some(column => column.name === "relatedItemId"), + ); + + expect(fromItem?.onDelete).toBe("cascade"); + // The field declares `onDelete: "restrict"`, so Postgres refuses to + // delete a category that is still in use - no service check required. + expect(fromTarget?.onDelete).toBe("restrict"); + }); + }); + + describe("self-relation junction", () => { + it("points both foreign keys at the same table", () => { + const targets = selfJunction.foreignKeys.map(key => + getTableName(key.reference().foreignTable), + ); + + expect(new Set(targets)).toStrictEqual( + new Set(["example_advanced_articles"]), + ); + }); + + it("does not collide with the other junction", () => { + expect(getTableName(example_advanced_articles_related_articles)).not.toBe( + getTableName(example_advanced_articles_categories), + ); + }); + }); + + describe("repeatable child table", () => { + it("gives every child a stable identity of its own", () => { + const id = faq.columns.find(column => column.name === "id"); + + expect(id?.primary).toBe(true); + // Not `(item_id, position)`: position is where a child sits, identity is + // what an edit addresses and what a restore matches against. + expect(faq.primaryKeys).toHaveLength(0); + }); + + it("carries the leaf columns with their declared types", () => { + expect(columnNames(faq)).toStrictEqual([ + "answer", + "createdAt", + "id", + "itemId", + "position", + "question", + "updatedAt", + ]); + + const question = faq.columns.find(column => column.name === "question"); + const answer = faq.columns.find(column => column.name === "answer"); + + expect(question?.getSQLType()).toBe("varchar(200)"); + expect(answer?.getSQLType()).toBe("text"); + expect(question?.notNull).toBe(true); + }); + + it("makes duplicate positions impossible", () => { + const unique = faq.indexes.find(item => item.config.unique); + + expect(unique?.config.name).toBe( + "example_advanced_articles_faq_position_key", + ); + }); + + it("goes away with the record it belongs to", () => { + const [foreignKey] = faq.foreignKeys; + + expect(foreignKey.onDelete).toBe("cascade"); + expect(getTableName(foreignKey.reference().foreignTable)).toBe( + "example_advanced_articles", + ); + }); + }); +}); diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index ed362b710..7413ebd4f 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -3476,6 +3476,173 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { [1], ); }); + + /** + * The recreate above, run as a race on two connections. + * + * Both writers read the same `latest()` inside their own transaction and + * both compute the same next version, so the only thing standing between + * them and two rows is the primary key. `create` targets its + * `onConflictDoNothing` at `(itemId, languageId)` alone, which is what + * turns the loser into a named `ContentTranslationExists` instead of a + * `23505` a translator cannot act on - and what leaves a *slug* clash still + * reported as the unique violation it is. + */ + it("lets exactly one of two concurrent recreates claim the version", async () => { + const itemId = await guide("Recreated Concurrently"); + + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski Wyscig" }, + { actor: ACTOR }, + ); + await editorial().delete(itemId, "pl", { + actor: ACTOR, + expectedVersion: 1, + }); + + const outcomes = await Promise.allSettled([ + editorial().create( + itemId, + "pl", + { body: "Znowu A", title: "Polski Wyscig A" }, + { actor: ACTOR }, + ), + editorial(rivalContext).create( + itemId, + "pl", + { body: "Znowu B", title: "Polski Wyscig B" }, + { actor: ACTOR }, + ), + ]); + + const fulfilled = outcomes.filter( + outcome => outcome.status === "fulfilled", + ); + const rejected = outcomes.filter( + outcome => outcome.status === "rejected", + ); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + // Structured, and named for what actually happened - not the driver's + // constraint message. + expect(rejected[0]).toMatchObject({ + reason: expect.objectContaining({ + itemId, + locale: "pl", + name: "ContentTranslationExists", + }), + }); + + // One row, at the version the delete left room for. + const polish = (await rowsFor(itemId)).filter( + row => row.languageId === 2, + ); + expect(polish).toHaveLength(1); + expect(polish[0].version).toBe(3); + + // The loser's transaction took its revision down with it, so the history + // is still strictly increasing with no duplicate at 3. + expect( + (await revisionsFor(itemId, 2)).map(row => [ + row.version, + row.operation, + ]), + ).toEqual([ + [1, "create"], + [2, "delete"], + [3, "create"], + ]); + }); + + /** + * A delete and an update that both think the row is at the same version. + * + * Exactly one of them may take effect, and the loser has two legitimate + * answers depending on which order they land in - so the assertion is on + * the *pair*, not on either one: + * + * - the delete lands first, and the update finds no translation at all, + * which is a `null` the route turns into a 404 rather than a conflict; + * - the update lands first, and the delete re-reads after its zero-row + * statement, finds the row at 2 and reports a version conflict. + * + * What must never happen is both taking effect: a delete reporting that it + * removed a row the update had just moved would be a lost update, and an + * update writing over a row the delete had removed would resurrect it. + */ + it("never lets a delete and a stale update resurrect or lose a translation", async () => { + const itemId = await guide("Delete Versus Update"); + + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski Kontra" }, + { actor: ACTOR }, + ); + + const [deletion, revision] = await Promise.allSettled([ + editorial().delete(itemId, "pl", { + actor: ACTOR, + expectedVersion: 1, + }), + editorial(rivalContext).update( + itemId, + "pl", + { title: "Polski Kontra Nowy" }, + { actor: ACTOR, expectedVersion: 1 }, + ), + ]); + + const polish = (await rowsFor(itemId)).filter( + row => row.languageId === 2, + ); + const history = await revisionsFor(itemId, 2); + const deleteWon = polish.length === 0; + + if (deleteWon) { + // The update found nothing to update. `null`, not a conflict: there is + // no version to disagree about once the row is gone. + expect(deletion).toMatchObject({ + status: "fulfilled", + value: expect.objectContaining({ version: 2 }), + }); + expect(revision).toStrictEqual({ + status: "fulfilled", + value: null, + }); + expect(history.map(row => [row.version, row.operation])).toEqual([ + [1, "create"], + [2, "delete"], + ]); + + return; + } + + // The update won, so the delete's `expectedVersion: 1` no longer matches + // a row that is sitting at 2 - and it says so rather than reporting a + // removal that never happened. + expect(revision).toMatchObject({ + status: "fulfilled", + value: expect.objectContaining({ changed: true, version: 2 }), + }); + expect(deletion).toMatchObject({ + reason: expect.objectContaining({ + currentVersion: 2, + expectedVersion: 1, + locale: "pl", + name: "ContentTranslationVersionConflict", + }), + status: "rejected", + }); + expect(polish[0].version).toBe(2); + expect(history.map(row => [row.version, row.operation])).toEqual([ + [1, "create"], + [2, "update"], + ]); + }); }); /** diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index 6bc55b062..0e379dc0f 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -7,6 +7,13 @@ import { describe, expect, it } from "vitest"; import { EXAMPLE_MIGRATIONS } from "@/const"; +import { + example_advanced_articles, + example_advanced_articles_categories, + example_advanced_articles_faq, + example_advanced_articles_related_articles, + example_advanced_articles_translations, +} from "./advanced-articles"; import { example_articles } from "./articles"; import { example_categories } from "./categories"; import { @@ -32,6 +39,21 @@ const localizedTranslationTable = (() => { })(); const localizedTranslations = getTableConfig(localizedTranslationTable); +const advancedArticles = getTableConfig(example_advanced_articles); +const advancedCategories = getTableConfig(example_advanced_articles_categories); +const advancedRelated = getTableConfig( + example_advanced_articles_related_articles, +); +const advancedFaq = getTableConfig(example_advanced_articles_faq); + +const advancedTranslations = (() => { + if (!example_advanced_articles_translations) { + throw new Error("example.advanced-article generated no translation table."); + } + + return getTableConfig(example_advanced_articles_translations); +})(); + const indexNames = (config: typeof articles) => config.indexes.map(item => item.config.name); @@ -232,6 +254,15 @@ describe("the generated migration", () => { ...indexNames(categories), ...indexNames(localizedArticles), ...indexNames(localizedTranslations), + // Stage 6: the base table, its translations, the two junctions and the + // repeatable child table. Every one of them is generated from a + // resolved definition entry, so the migration and the definition agree + // here for exactly the reason the four above do. + ...indexNames(advancedArticles), + ...indexNames(advancedTranslations), + ...indexNames(advancedCategories), + ...indexNames(advancedRelated), + ...indexNames(advancedFaq), ].sort(byName), ); }); diff --git a/plugins/example/vitest.config.ts b/plugins/example/vitest.config.ts index a6624ef5b..7d8fa50e9 100644 --- a/plugins/example/vitest.config.ts +++ b/plugins/example/vitest.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ test: { environment: "node", exclude: ["**/node_modules/**", "**/dist/**"], + // Two suites run against the same real Postgres, and each one drops and + // recreates the schema in its `beforeAll`. Run one file at a time so the + // second does not wipe the first out from under it. + fileParallelism: false, }, resolve: { alias: {