From 8e34c625ecbb168bf38dfb75e9fd3d3a5ff65bda Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 10:53:08 +0200 Subject: [PATCH 01/10] feat(content): add advanced relation, group and repeatable definitions Stage 6 starts at the definition layer: three new field shapes, one canonical field-path vocabulary, and the definition-time validation that makes every generated table name and every flattened column known before a request runs. - `field.relation({ multiple, ordered })` for to-many and ordered relations. `target` was already a thunk, so a self-relation needs nothing special. - `field.group({ fields })` for reusable structured groups. Localization is a property of the group, never of a leaf: half a logical value on each table would mean two revision histories and two permissions for one editor box. - `field.repeatable({ fields })` for ordered child rows with stable identity. - `content/paths.ts` is the single leaf-path <-> column mapping every subsystem reads, so `seo.title` and `seo_title` cannot drift. - `content/advanced.ts` resolves the generated junction and child table names and refuses every advanced-field mistake at import time. Nested create/update schemas, leaf-path public allowlisting and the membership filter land with them. Stage 1-5 behaviour is unchanged: a content type that declares no advanced field resolves to empty arrays everywhere. Co-Authored-By: Claude Opus 5 (1M context) --- packages/elasticsearch/src/index.test.ts | 31 + packages/vitnode/src/content/advanced.ts | 469 ++++++++++++ packages/vitnode/src/content/const.ts | 85 +++ packages/vitnode/src/content/define.ts | 350 ++++++++- packages/vitnode/src/content/fields.ts | 114 ++- .../vitnode/src/content/localization.test.ts | 2 +- packages/vitnode/src/content/localization.ts | 48 +- packages/vitnode/src/content/paths.ts | 374 ++++++++++ packages/vitnode/src/content/schemas.ts | 324 +++++++- .../src/content/server/column-builders.ts | 19 + packages/vitnode/src/content/types.ts | 695 ++++++++++++++++-- plugins/example/src/database/postgres.test.ts | 167 +++++ 12 files changed, 2550 insertions(+), 128 deletions(-) create mode 100644 packages/vitnode/src/content/advanced.ts create mode 100644 packages/vitnode/src/content/paths.ts 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/advanced.ts b/packages/vitnode/src/content/advanced.ts new file mode 100644 index 000000000..f24ef28c1 --- /dev/null +++ b/packages/vitnode/src/content/advanced.ts @@ -0,0 +1,469 @@ +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 { clampWithFingerprint } from "./fingerprint"; +import { toSnakeCase } from "./indexes"; +import { + contentLeafColumns, + contentLeafColumnName, + 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 === true) { + 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}"`); + } +}; + +/** 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..db575f985 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -59,12 +59,18 @@ import { CONTENT_TABLE_NAME_PATTERN, isFilterableFieldKind, } from "./const"; +import { resolveContentAdvanced } from "./advanced"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; 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 +106,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 +160,10 @@ const FIELD_KINDS = new Set([ "boolean", "dateTime", "enum", + "group", "number", "relation", + "repeatable", "slug", "text", "textarea", @@ -333,6 +346,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 +426,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 +460,7 @@ const resolveAdmin = ( ] as const) { if (!names) continue; assertNotLocalized(id, label, names.map(String), localizedFields); + assertColumnField(label, names.map(String)); } const generatedColumns = [ @@ -367,17 +468,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 +495,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 +528,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 +584,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 +736,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 +764,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 +801,72 @@ 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 +933,7 @@ const disabledSearch: ResolvedContentSearchConfig = { * function with anything at all. */ const assertSearchField = ({ + allowRepeatable = false, exposed, fields, id, @@ -698,6 +941,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 +950,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 +1115,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 +1159,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, @@ -1242,14 +1500,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 +1536,24 @@ 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, @@ -1335,6 +1615,7 @@ export const defineContentType = < return { admin: resolvedAdmin, + advanced: resolvedAdvanced, editorial: resolvedEditorial as ResolvedContentEditorialConfig< ContentEditorialEnabled, ContentPreviewEnabled, @@ -1369,6 +1650,7 @@ export const defineContentType = < > >({ admin: resolvedAdmin, + advanced: resolvedAdvanced, editorial: editorialEnabled, fields: fieldMap, localization: resolvedLocalization, diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index f5089bd93..3fbc84657 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, @@ -220,19 +222,127 @@ const user = < }; }; +/** + * 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({ target: () => articleContentType, multiple: true, ordered: true }) + * ``` + * + * `target` is a thunk, which is what makes the third line legal inside + * `articleContentType` itself: the reference is resolved on first read rather + * than at declaration, so a self-relation needs nothing the other two do not. + * + * `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. + */ const relation = < TRequired extends boolean = false, TNullable extends boolean = false, + TMultiple extends boolean = false, + TOrdered extends boolean = false, >( args: SharedArgs & { + multiple?: TMultiple; onDelete?: ContentOnDelete; + ordered?: TOrdered; 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, +}); + +/** + * 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 +354,10 @@ export const field = { boolean, dateTime, enum: enumField, + group, number, relation, + repeatable, slug, text, textarea, 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..357d51fee 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,12 @@ 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 +181,19 @@ 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.ts b/packages/vitnode/src/content/paths.ts new file mode 100644 index 000000000..1abf594ef --- /dev/null +++ b/packages/vitnode/src/content/paths.ts @@ -0,0 +1,374 @@ +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 `seo_title`, 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 the Drizzle client runs with `casing: "camelCase"`, which is + * what turns it into `seo_title` in SQL - the same rule every hand-written + * VitNode column already follows. Building the SQL identifier here instead would + * make the generated table the only one in the codebase that does not. + */ +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 === true; + +/** 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 as ContentFieldMap; + } + + 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 === true, + 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)) { + // A leaf of an optional or nullable group has to be storable as NULL: + // omitting the group has to write *something*, and the only honest + // something is nothing. See `relaxLeafNullability`. + columns[contentLeafColumnName(name, leaf)] = relaxLeafNullability( + fieldValue, + leafValue, + ); + } + } + + return columns; +}; + +/** + * The column nullability of one leaf. + * + * A leaf inside a group that may be absent (`required: false`) or explicitly + * blanked (`nullable: true`) is stored in a nullable column whatever its own + * `nullable` says, because both of those states have to be representable. The + * leaf's own `nullable` still governs what a *request* may send: `seo: { title: + * null }` is rejected for a non-nullable leaf even though the column would take + * it, which is what keeps "the group is absent" and "the title was cleared" two + * different things. + */ +export const relaxLeafNullability = ( + group: ContentFieldDescriptor, + leaf: ContentFieldDescriptor, +): ContentFieldDescriptor => { + if (group.required && !group.nullable) return leaf; + if (leaf.nullable) return leaf; + + // The spread widens `nullable` past what a few descriptor interfaces pin - + // `repeatable` declares it literally `false` - but a leaf is only ever one of + // `CONTENT_ADVANCED_LEAF_KINDS`, which `resolveContentAdvanced` has already + // proven, and every one of those takes `nullable: boolean`. + return { ...leaf, nullable: true } as ContentFieldDescriptor; +}; + +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 || fieldValue.kind !== "group") { + 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 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/schemas.ts b/packages/vitnode/src/content/schemas.ts index 97780419a..b2986f01b 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,9 +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": + 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 "user": return referenceSchema(); case "slug": @@ -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,110 @@ 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 +410,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 +445,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(), @@ -308,8 +487,25 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => return [name, z.enum(["true", "false"]).optional()]; case "enum": return [name, z.enum(fieldValue.values).optional()]; - case "number": 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()]; + case "number": case "user": return [name, z.coerce.number().optional()]; default: @@ -339,6 +535,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 +601,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 +741,7 @@ const buildTranslationSchemas = ({ export const buildContentSchemas = ({ admin, + advanced = contentAdvancedDisabled(), editorial = false, fields, localization = contentLocalizationDisabled(), @@ -480,6 +749,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 +759,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 +803,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 +840,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/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/types.ts b/packages/vitnode/src/content/types.ts index 43eb6e7a1..7225c3fea 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -186,22 +186,106 @@ 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, > 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; + /** Thunk so two content types - or one content type and itself - can refer. */ 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, named + * `seoTitle` and therefore `seo_title` in SQL. 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 = ContentFieldMap, + 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 = ContentFieldMap, +> 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 +293,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 +352,107 @@ 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< + { id: number } & { + [K in keyof TFields]: ContentFieldValue; + } +>; + +/** + * 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< + { id?: number } & CreateValuesOf +>; + /** * 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>>, TField> + : ContentFieldInput; /** * Whether the generated column carries a Postgres default. Drives `hasDefault` @@ -323,6 +492,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 (`seo_title`) 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 = + | ContentLeafPath> + | (CollectionFieldKeys> & string) + | (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< + ContentFieldsOf[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 +670,7 @@ type ContentAddressableColumn< | ContentEditorialColumn | ContentPublicationColumn | ContentSystemField - | SharedFieldKeys; + | ScalarColumnFieldKeys; export interface ContentAdminListConfig< TFields = ContentFieldMap, @@ -393,9 +685,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 +711,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 +757,26 @@ 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; +> = string & + (ContentAddressableColumn | + ContentLeafPath); /** * Stored shape. Non-generic for the same reason as @@ -531,9 +838,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 +934,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 +989,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 { + /** `(itemId, position)`, so an ordered relation has no duplicate slots. */ + positionIndexName: string; + /** The source field this junction belongs to. */ + field: string; + /** `__related_idx` - the reverse lookup and the FK's index. */ + relatedIndexName: string; + /** `
__pk` on `(itemId, relatedItemId)`. */ + primaryKeyName: 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 +1472,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`, which Drizzle's camelCase casing emits as `seo_title`. */ + 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 +1522,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 +1583,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 keyof ContentCreateInput]?: ContentFieldPatch< + ContentFieldsOf[K] + >; +}>; /** * Every field name the content type declares, localized ones included. @@ -1164,13 +1638,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 @@ -1188,15 +1662,18 @@ export type FilterableContentFieldName = FieldNamesOfKind< /** {@link FieldNamesOfKind} over every field, shared and localized alike. */ type AnyFieldNamesOfKind = string & - { - [ - K in keyof ContentFieldsOf - ]: ContentFieldsOf[K] extends { - kind: TKind; - } - ? K - : never; - }[keyof ContentFieldsOf]; + Exclude< + { + [ + K in keyof ContentFieldsOf + ]: ContentFieldsOf[K] extends { + kind: TKind; + } + ? K + : never; + }[keyof ContentFieldsOf], + CollectionFieldKeys> + >; /** * Field names a **public** filter may name. @@ -1217,10 +1694,27 @@ 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 ContentRelationCollectionName]: ContentRelationFilter; + } & { [K in FilterableContentFieldName]: ContentFieldInput< ContentFieldsOf[K] >; @@ -1294,13 +1788,56 @@ 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 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 PublicPathOwner & keyof TFields]: TFields[TOwner] extends { + fields: infer TInner; + kind: "repeatable"; + } + ? Prettify< + { id: number } & { + [TLeaf in PublicPathLeaf & + keyof TInner]: ContentFieldValue; + } + >[] + : TFields[TOwner] extends { fields: infer TInner; kind: "group" } + ? ApplyNullable< + Prettify<{ + [TLeaf in PublicPathLeaf & + keyof TInner]: ContentFieldValue; + }>, + TFields[TOwner] + > + : never; +}; + /** * One public row: exactly the allowlisted fields, and not one key more. * @@ -1309,12 +1846,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< + ContentPublicFieldName + >]: ContentPublicValue, K>; + } >; /** @@ -1354,13 +1894,28 @@ 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< + { + [ + K in 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/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"], + ]); + }); }); /** From 1c3783e56b0b71ac43cf4ee2be56eb63516a00d7 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 11:22:19 +0200 Subject: [PATCH 02/10] feat(content): generate relational storage for advanced fields The runtime half of Stage 6's storage model. Nothing here is JSONB, an EAV table or a comma-separated identifier list: a to-many relation is a junction table with two real foreign keys, a repeatable is a child table with a serial primary key of its own, and a group is flattened into ordinary columns. - `advanced-tables.ts` generates both shapes, with PRIMARY KEY (itemId, relatedItemId), UNIQUE (itemId, position) and a reverse index on the junction; UNIQUE (itemId, position) and ON DELETE CASCADE on the child. - `advanced-store.ts` is the one read/write layer both kinds share. Positions are settled in two passes through a negative parking space, so a reorder can never violate the unique index even for an instant - and child identity survives it, which is what makes "edit entry 11" and a revision restore mean anything. - Relation and repeatable mutations are wrappers over `update`, so each one inherits the no-op rule, the lock, `updatedAt`, and - on an editorial content type - the version guard, the revision and the event. The guard runs before a single junction row is touched, so a losing writer leaves nothing behind. - Revisions record collections as identity and groups as nested values, never flattened column names and never the expanded related records. - Public reads batch-load collections one query per field per page, so a list never issues one per row. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/errors.ts | 44 + packages/vitnode/src/content/revisions.ts | 47 +- .../src/content/server/advanced-store.ts | 750 ++++++++++++++++++ .../src/content/server/advanced-tables.ts | 283 +++++++ .../src/content/server/editorial-service.ts | 173 +++- .../server/localized-public-service.ts | 93 ++- packages/vitnode/src/content/server/model.ts | 43 +- .../src/content/server/public-service.ts | 181 ++++- packages/vitnode/src/content/server/query.ts | 152 +++- .../src/content/server/revision-snapshot.ts | 142 +++- .../vitnode/src/content/server/routes.test.ts | 21 +- .../src/content/server/search-sync.test.ts | 4 + .../vitnode/src/content/server/service.ts | 543 +++++++++++-- packages/vitnode/src/content/server/table.ts | 41 +- packages/vitnode/src/content/server/types.ts | 151 +++- packages/vitnode/src/content/types.ts | 6 +- 16 files changed, 2533 insertions(+), 141 deletions(-) create mode 100644 packages/vitnode/src/content/server/advanced-store.ts create mode 100644 packages/vitnode/src/content/server/advanced-tables.ts 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/revisions.ts b/packages/vitnode/src/content/revisions.ts index f9a3d0a78..2dad99b63 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 + | Record + | Record[] + | number[]; /** * 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/server/advanced-store.ts b/packages/vitnode/src/content/server/advanced-store.ts new file mode 100644 index 000000000..56b53f5cc --- /dev/null +++ b/packages/vitnode/src/content/server/advanced-store.ts @@ -0,0 +1,750 @@ +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 { 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"; +import type { ContentDatabase } from "./service"; + +/** + * 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. */ + load: ( + itemId: number, + database: ContentDatabase, + ) => 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, + ) => Promise>>; + /** + * 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; + }>; + /** + * 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; + /** 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 as ContentFieldMap; + const collectionNames = Object.keys(fields).filter(name => + isContentCollectionField(fields[name]), + ); + const baseColumns = table as unknown as Record; + + const junctionOf = ( + field: string, + ): { columns: Record; table: PgTable } | null => { + 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, + ): { columns: Record; table: PgTable } | null => { + 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()) + .find(reference => + reference.columns.some(column => column.name === "related_item_id"), + )?.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 === true, + ); + 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; + }; + + const enabled = collectionNames.length > 0; + + const disabled: ContentAdvancedStore = { + diff: async () => [], + enabled: false, + fields: [], + load: async () => ({}), + loadMany: async () => new Map(), + membershipCondition: () => undefined, + prepareRestore: async (_tx, _itemId, patch) => ({ + missingRelations: [], + patch, + }), + write: async () => [], + }; + + 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) => { + const loaded = await Promise.all( + collectionNames.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) => { + const result = new Map>(); + for (const itemId of itemIds) { + result.set( + itemId, + Object.fromEntries(collectionNames.map(field => [field, []])), + ); + } + if (itemIds.length === 0) return result; + + for (const field of collectionNames) { + 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; + + const { id: _dropped, ...rest } = row; + + return rest; + }); + } + + 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..fef9d9065 --- /dev/null +++ b/packages/vitnode/src/content/server/advanced-tables.ts @@ -0,0 +1,283 @@ +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 + * item_id -> example_articles.id ON DELETE CASCADE + * related_item_id -> example_categories.id ON DELETE + * position integer NOT NULL + * created_at timestamp NOT NULL DEFAULT now() + * + * PRIMARY KEY (item_id, related_item_id) + * UNIQUE (item_id, position) + * INDEX (related_item_id) + * ``` + * + * `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 + * item_id -> example_articles.id ON DELETE CASCADE + * position integer NOT NULL + * created_at timestamp NOT NULL DEFAULT now() + * updated_at timestamp NOT NULL DEFAULT now() + * question varchar(255) NOT NULL + * answer text NOT NULL + * + * UNIQUE (item_id, 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; + + const relatedReference = 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< + string, + ContentRepeatableChildTable + > = {}; + 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/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index e3dab4da7..52faa1428 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -12,23 +12,33 @@ import type { ContentActor, ContentRevisionOperation } from "../revisions"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, + ContentChangedPath, ContentCreateInput, - ContentFieldName, ContentSelect, ContentUpdateInput, } 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 { + changedPathsToColumns, + diffChangedPaths, + toInsertColumns, +} from "./query"; import { contentRevisionSnapshot, projectRevisionSnapshot, @@ -47,7 +57,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; @@ -139,6 +150,7 @@ export interface ContentEditorialService { export const createContentEditorialService = < TDefinition extends AnyContentTypeDefinition, >({ + advanced, c, columns, definition, @@ -146,6 +158,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 +175,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 +205,29 @@ 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 +238,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 +290,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. @@ -384,15 +441,22 @@ export const createContentEditorialService = < 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 +464,7 @@ export const createContentEditorialService = < return { changed: true, - changedFields: fieldNames, + changedFields: allPaths, operation: "create", previousSlug: null, restoredFromRevisionId: null, @@ -412,6 +476,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 +521,7 @@ export const createContentEditorialService = < actor: options.actor, changedFields: [], operation: "delete", - row, + row: { ...row, __collections: collections }, version, }); @@ -515,8 +584,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 +629,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, @@ -588,7 +682,15 @@ export const createContentEditorialService = < 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 +710,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, diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index 92bd77da0..37c8c04f7 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,17 @@ 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 +113,7 @@ const EMPTY_PAGE = { export const createContentLocalizedPublicService = < TDefinition extends AnyContentTypeDefinition, >({ + advanced, c, columns, definition, @@ -113,6 +121,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 +141,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 +158,65 @@ 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); + + /** + * 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"); + const loaded = await advanced?.loadMany(ids, c.get("db")); + + 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 @@ -377,7 +439,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 +479,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 +516,9 @@ export const createContentLocalizedPublicService = < allowed: publicApi.filterableFields, columns, contentTypeId, - fields: sharedFields, + fields: { ...collectionFields, ...sharedFields }, filters: sharedFilters, + membership: advanced?.membershipCondition, }), anyOf( sharedSearch, @@ -502,7 +569,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..eb3d2db1a 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,20 @@ import { } from "./translation-table"; export interface ContentModel { + /** + * 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 +206,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 +262,7 @@ export const createContentModel = < }; return { + advancedTables, columns, definition, // The plugin id arrives at call time rather than being captured here: a @@ -242,6 +273,7 @@ export const createContentModel = < editorialService: definition.editorial.enabled ? (c: Context, { pluginId }: { pluginId: string }) => createContentEditorialService({ + advanced, c, columns, definition, @@ -279,6 +311,7 @@ export const createContentModel = < }) : undefined, service: createContentService({ + advanced, c, columns, definition, @@ -297,6 +330,7 @@ export const createContentModel = < ? (c: Context) => localized && translationTable && translationColumns ? createContentLocalizedPublicService({ + advanced, c, columns, definition, @@ -304,11 +338,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-service.ts b/packages/vitnode/src/content/server/public-service.ts index 9faec83ba..21c062c18 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,12 @@ 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 +134,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 +179,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 +223,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 +332,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 +370,32 @@ 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"); + const loaded = await advanced?.loadMany(ids, c.get("db")); + + return nested.map(row => ({ + ...row, + ...(typeof row.id === "number" ? loaded?.get(row.id) : undefined), + })); + }; const readOne = async ( condition: SQL, @@ -248,7 +407,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 +431,7 @@ export const createContentPublicService = < contentTypeId, fields, filters, + membership: advanced?.membershipCondition, }), buildSearchCondition(searchColumns, query.search), ].filter((item): item is SQL => item !== undefined); @@ -311,7 +475,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..e30ffb335 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,15 @@ import { isFilterableFieldKind, } from "../const"; import { ContentEngineError } from "../errors"; +import { + contentFieldPath, + contentInnerFields, + contentLeafColumnName, + contentStorageColumns, + contentValuesToColumns, + isContentRelationCollection, + splitContentFieldPath, +} from "../paths"; /** * Escapes the `LIKE` wildcards so a search for "100%" matches the literal text @@ -61,6 +74,7 @@ export const buildFilterCondition = ({ contentTypeId, fields, filters, + membership, publication = false, }: { /** @@ -73,6 +87,15 @@ 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 +129,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 +245,99 @@ 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; + + 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 column = contentLeafColumnName(name, leaf); + if (current[column] === null || current[column] === 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; + + const column = contentLeafColumnName(name, leaf); + if (sameValue(current[column], 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 `seo_title` and + * leaves `seo_description` 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 +352,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/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 5b0f5fe56..db9c88438 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, + contentLeafColumnName, + isContentRelationCollection, +} from "../paths"; const toIso = (value: unknown): string => { if (value instanceof Date) return value.toISOString(); @@ -29,18 +35,85 @@ 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 `seo_title`, + * 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) { + // Read from the column when the row is a database row, and from the + // nested value when it is already logical - a restore hands over the + // second, and both have to snapshot identically. + const raw = + values[contentLeafColumnName(name, leaf)] ?? + (values[name] as Record | null | undefined)?.[leaf] ?? + null; + const snapshot = toSnapshotValue(raw); + 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 +137,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 = { @@ -127,17 +209,59 @@ export const projectRevisionSnapshot = ( snapshot: ContentRevisionSnapshot, ): Record => { const projected: Record = {}; - const { sharedFields } = partitionContentFields(definition.fields); + const { collectionFields, sharedFields } = partitionContentFields( + definition.fields, + ); + const restorable: ContentFieldMap = { + ...sharedFields, + ...collectionFields, + }; - for (const name of Object.keys(sharedFields)) { + for (const [name, fieldValue] of Object.entries(restorable)) { if (!(name in snapshot.fields)) continue; - projected[name] = snapshot.fields[name]; + const value = 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 as Record, 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. * diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index 07f896313..acbe623ff 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -98,11 +98,22 @@ 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(), + findDetail: vi.fn(), findById: vi.fn(), findMany: vi.fn(), options: vi.fn(), @@ -110,7 +121,7 @@ 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 +156,8 @@ const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => { const service = { create: vi.fn(), delete: vi.fn(), + advanced: vi.fn(), + findDetail: vi.fn(), findById: vi.fn(), findMany: vi.fn(), options: vi.fn(), @@ -154,7 +167,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 +701,10 @@ describe("generated content routes", () => { const service = { create: vi.fn(), delete: vi.fn(), + advanced: vi.fn(), + findDetail: vi.fn(), + relations: {}, + repeatable: {}, findById: vi.fn(), findMany: vi.fn(), options: vi.fn(), diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index 5ccd529b9..9b00449f1 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -79,7 +79,11 @@ const harness = ({ const service = { create: vi.fn(), delete: vi.fn(), + advanced: 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/service.ts b/packages/vitnode/src/content/server/service.ts index d802f0d37..d15b02461 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -11,14 +11,17 @@ import { and, eq, ne, sql } from "drizzle-orm"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, + ContentAdvancedValues, + ContentChangedPath, ContentCreateInput, + ContentDetail, ContentFilterInput, ContentOrderableFieldName, ContentReferenceFieldName, ContentSelect, - ContentSharedFieldName, ContentUpdateInput, } from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; import { withPagination } from "../../api/lib/with-pagination"; import { @@ -26,16 +29,19 @@ 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 { + changedPathsToColumns, buildFilterCondition, buildOrderColumn, buildSearchCondition, - diffChangedFields, - toColumnValues, + diffChangedPaths, + toInsertColumns, } from "./query"; import { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; import { createSlugNormalizer } from "./slugs"; @@ -84,10 +90,121 @@ 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. + * + * Every method is a thin wrapper over `update`, and deliberately so: a relation + * or repeatable mutation *is* an edit of the source record, so it has to take + * the same lock, bump the same version, leave the same revision, emit the same + * event and invalidate the same tags. Routing them through one write path is + * what makes that true by construction instead of by six call sites remembering. + */ +export interface ContentRelationMethods { + /** The current targets, in stored order. */ + get: ( + itemId: number, + options?: ContentServiceOptions, + ) => Promise; + /** Adds one target. A target already present is a no-op. */ + add: ( + itemId: number, + relatedItemId: number, + options?: ContentWriteOptions, + ) => Promise | null>; + /** 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. */ + create: ( + itemId: number, + values: Record, + 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[]>; + /** 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 Record[], + options?: ContentWriteOptions, + ) => Promise | null>; + /** Updates one child by its stable identifier. */ + update: ( + itemId: number, + childId: number, + values: Record, + options?: ContentWriteOptions, + ) => Promise | null>; +} + +/** + * Options for a collection mutation. + * + * `expectedVersion` is the source record's, and it is optional here for the same + * reason it does not exist at all on the plain `update`: this service is the + * non-editorial one, which has no `version` column to guard on and serialises + * concurrent writers with `SELECT ... FOR UPDATE` instead. The editorial service + * takes a required one - see `ContentEditorialWriteOptions`. + */ +export interface ContentWriteOptions extends ContentServiceOptions { + expectedVersion?: number; +} + export interface ContentPublicationResult { /** * `false` when the row was already in that state: no write happened, no event @@ -132,6 +249,11 @@ 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>; /** Throws a `ZodError` if `values` does not satisfy `schemas.create`. */ create: ( values: ContentCreateInput, @@ -141,6 +263,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>; findById: ( id: number, options?: ContentServiceOptions, @@ -154,6 +286,16 @@ export interface ContentServiceBase { field: ContentReferenceFieldName, search?: string, ) => Promise<{ label: string; value: number }[]>; + /** + * Typed to-many relation operations, keyed by field name. + * + * Empty for a content type that declares none, so `service.relations` always + * exists and `service.relations.categories` is a compile error unless there is + * a `categories`. + */ + relations: Record>; + /** Typed repeatable operations, keyed by field name. */ + repeatable: Record>; /** Throws a `ZodError` if `values` does not satisfy `schemas.update`. */ update: ( id: number, @@ -173,12 +315,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 +339,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 +355,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( @@ -227,8 +380,27 @@ export const createContentService = < 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 `seo_title` and `seo_description` 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 +416,7 @@ export const createContentService = < values[key] = value; } - return { ...values, labels } as ContentListRow; + return { ...projectRow(values), labels } as ContentListRow; }; const readOne = async ( @@ -329,20 +501,73 @@ 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); - }, + /** + * 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): Promise => { + if (!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, + + 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 +584,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 +693,230 @@ 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: {}, + + 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; + + const current = await readOne(id, tx); + if (!current) return null; + + 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. A reorder to the order + // that is already stored lands here. + if (changedFields.length === 0) { + return { + changedFields: changedFields as ContentChangedPath[], + row: toRow(current), + }; + } + + 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( + changedPaths.length > 0 + ? changedPathsToColumns(fields, patch, changedPaths) + : { updatedAt: new Date() }, + ) + .where(eq(primaryCursor, id)) + .returning(ownSelection()); + + return { + changedFields: changedFields as ContentChangedPath[], + row: toRow(row), + }; + }), + }; - const database = db(options); - const current = await readOne(id, database); - if (!current) return null; + /** + * Turns a collection field into the five typed operations callers use. + * + * Each one reads the current state, computes the whole new list and hands it + * to `update` - so every one of them inherits the no-op rule, the row lock, + * the `updatedAt` bump and, on the editorial service, the version guard, the + * revision and the event. There is no second write path to keep in step. + */ + const relationMethods = ( + field: string, + ): ContentRelationMethods => { + const read = async ( + itemId: number, + options?: ContentServiceOptions, + ): Promise => { + const loaded = await store?.load(itemId, db(options)); + const value = loaded?.[field]; + + return Array.isArray(value) ? (value as number[]) : []; + }; + + const write = ( + itemId: number, + next: readonly number[], + options?: ContentWriteOptions, + ): Promise | null> => + service.update( + itemId, + { [field]: [...next] } as ContentUpdateInput, + options, + ); + + return { + add: async (itemId, relatedItemId, options) => { + const current = await read(itemId, options); + + return await write( + itemId, + current.includes(relatedItemId) + ? current + : [...current, relatedItemId], + options, + ); + }, - const changedFields = diffChangedFields(fieldNames, current, patch); + get: read, - // Nothing actually moved - skip the write so `updatedAt` and the - // `content.*.updated` event both stay honest. - if (changedFields.length === 0) { - return { changedFields, row: toRow(current) }; - } + remove: async (itemId, relatedItemId, options) => + await write( + itemId, + (await read(itemId, options)).filter(id => id !== relatedItemId), + options, + ), + + reorder: async (itemId, relatedItemIds, options) => { + const current = await read(itemId, options); + assertPermutation(field, current, relatedItemIds, "target"); - const [row] = await database - .update(table) - .set( - toColumnValues( - fields, - Object.fromEntries(changedFields.map(key => [key, patch[key]])), + return await write(itemId, relatedItemIds, options); + }, + + set: async (itemId, relatedItemIds, options) => + await write(itemId, relatedItemIds, options), + }; + }; + + const repeatableMethods = ( + field: string, + ): ContentRepeatableMethods => { + const read = async ( + itemId: number, + options?: ContentServiceOptions, + ): Promise[]> => { + const loaded = await store?.load(itemId, db(options)); + const value = loaded?.[field]; + + return Array.isArray(value) ? (value as Record[]) : []; + }; + + const write = ( + itemId: number, + rows: readonly Record[], + options?: ContentWriteOptions, + ): Promise | null> => + service.update( + itemId, + { [field]: [...rows] } as ContentUpdateInput, + options, + ); + + return { + create: async (itemId, values, options) => + await write(itemId, [...(await read(itemId, options)), values], options), + + delete: async (itemId, childId, options) => + await write( + itemId, + (await read(itemId, options)).filter(row => row.id !== childId), + options, + ), + + list: read, + + reorder: async (itemId, childIds, options) => { + const current = await read(itemId, options); + assertPermutation( + field, + current.map(row => Number(row.id)), + childIds, + "entry", + ); + + const byId = new Map(current.map(row => [Number(row.id), row])); + + return await write( + itemId, + childIds.map(childId => byId.get(childId) ?? {}), + options, + ); + }, + + set: async (itemId, rows, options) => await write(itemId, rows, options), + + update: async (itemId, childId, values, options) => + await write( + itemId, + (await read(itemId, options)).map(row => + row.id === childId ? { ...row, ...values, id: childId } : row, ), - ) - .where(eq(primaryCursor, id)) - .returning(ownSelection()); + options, + ), + }; + }; - return { changedFields, row: toRow(row) }; - }, + /** + * 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. + */ + const assertPermutation = ( + field: string, + current: readonly number[], + 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 }, + ); }; + for (const field of store?.fields ?? []) { + if (definition.fields[field]?.kind === "repeatable") { + service.repeatable[field] = repeatableMethods(field); + continue; + } + service.relations[field] = relationMethods(field); + } + // `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..81d3b812c 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, @@ -144,8 +145,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 +168,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 as ContentFieldMap; const unknownReference = Object.keys(referenceThunks).find( - name => fields[name]?.kind !== "relation", + name => declaredFields[name]?.kind !== "relation", ); if (unknownReference !== undefined) { throw new ContentEngineError( @@ -211,7 +220,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 +237,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/types.ts b/packages/vitnode/src/content/server/types.ts index 751f19158..da7c73bf4 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,81 @@ 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 + ? RelaxedLeaf + : never + : never + : never + : never; + +/** The type-level twin of `relaxLeafNullability`. */ +type RelaxedLeaf = TGroup extends { + nullable: false; + required: true; +} + ? TLeaf + : TLeaf extends { nullable: true } + ? TLeaf + : Omit & { nullable: true }; + /** * The base `pgTable` for one definition. * @@ -250,7 +326,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,21 +339,31 @@ 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 | (TDefinition extends { editorial: { enabled: true } } ? ContentEditorialField : never) | (TDefinition extends { publication: { enabled: true } } ? ContentPublicationField - : never); + : never) + | (keyof ContentStorageFields> & string); /** * 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. */ export type ContentReferences = { [ @@ -280,6 +371,56 @@ export type ContentReferences = { ]: () => 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, + { + createdAt: NotNull>>; + id: PgSerialBuilderInitial; + itemId: NotNull>; + position: NotNull>; + updatedAt: NotNull>>; + } & { + [K in keyof TFields]: ContentColumnBuilder; + }, + "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 7225c3fea..9e18bee88 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -244,7 +244,7 @@ export interface ContentRelationField< * `partitionContentFields` exists to prevent. */ export interface ContentGroupField< - TFields = ContentFieldMap, + TFields = ContentLeafFieldMap, TRequired extends boolean = boolean, TNullable extends boolean = boolean, TLocalized extends boolean = boolean, @@ -268,7 +268,7 @@ export interface ContentGroupField< * `apps/docs/.../repeatable-fields.mdx` for why that is a later stage. */ export interface ContentRepeatableField< - TFields = ContentFieldMap, + TFields = ContentLeafFieldMap, > extends ContentFieldShared { fields: TFields; kind: "repeatable"; @@ -1614,7 +1614,7 @@ export type ContentCreateInput = ContentSharedValues; * their own service call. */ export type ContentUpdateInput = Prettify<{ - [K in keyof ContentCreateInput]?: ContentFieldPatch< + [K in SharedFieldKeys>]?: ContentFieldPatch< ContentFieldsOf[K] >; }>; From 72a874b10630c1d083ce1a0f63ad8165e7dcf9d0 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 11:28:49 +0200 Subject: [PATCH 03/10] feat(content): integrate advanced fields with search, cache and events Search, cache and events all read the same vocabulary now - canonical paths - so none of them needed a Stage 6 code path of its own. - `search.contentFields` accepts group leaves and repeatable leaves. A repeatable leaf resolves to every child's value joined in **position** order, because position is what the page renders and an index that disagreed with the page would highlight the wrong entry. - The synchronizer matches `changedFields` against indexed paths *and* their containers, so a rewritten FAQ answer (reported as `faq`) still moves the document, while a relation change moves nothing unless the relation actually contributes to a configured projection. - Collections are read back only when the configuration names one, so every Stage 1-5 content type pays a boolean check and no query. - Cache and events needed no change at all: `changedFields` already carries `seo.description` and `categories`, and the existing locale fan-out already treats a shared field as reaching every locale - which a collection is. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/advanced.ts | 27 ++-- packages/vitnode/src/content/define.ts | 8 +- packages/vitnode/src/content/localization.ts | 9 +- packages/vitnode/src/content/paths.ts | 23 ++-- packages/vitnode/src/content/revisions.ts | 4 +- packages/vitnode/src/content/schemas.ts | 14 +- packages/vitnode/src/content/search.ts | 50 +++++++ .../src/content/server/advanced-store.ts | 67 +++++---- .../src/content/server/advanced-tables.ts | 5 +- .../src/content/server/editorial-effects.ts | 28 +++- .../src/content/server/editorial-service.ts | 4 +- .../server/localized-public-service.ts | 5 +- .../src/content/server/public-service.ts | 5 +- packages/vitnode/src/content/server/query.ts | 5 +- .../src/content/server/revision-snapshot.ts | 4 +- .../vitnode/src/content/server/routes.test.ts | 5 +- packages/vitnode/src/content/server/routes.ts | 27 +++- .../src/content/server/search-document.ts | 44 +++++- .../vitnode/src/content/server/search-sync.ts | 35 +++-- .../vitnode/src/content/server/service.ts | 34 ++--- packages/vitnode/src/content/server/table.ts | 2 +- .../src/content/server/translation-effects.ts | 17 ++- packages/vitnode/src/content/server/types.ts | 18 +-- packages/vitnode/src/content/types.ts | 128 ++++++++++-------- 24 files changed, 363 insertions(+), 205 deletions(-) diff --git a/packages/vitnode/src/content/advanced.ts b/packages/vitnode/src/content/advanced.ts index f24ef28c1..63e4017b6 100644 --- a/packages/vitnode/src/content/advanced.ts +++ b/packages/vitnode/src/content/advanced.ts @@ -22,8 +22,8 @@ import { ContentEngineError } from "./errors"; import { clampWithFingerprint } from "./fingerprint"; import { toSnakeCase } from "./indexes"; import { - contentLeafColumns, contentLeafColumnName, + contentLeafColumns, isContentLeafKind, isContentRelationCollection, partitionContentStorage, @@ -72,10 +72,7 @@ export const contentCollectionTableName = ( ); const suffixed = (tableName: string, suffix: string): string => - clampWithFingerprint( - `${tableName}_${suffix}`, - CONTENT_IDENTIFIER_MAX_LENGTH, - ); + clampWithFingerprint(`${tableName}_${suffix}`, CONTENT_IDENTIFIER_MAX_LENGTH); const junctionSystemFields: readonly string[] = CONTENT_JUNCTION_SYSTEM_FIELDS; const repeatableSystemFields: readonly string[] = @@ -229,7 +226,11 @@ const assertRepeatable = ( }); const max = repeatableValue.max ?? CONTENT_REPEATABLE_DEFAULT_MAX; - if (!Number.isInteger(max) || max < 1 || max > CONTENT_REPEATABLE_ABSOLUTE_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 }, @@ -287,7 +288,7 @@ const assertRelation = ( return; } - if (fieldValue.ordered === true) { + 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 }, @@ -317,7 +318,8 @@ export const resolveContentAdvanced = ({ 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); + if (fieldValue.kind === "repeatable") + assertRepeatable(id, name, fieldValue); } const leaves = contentLeafColumns(fields); @@ -433,13 +435,16 @@ const assertGeneratedTableNames = ( }; /** The resolved ceiling on a repeatable's child count. */ -export const contentRepeatableMax = (fieldValue: ContentFieldDescriptor): number => +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 contentRepeatableMin = ( + fieldValue: ContentFieldDescriptor, +): number => (fieldValue.kind === "repeatable" ? (fieldValue.min ?? 0) : 0); export const contentRelationCollectionMax = (): number => CONTENT_RELATION_COLLECTION_MAX; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index db575f985..d879dafba 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -26,6 +26,7 @@ import type { ResolvedContentSearchConfig, } from "./types"; +import { resolveContentAdvanced } from "./advanced"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, @@ -59,7 +60,6 @@ import { CONTENT_TABLE_NAME_PATTERN, isFilterableFieldKind, } from "./const"; -import { resolveContentAdvanced } from "./advanced"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; import { @@ -827,6 +827,7 @@ const resolvePublicApi = ( // 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) { @@ -1541,7 +1542,10 @@ export const defineContentType = < // 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) }; + 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` diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts index 357d51fee..6eedb91ce 100644 --- a/packages/vitnode/src/content/localization.ts +++ b/packages/vitnode/src/content/localization.ts @@ -166,7 +166,10 @@ const assertLocalizedFields = ( // 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)) { + 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 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 }, @@ -185,7 +188,9 @@ const assertLocalizedFields = ( // 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))) { + for (const columnName of Object.keys( + contentStorageColumns(localizedFields), + )) { if (!translationSystemFields.includes(columnName)) continue; throw new ContentEngineError( diff --git a/packages/vitnode/src/content/paths.ts b/packages/vitnode/src/content/paths.ts index 1abf594ef..ab6361b75 100644 --- a/packages/vitnode/src/content/paths.ts +++ b/packages/vitnode/src/content/paths.ts @@ -6,10 +6,7 @@ import type { ContentRepeatableField, } from "./types"; -import { - CONTENT_ADVANCED_LEAF_KINDS, - CONTENT_PATH_SEPARATOR, -} from "./const"; +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. @@ -60,8 +57,7 @@ export const contentLeafColumnName = (owner: string, leaf: string): string => 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 isContentLeafKind = (kind: string): boolean => leafKinds.has(kind); export const isContentGroupField = ( fieldValue: ContentFieldDescriptor, @@ -85,15 +81,13 @@ export const isContentRepeatableField = ( */ export const isContentRelationCollection = ( fieldValue: ContentFieldDescriptor, -): boolean => fieldValue.kind === "relation" && fieldValue.multiple === true; +): 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; + fieldValue.kind === "relation" && fieldValue.multiple ? fieldValue : null; /** The repeatable descriptor, or `null` when the field is not one. */ export const asContentRepeatableField = ( @@ -117,7 +111,7 @@ export const contentInnerFields = ( fieldValue: ContentFieldDescriptor, ): ContentFieldMap => { if (fieldValue.kind === "group" || fieldValue.kind === "repeatable") { - return fieldValue.fields as ContentFieldMap; + return fieldValue.fields; } return {}; @@ -143,7 +137,7 @@ export const contentLeafColumns = ( columnName: contentLeafColumnName(group, leaf), group, leaf, - localized: fieldValue.localized === true, + localized: fieldValue.localized, path: contentFieldPath(group, leaf), }); } @@ -274,8 +268,11 @@ export const contentValuesToColumns = ( for (const [name, value] of Object.entries(values)) { const fieldValue = fields[name]; - if (!fieldValue || fieldValue.kind !== "group") { + 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; } diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index 2dad99b63..793a0f6b7 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -54,9 +54,9 @@ export type ContentSnapshotScalar = boolean | null | number | string; */ export type ContentSnapshotValue = | ContentSnapshotScalar + | number[] | Record - | Record[] - | number[]; + | Record[]; /** * The complete post-mutation editable state of one record. diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index b2986f01b..f7dc8cad7 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -245,8 +245,6 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { }), ); } - case "user": - return referenceSchema(); case "slug": // Never empty: the service normalises before writing, and a value that // folds to nothing is rejected rather than stored. @@ -257,6 +255,8 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { case "text": case "textarea": return textSchema(fieldValue); + case "user": + return referenceSchema(); } }; @@ -322,9 +322,7 @@ const relationSetSchema = (): z.ZodType => * 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 repeatableRowSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { const inner = contentInnerFields(fieldValue); const names = Object.keys(inner); @@ -487,6 +485,9 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => return [name, z.enum(["true", "false"]).optional()]; case "enum": return [name, z.enum(fieldValue.values).optional()]; + case "number": + 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 @@ -505,9 +506,6 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => ), ] : [name, z.coerce.number().optional()]; - case "number": - case "user": - return [name, z.coerce.number().optional()]; default: return [name, z.string().optional()]; } diff --git a/packages/vitnode/src/content/search.ts b/packages/vitnode/src/content/search.ts index f5f2ac8db..c52ec6a5c 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,52 @@ 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]; + }), + ); +}; + +/** + * 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 => + contentSearchIndexedFieldNames(definition).some(name => { + const path = splitContentFieldPath(name); + if (!path) return false; + + const fieldValue = definition.fields[path[0]]; + + return ( + fieldValue !== undefined && + (fieldValue.kind === "repeatable" || + isContentRelationCollection(fieldValue)) + ); + }); diff --git a/packages/vitnode/src/content/server/advanced-store.ts b/packages/vitnode/src/content/server/advanced-store.ts index 56b53f5cc..c385eadda 100644 --- a/packages/vitnode/src/content/server/advanced-store.ts +++ b/packages/vitnode/src/content/server/advanced-store.ts @@ -9,6 +9,7 @@ import type { ContentFieldMap, ContentRelationFilter, } from "../types"; +import type { ContentDatabase } from "./service"; import type { ContentAdvancedTables } from "./types"; import { @@ -22,7 +23,6 @@ import { isContentCollectionField, } from "../paths"; import { toColumnValues } from "./query"; -import type { ContentDatabase } from "./service"; /** * The read and write layer for a content type's advanced collections. @@ -81,6 +81,17 @@ export interface ContentAdvancedStore { itemIds: readonly number[], database: ContentDatabase, ) => 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. * @@ -104,17 +115,6 @@ export interface ContentAdvancedStore { missingRelations: { field: string; ids: number[] }[]; patch: Record; }>; - /** - * 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; /** Applies a patch's collection half. Returns the fields that moved. */ write: ( tx: ContentDatabase, @@ -187,7 +187,7 @@ export const createContentAdvancedStore = < tables: ContentAdvancedTables; }): ContentAdvancedStore => { const contentTypeId = definition.id; - const fields = definition.fields as ContentFieldMap; + const fields = definition.fields; const collectionNames = Object.keys(fields).filter(name => isContentCollectionField(fields[name]), ); @@ -195,7 +195,7 @@ export const createContentAdvancedStore = < const junctionOf = ( field: string, - ): { columns: Record; table: PgTable } | null => { + ): null | { columns: Record; table: PgTable } => { const junction = tables.junctions[field]; if (!junction) return null; @@ -207,7 +207,7 @@ export const createContentAdvancedStore = < const childOf = ( field: string, - ): { columns: Record; table: PgTable } | null => { + ): null | { columns: Record; table: PgTable } => { const child = tables.repeatables[field]; if (!child) return null; @@ -493,10 +493,7 @@ export const createContentAdvancedStore = < .update(child.table) .set({ ...values, position: parked(index) }) .where( - and( - eq(child.columns.itemId, itemId), - eq(child.columns.id, row.id), - ), + and(eq(child.columns.itemId, itemId), eq(child.columns.id, row.id)), ); continue; } @@ -536,10 +533,7 @@ export const createContentAdvancedStore = < if (relation) { if (!Array.isArray(value)) continue; - const desired = normalizeTargets( - value as number[], - relation.ordered === true, - ); + const desired = normalizeTargets(value as number[], relation.ordered); const current = (await readJunction(field, [itemId], tx)).get(itemId) ?? []; @@ -579,18 +573,18 @@ export const createContentAdvancedStore = < 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 () => [], + diff: async () => Promise.resolve([]), enabled: false, fields: [], - load: async () => ({}), - loadMany: async () => new Map(), + load: async () => Promise.resolve({}), + loadMany: async () => Promise.resolve(new Map()), membershipCondition: () => undefined, - prepareRestore: async (_tx, _itemId, patch) => ({ - missingRelations: [], - patch, - }), - write: async () => [], + prepareRestore: async (_tx, _itemId, patch) => + Promise.resolve({ missingRelations: [], patch }), + write: async () => Promise.resolve([]), }; if (!enabled) return disabled; @@ -675,7 +669,8 @@ export const createContentAdvancedStore = < 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 }); + if (missing.length > 0) + missingRelations.push({ field, ids: missing }); continue; } @@ -689,9 +684,11 @@ export const createContentAdvancedStore = < prepared[field] = (value as ChildValues[]).map(row => { if (typeof row.id === "number" && known.has(row.id)) return row; - const { id: _dropped, ...rest } = row; - - return rest; + // 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"), + ); }); } diff --git a/packages/vitnode/src/content/server/advanced-tables.ts b/packages/vitnode/src/content/server/advanced-tables.ts index fef9d9065..f4f13f3ca 100644 --- a/packages/vitnode/src/content/server/advanced-tables.ts +++ b/packages/vitnode/src/content/server/advanced-tables.ts @@ -256,10 +256,7 @@ export const createContentAdvancedTables = < }); } - const repeatables: Record< - string, - ContentRepeatableChildTable - > = {}; + const repeatables: Record> = {}; for (const entry of advanced.repeatables) { repeatables[entry.field] = createContentRepeatableTable({ contentTypeId, diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index 2fb584253..9e350bf41 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -4,10 +4,10 @@ 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 { contentSearchIndexesCollections } from "../search"; import { emitContentEvent } from "./emit"; import { syncContentLocalizedSearch, syncContentSearch } from "./search-sync"; @@ -98,11 +98,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 +109,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[]; } /** @@ -179,6 +179,13 @@ export const contentEditorialEffects = async ( return { event, search: await syncContentSearch(c, definition, { + // Read back only when a document is actually made of collection values. + // A content type that indexes none - which is every Stage 1-5 one - pays + // for nothing here. + advanced: + model && contentSearchIndexesCollections(definition) + ? await model.service(c).advanced(idOf(outcome.row)) + : undefined, changed: outcome.changed, changedFields: outcome.changedFields, operation: outcome.operation, @@ -188,6 +195,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 52faa1428..84391b550 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -218,7 +218,9 @@ export const createContentEditorialService = < 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 projectRow = ( + row: Record, + ): Record => { const projected: Record = {}; for (const name of generatedColumnNames) { diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index 37c8c04f7..585b76454 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -22,10 +22,7 @@ import { } from "../const"; import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; -import { - isContentRelationCollection, - splitContentFieldPath, -} from "../paths"; +import { isContentRelationCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; import { findContentLanguage } from "./language-resolver"; import { diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 21c062c18..1fe9384ad 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -24,10 +24,7 @@ import { CONTENT_PUBLIC_MAX_PAGE_SIZE, } from "../const"; import { ContentEngineError } from "../errors"; -import { - isContentRelationCollection, - splitContentFieldPath, -} from "../paths"; +import { isContentRelationCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; import { groupPublicLeafPaths } from "../schemas"; import { publicationColumns, publishedCondition } from "./publication"; diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index e30ffb335..f184dee36 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -95,7 +95,10 @@ export const buildFilterCondition = ({ * 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; + membership?: ( + field: string, + filter: ContentRelationFilter, + ) => SQL | undefined; /** Whether `status` is a generated column and therefore filterable. */ publication?: boolean; }): SQL | undefined => { diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index db9c88438..073c121b3 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -78,7 +78,7 @@ const toFieldSnapshot = ( // second, and both have to snapshot identically. const raw = values[contentLeafColumnName(name, leaf)] ?? - (values[name] as Record | null | undefined)?.[leaf] ?? + (values[name] as null | Record | undefined)?.[leaf] ?? null; const snapshot = toSnapshotValue(raw); if (snapshot !== null) allNull = false; @@ -233,7 +233,7 @@ export const projectRevisionSnapshot = ( projected[name] = value === null || typeof value !== "object" || Array.isArray(value) ? null - : pickLeaves(value as Record, leaves); + : pickLeaves(value, leaves); continue; } diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index acbe623ff..98e83658a 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -121,7 +121,10 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { }; permissionGranted = allow; - vi.spyOn(articles, "service").mockReturnValue({ ...service, ...noCollections }); + vi.spyOn(articles, "service").mockReturnValue({ + ...service, + ...noCollections, + }); const app = new OpenAPIHono(); diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 13d260376..1cadc8c87 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"; @@ -75,6 +76,27 @@ 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: a document built from `faq.answer` is built + * from child rows, which are not on the row the mutation gave back. Every + * Stage 1-5 content type indexes none, so this is a boolean check and no query. + */ +const advancedForSearch = async ( + c: Context, + model: AnyContentModel, + row: object, +): Promise | undefined> => { + if (!contentSearchIndexesCollections(model.definition)) return undefined; + + const id = (row as { id?: unknown }).id; + if (typeof id !== "number") return undefined; + + return await model.service(c).advanced(id); +}; + export const buildContentRoutes = < TDefinition extends AnyContentTypeDefinition, P extends string, @@ -468,6 +490,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 +600,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 +680,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..9b4507a89 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( @@ -110,7 +150,7 @@ export const contentSearchDocument = ( // `SearchModel.index` strips HTML from `content` (but never from `title`, // which is why the title is normalized above). content: sources - .map(name => normalize(values[name])) + .map(name => normalize(readSearchValue(values, name))) .filter(value => value !== "") .join("\n\n"), createdAt, diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index c2cdb47c8..ac27065fa 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -7,10 +7,8 @@ import type { AnyContentTypeDefinition } from "../types"; import type { AnyContentModel } from "./model"; import { partitionContentFields } from "../localization"; -import { - contentSearchDocumentId, - contentSearchIndexedFieldNames, -} from "../search"; +import { contentColumnsToValues, contentStorageColumns } from "../paths"; +import { contentSearchDocumentId, contentSearchIndexedPaths } from "../search"; import { listContentLanguages } from "./language-resolver"; import { contentSearchDocument, @@ -23,6 +21,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 +95,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 +153,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; @@ -238,6 +247,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 +260,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 +268,7 @@ const readTranslations = async ( return rows.map(row => ({ languageId: row.languageId as number, - values: row, + values: { ...row, ...contentColumnsToValues(localizedFields, row) }, })); }; @@ -333,7 +346,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 []; } diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index d15b02461..1325aa173 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -36,10 +36,10 @@ import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { orderableColumns } from "../registry"; import { - changedPathsToColumns, buildFilterCondition, buildOrderColumn, buildSearchCondition, + changedPathsToColumns, diffChangedPaths, toInsertColumns, } from "./query"; @@ -110,17 +110,14 @@ export interface ContentUpdateResult { * what makes that true by construction instead of by six call sites remembering. */ export interface ContentRelationMethods { - /** The current targets, in stored order. */ - get: ( - itemId: number, - options?: ContentServiceOptions, - ) => Promise; /** 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, @@ -263,6 +260,10 @@ export interface ContentServiceBase { id: number, options?: ContentServiceOptions, ) => Promise | null>; + findById: ( + id: number, + options?: ContentServiceOptions, + ) => Promise | null>; /** * One record with its advanced collections attached. * @@ -273,10 +274,6 @@ export interface ContentServiceBase { id: number, options?: ContentServiceOptions, ) => Promise | null>; - findById: ( - id: number, - options?: ContentServiceOptions, - ) => Promise | null>; findMany: (args?: ContentFindManyArgs) => Promise<{ edges: ContentListRow[]; pageInfo: ContentPageInfo; @@ -389,7 +386,9 @@ export const createContentService = < * 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 projectRow = ( + row: Record, + ): Record => { const projected: Record = {}; for (const name of generatedColumnNames) { @@ -713,8 +712,7 @@ export const createContentService = < 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 changedCollections = (await store?.diff(tx, id, patch)) ?? []; const changedFields = [...changedPaths, ...changedCollections]; // Nothing actually moved - skip the write so `updatedAt` and the @@ -769,7 +767,7 @@ export const createContentService = < return Array.isArray(value) ? (value as number[]) : []; }; - const write = ( + const write = async ( itemId: number, next: readonly number[], options?: ContentWriteOptions, @@ -827,7 +825,7 @@ export const createContentService = < return Array.isArray(value) ? (value as Record[]) : []; }; - const write = ( + const write = async ( itemId: number, rows: readonly Record[], options?: ContentWriteOptions, @@ -840,7 +838,11 @@ export const createContentService = < return { create: async (itemId, values, options) => - await write(itemId, [...(await read(itemId, options)), values], options), + await write( + itemId, + [...(await read(itemId, options)), values], + options, + ), delete: async (itemId, childId, options) => await write( diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 81d3b812c..363d7b32d 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -171,7 +171,7 @@ 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 as ContentFieldMap; + const declaredFields = definition.fields; const unknownReference = Object.keys(referenceThunks).find( name => declaredFields[name]?.kind !== "relation", ); diff --git a/packages/vitnode/src/content/server/translation-effects.ts b/packages/vitnode/src/content/server/translation-effects.ts index 0226e5ea7..3c1dd9c7b 100644 --- a/packages/vitnode/src/content/server/translation-effects.ts +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -3,10 +3,9 @@ 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"; @@ -72,13 +71,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 +80,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[]; } /** diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index da7c73bf4..9987a5639 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -254,11 +254,13 @@ 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 } + [ + K in keyof TFields as TFields[K] extends { kind: "group" | "repeatable" } ? never - : K]: TFields[K]; + : TFields[K] extends { kind: "relation"; multiple: true } + ? never + : K + ]: TFields[K]; }; /** @@ -348,13 +350,13 @@ export type ContentTableFor = TDefinition extends { export type ContentColumnName = | ContentLeafPath> | ContentSystemField + | (keyof ContentStorageFields> & string) | (TDefinition extends { editorial: { enabled: true } } ? ContentEditorialField : never) | (TDefinition extends { publication: { enabled: true } } ? ContentPublicationField - : never) - | (keyof ContentStorageFields> & string); + : never); /** * One thunk per `relation` field, resolving to the target table's `id`. Missing @@ -400,13 +402,13 @@ export type ContentRepeatableChildTable = PgTableWithColumns<{ columns: BuildColumns< string, { + [K in keyof TFields]: ContentColumnBuilder; + } & { createdAt: NotNull>>; id: PgSerialBuilderInitial; itemId: NotNull>; position: NotNull>; updatedAt: NotNull>>; - } & { - [K in keyof TFields]: ContentColumnBuilder; }, "pg" >; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 9e18bee88..5b11aee4b 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -387,9 +387,9 @@ type ContentGroupValue = Prettify<{ * revision restore matches an historical row against. */ export type ContentRepeatableRow = Prettify< - { id: number } & { + { [K in keyof TFields]: ContentFieldValue; - } + } & { id: number } >; /** @@ -400,7 +400,7 @@ export type ContentRepeatableRow = Prettify< * array order, so nothing carries it explicitly. */ export type ContentRepeatableInputRow = Prettify< - { id?: number } & CreateValuesOf + CreateValuesOf & { id?: number } >; /** @@ -451,7 +451,10 @@ export type ContentFieldPatch = TField extends { fields: infer TInner; kind: "group"; } - ? ApplyNullable>>, TField> + ? ApplyNullable< + Prettify>>, + TField + > : ContentFieldInput; /** @@ -572,8 +575,8 @@ export type ContentRepeatableLeafPath = string & * something a cache tag or an event payload branches on. */ export type ContentChangedPath = - | ContentLeafPath> | (CollectionFieldKeys> & string) + | ContentLeafPath> | (ScalarColumnFieldKeys> & string); /** Field names of a to-many relation. */ @@ -608,11 +611,11 @@ export type ContentRepeatableFieldName = string & * public projections that ask for them, and nowhere else. */ export type ContentAdvancedValues = Prettify<{ - [K in - | ContentRelationCollectionName - | ContentRepeatableFieldName]: ContentFieldValue< - ContentFieldsOf[K] - >; + [ + K in + | ContentRelationCollectionName + | ContentRepeatableFieldName + ]: ContentFieldValue[K]>; }>; /** @@ -774,9 +777,11 @@ type ContentIndexColumn< TFields, TPublication extends boolean, TEditorial extends boolean, -> = string & - (ContentAddressableColumn | - ContentLeafPath); +> = ( + | ContentAddressableColumn + | ContentLeafPath +) & + string; /** * Stored shape. Non-generic for the same reason as @@ -999,14 +1004,14 @@ export type ContentSearchTitleField< * index and translation table names already are. */ export interface ContentRelationJunction { - /** `(itemId, position)`, so an ordered relation has no duplicate slots. */ - positionIndexName: string; /** The source field this junction belongs to. */ field: string; - /** `
__related_idx` - the reverse lookup and the FK's index. */ - relatedIndexName: 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; } @@ -1661,19 +1666,19 @@ export type FilterableContentFieldName = FieldNamesOfKind< >; /** {@link FieldNamesOfKind} over every field, shared and localized alike. */ -type AnyFieldNamesOfKind = string & - Exclude< - { - [ - K in keyof ContentFieldsOf - ]: ContentFieldsOf[K] extends { - kind: TKind; - } - ? K - : never; - }[keyof ContentFieldsOf], - CollectionFieldKeys> - >; +type AnyFieldNamesOfKind = Exclude< + { + [ + K in keyof ContentFieldsOf + ]: ContentFieldsOf[K] extends { + kind: TKind; + } + ? K + : never; + }[keyof ContentFieldsOf], + CollectionFieldKeys> +> & + string; /** * Field names a **public** filter may name. @@ -1710,15 +1715,14 @@ export interface ContentRelationFilter { } export type ContentFilterInput = Partial< - (TDefinition extends { publication: { enabled: true } } - ? { status: ContentPublicationStatus } - : Record) & { - [K in ContentRelationCollectionName]: ContentRelationFilter; - } & { - [K in FilterableContentFieldName]: ContentFieldInput< - ContentFieldsOf[K] - >; - } + Record, ContentRelationFilter> & + (TDefinition extends { publication: { enabled: true } } + ? { status: ContentPublicationStatus } + : Record) & { + [K in FilterableContentFieldName]: ContentFieldInput< + ContentFieldsOf[K] + >; + } >; /** @@ -1802,11 +1806,15 @@ type PublicPathOwner = TName extends `${infer TOwner}.${string}` ? TOwner : never; -type PublicPathLeaf = - TName extends `${TOwner}.${infer TLeaf}` ? TLeaf : 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; +type PublicFlatName = TName extends `${string}.${string}` + ? never + : TName; /** * The nested half of a public response: one key per group or repeatable that @@ -1817,21 +1825,23 @@ type PublicFlatName = TName extends `${string}.${string}` ? never : TName * paths the allowlist names. */ type ContentPublicNested = { - [TOwner in PublicPathOwner & keyof TFields]: TFields[TOwner] extends { + [TOwner in keyof TFields & PublicPathOwner]: TFields[TOwner] extends { fields: infer TInner; kind: "repeatable"; } ? Prettify< - { id: number } & { - [TLeaf in PublicPathLeaf & - keyof TInner]: ContentFieldValue; - } + { + [ + TLeaf in keyof TInner & PublicPathLeaf + ]: ContentFieldValue; + } & { id: number } >[] : TFields[TOwner] extends { fields: infer TInner; kind: "group" } ? ApplyNullable< Prettify<{ - [TLeaf in PublicPathLeaf & - keyof TInner]: ContentFieldValue; + [ + TLeaf in keyof TInner & PublicPathLeaf + ]: ContentFieldValue; }>, TFields[TOwner] > @@ -1851,9 +1861,9 @@ export type ContentPublicSelect = Prettify< ContentFieldsOf, ContentPublicFieldName > & { - [K in PublicFlatName< - ContentPublicFieldName - >]: ContentPublicValue, K>; + [ + K in PublicFlatName> + ]: ContentPublicValue, K>; } >; @@ -1895,12 +1905,11 @@ export type ContentPublicListRow = * narrower check is the runtime allowlist. */ export type ContentPublicFilterInput = Partial< - { - [ - K in ContentPublicFieldName & - ContentRelationCollectionName - ]: ContentRelationFilter; - } & { + Record< + ContentPublicFieldName & + ContentRelationCollectionName, + ContentRelationFilter + > & { [ K in ContentPublicFieldName & PublicFilterableContentFieldName @@ -1917,5 +1926,4 @@ export type ContentPublicFilterInput = Partial< * allowlist error. */ export type ContentPublicOrderableFieldName = - | "publishedAt" - | PublicFlatName>; + "publishedAt" | PublicFlatName>; From 996f542a17d2798cf2360f407f0812691bd53c14 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 11:46:06 +0200 Subject: [PATCH 04/10] feat(content): add the advanced-modeling example and its PostgreSQL suite `example.advanced-article` exercises every Stage 6 shape at once: an unordered to-many relation, an ordered **self**-relation, a localized group, a shared group and a repeatable - on a content type that is also localized, published, editorial, public and searchable. Migration 0031 is what the definition compiles to, generated rather than written: two junction tables, one child table, four flattened group columns across the base and translation tables, and the constraints that make ordering and integrity facts about Postgres rather than about service code. The new suite is the half a mock cannot show. A duplicate junction row is a 23505, a category still in use is a 23503, a cascade really does take the child rows, and five concurrency tests prove exactly one writer wins a race - relation vs relation, relation vs scalar, repeatable vs repeatable, reorder vs reorder - while two different records stay independent. Three real bugs fell out of running it: to-many relations were being asked for a base-table foreign key they do not have, the translation model was selecting a group rather than its leaf columns, and the localized public join was looking a canonical path up on an aliased table that only carries column keys. Co-Authored-By: Claude Opus 5 (1M context) --- .../0031_add_example_advanced_articles.sql | 75 + apps/docs/migrations/meta/0031_snapshot.json | 3868 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + packages/vitnode/src/content/define.ts | 4 + packages/vitnode/src/content/index.ts | 2 + packages/vitnode/src/content/paths.ts | 51 +- .../src/content/server/advanced-store.ts | 5 +- .../src/content/server/advanced-tables.ts | 30 +- .../server/localized-public-service.ts | 28 +- packages/vitnode/src/content/server/query.ts | 4 +- .../vitnode/src/content/server/references.ts | 5 + .../src/content/server/revision-snapshot.ts | 2 +- .../vitnode/src/content/server/service.ts | 2 +- .../src/content/server/translation-model.ts | 43 +- .../src/content/server/translation-table.ts | 24 +- packages/vitnode/src/content/server/types.ts | 12 +- packages/vitnode/src/content/types.ts | 8 +- .../src/api/modules/admin/admin.module.ts | 8 +- plugins/example/src/config.api.ts | 2 + plugins/example/src/const.ts | 6 + .../example/src/content/advanced-article.ts | 190 + .../example/src/database/advanced-articles.ts | 36 + .../src/database/advanced-postgres.test.ts | 1021 +++++ .../src/database/advanced-tables.test.ts | 196 + plugins/example/src/database/tables.test.ts | 31 + plugins/example/vitest.config.ts | 4 + 26 files changed, 5573 insertions(+), 91 deletions(-) create mode 100644 apps/docs/migrations/0031_add_example_advanced_articles.sql create mode 100644 apps/docs/migrations/meta/0031_snapshot.json create mode 100644 plugins/example/src/content/advanced-article.ts create mode 100644 plugins/example/src/database/advanced-articles.ts create mode 100644 plugins/example/src/database/advanced-postgres.test.ts create mode 100644 plugins/example/src/database/advanced-tables.test.ts 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/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index d879dafba..2ef3dc1ee 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -460,6 +460,10 @@ 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)); } 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/paths.ts b/packages/vitnode/src/content/paths.ts index ab6361b75..e3ba62363 100644 --- a/packages/vitnode/src/content/paths.ts +++ b/packages/vitnode/src/content/paths.ts @@ -12,7 +12,7 @@ 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 `seo_title`, which only Postgres speaks. Every subsystem + * 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 @@ -46,10 +46,11 @@ export const isContentFieldPath = (path: string): boolean => /** * `("seo", "title")` -> `"seoTitle"`. * - * camelCase because the Drizzle client runs with `casing: "camelCase"`, which is - * what turns it into `seo_title` in SQL - the same rule every hand-written - * VitNode column already follows. Building the SQL identifier here instead would - * make the generated table the only one in the codebase that does not. + * 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)}`; @@ -171,44 +172,20 @@ export const contentStorageColumns = ( const inner = contentInnerFields(fieldValue); for (const [leaf, leafValue] of Object.entries(inner)) { - // A leaf of an optional or nullable group has to be storable as NULL: - // omitting the group has to write *something*, and the only honest - // something is nothing. See `relaxLeafNullability`. - columns[contentLeafColumnName(name, leaf)] = relaxLeafNullability( - fieldValue, - leafValue, - ); + // 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; }; -/** - * The column nullability of one leaf. - * - * A leaf inside a group that may be absent (`required: false`) or explicitly - * blanked (`nullable: true`) is stored in a nullable column whatever its own - * `nullable` says, because both of those states have to be representable. The - * leaf's own `nullable` still governs what a *request* may send: `seo: { title: - * null }` is rejected for a non-nullable leaf even though the column would take - * it, which is what keeps "the group is absent" and "the title was cleared" two - * different things. - */ -export const relaxLeafNullability = ( - group: ContentFieldDescriptor, - leaf: ContentFieldDescriptor, -): ContentFieldDescriptor => { - if (group.required && !group.nullable) return leaf; - if (leaf.nullable) return leaf; - - // The spread widens `nullable` past what a few descriptor interfaces pin - - // `repeatable` declares it literally `false` - but a leaf is only ever one of - // `CONTENT_ADVANCED_LEAF_KINDS`, which `resolveContentAdvanced` has already - // proven, and every one of those takes `nullable: boolean`. - return { ...leaf, nullable: true } as ContentFieldDescriptor; -}; - export interface ContentAdvancedPartition { /** Group descriptors, by field name. */ groups: ContentFieldMap; diff --git a/packages/vitnode/src/content/server/advanced-store.ts b/packages/vitnode/src/content/server/advanced-store.ts index c385eadda..a9e30c088 100644 --- a/packages/vitnode/src/content/server/advanced-store.ts +++ b/packages/vitnode/src/content/server/advanced-store.ts @@ -239,8 +239,11 @@ export const createContentAdvancedStore = < 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 === "related_item_id"), + reference.columns.some(column => column.name === "relatedItemId"), )?.foreignColumns[0] ?? null) : null; diff --git a/packages/vitnode/src/content/server/advanced-tables.ts b/packages/vitnode/src/content/server/advanced-tables.ts index f4f13f3ca..0d62b1001 100644 --- a/packages/vitnode/src/content/server/advanced-tables.ts +++ b/packages/vitnode/src/content/server/advanced-tables.ts @@ -51,14 +51,14 @@ import { buildContentColumn } from "./column-builders"; * * ```text * example_articles_categories - * item_id -> example_articles.id ON DELETE CASCADE - * related_item_id -> example_categories.id ON DELETE - * position integer NOT NULL - * created_at timestamp NOT NULL DEFAULT now() + * 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 (item_id, related_item_id) - * UNIQUE (item_id, position) - * INDEX (related_item_id) + * PRIMARY KEY (itemId, relatedItemId) + * UNIQUE (itemId, position) + * INDEX (relatedItemId) * ``` * * `itemId` always cascades: the references *belong to* the source record, so @@ -141,15 +141,15 @@ export const createContentJunctionTable = ({ * * ```text * example_articles_faq - * id serial PRIMARY KEY - * item_id -> example_articles.id ON DELETE CASCADE - * position integer NOT NULL - * created_at timestamp NOT NULL DEFAULT now() - * updated_at timestamp NOT NULL DEFAULT now() - * question varchar(255) NOT NULL - * answer text NOT NULL + * 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 (item_id, position) + * UNIQUE (itemId, position) * ``` * * `id` is a `serial` of its own and **not** `(itemId, position)`. Position is diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index 585b76454..d42eba815 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -184,6 +184,11 @@ export const createContentLocalizedPublicService = < 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. @@ -297,13 +302,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, diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index f184dee36..41344bd18 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -310,8 +310,8 @@ export const diffChangedPaths = ( /** * The column patch for a set of changed paths. * - * Only the leaves that moved are written, so an `UPDATE` touches `seo_title` and - * leaves `seo_description` alone - which is what a partial group update has to + * 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 = ( 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 073c121b3..a7ceb78cd 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -51,7 +51,7 @@ const toSnapshotValue = (value: unknown): ContentSnapshotScalar => { * 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 `seo_title`, + * 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. diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 1325aa173..148843604 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -381,7 +381,7 @@ export const createContentService = < * A database row, in the logical shape callers see. * * The generated columns pass straight through; the declared fields go through - * `contentColumnsToValues`, which folds `seo_title` and `seo_description` back + * `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. 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 9987a5639..0fd9dc48c 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -301,22 +301,12 @@ type LeafDescriptorAt = ? TOwner extends keyof TFields ? TFields[TOwner] extends { fields: infer TInner } ? TLeaf extends keyof TInner - ? RelaxedLeaf + ? TInner[TLeaf] : never : never : never : never; -/** The type-level twin of `relaxLeafNullability`. */ -type RelaxedLeaf = TGroup extends { - nullable: false; - required: true; -} - ? TLeaf - : TLeaf extends { nullable: true } - ? TLeaf - : Omit & { nullable: true }; - /** * The base `pgTable` for one definition. * diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 5b11aee4b..2b0954852 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -232,8 +232,8 @@ export interface ContentRelationField< * 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, named - * `seoTitle` and therefore `seo_title` in SQL. There is no JSONB here: a + * 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. * @@ -542,7 +542,7 @@ type ScalarColumnFieldKeys = Exclude< * * One representation, used by `changedFields`, validation errors, index * declarations, `publicApi.fields`, `search.contentFields` and revision - * diagnostics alike. The generated column name (`seo_title`) is an internal + * diagnostics alike. The generated column name (`seoTitle`) is an internal * mapping and never appears in any of them. */ export type ContentLeafPath = string & @@ -1502,7 +1502,7 @@ export interface ResolvedContentAdvancedConfig { /** One group leaf: its canonical path and the column it is stored in. */ export interface ContentLeafColumn { - /** `seoTitle`, which Drizzle's camelCase casing emits as `seo_title`. */ + /** `seoTitle` - the generated column, in the same camelCase every other one uses. */ columnName: string; /** The owning group's name. */ group: string; 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..5a0a0ef44 --- /dev/null +++ b/plugins/example/src/content/advanced-article.ts @@ -0,0 +1,190 @@ +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**. `target` is a thunk, + * which is what makes pointing at this very content type ordinary rather than + * special: the reference is resolved on first read, exactly like two content + * types referring to each other. `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, + target: () => advancedArticleContentType, + }), + + // 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..e07c5bd46 --- /dev/null +++ b/plugins/example/src/database/advanced-articles.ts @@ -0,0 +1,36 @@ +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, + // The self-relation points at this content type's own table, which is + // being declared right here. The thunk is what makes that legal: Drizzle + // leaves it unevaluated until it serializes the table, so there is no + // circular initialization to trip over. + relatedArticles: () => advancedArticleContent.table.id, + }, + }, +); + +// 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..4255d1cd5 --- /dev/null +++ b/plugins/example/src/database/advanced-postgres.test.ts @@ -0,0 +1,1021 @@ +import type { Context } from "hono"; + +import { + ContentAdvancedInputError, + ContentRevisionNotRestorable, + ContentVersionConflict, +} from "@vitnode/core/content"; +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 { 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; + +/** + * 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[] = []; + +const createArticle = async ( + values: Record = {}, +): Promise<{ id: number; version: number }> => { + const outcome = await advancedArticleContent + .editorialService?.(context, { pluginId: CONFIG_PLUGIN.pluginId }) + .create({ ...values } as never, { 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 }); + + 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 === "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); + }); + + // ------------------------------------------------------------------------- + // 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("23503"); + expect(article.id).toBeGreaterThan(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" }, + } as never); + + const second = await localized.create({ + shared: {}, + translation: { title: "Another" }, + } as never); + + 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" } as never, + { expectedVersion: 1 }, + ), + ).rejects.toThrow(); + + await expect( + translations.create(second.row.id, "pl", { + slug: "shared-title", + title: "Polski", + } as never), + ).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]], + { expectedVersion: article.version }, + ); + + 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] } as never, + { 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] } as never, + { 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] } as never, + { 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], { + expectedVersion: article.version, + }), + ).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, + { expectedVersion: 1 }, + ); + + 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] } } as never, + }); + + 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 as number); + + await service().repeatable.faq.reorder(article.id, [ids[1], ids[0]], { + expectedVersion: 1, + }); + + 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 as number; + + const result = await service().repeatable.faq.set( + article.id, + [ + { answer: "A revised", id: keptId, question: "Keep?" }, + { answer: "C", question: "New?" }, + ], + { expectedVersion: 1 }, + ); + + 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 as number, question: "Question?" }], + { expectedVersion: 1 }, + ), + ).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 })), + { expectedVersion: 1 }, + ); + + 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 } } as never, + { 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" }, + } as never); + + 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]] } as never, + { actor: ACTOR, expectedVersion: article.version }, + ), + editorial(rivalContext)?.update( + article.id, + { categories: [categoryIds[2]] } as never, + { 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] as PromiseRejectedResult).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]] } as never, + { actor: ACTOR, expectedVersion: article.version }, + ), + editorial(rivalContext)?.update( + article.id, + { syndication: { priority: 1 } } as never, + { 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?" }] } as never, + { actor: ACTOR, expectedVersion: 1 }, + ), + editorial(rivalContext)?.update( + article.id, + { faq: [{ answer: "Theirs", question: "Theirs?" }] } as never, + { 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?" }, + ], + }); + const ids = (await service().repeatable.faq.list(article.id)).map( + row => row.id as number, + ); + + await Promise.allSettled([ + editorial()?.update( + article.id, + { + faq: [ids[2], ids[1], ids[0]].map(id => ({ id })), + } as never, + { actor: ACTOR, expectedVersion: 1 }, + ), + editorial(rivalContext)?.update( + article.id, + { + faq: [ids[1], ids[0], ids[2]].map(id => ({ id })), + } as never, + { 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]); + }); + + 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]] } as never, + { actor: ACTOR, expectedVersion: first.version }, + ), + editorial(rivalContext)?.update( + second.id, + { categories: [categoryIds[1]] } as never, + { 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]]); + }); + }); + + // ------------------------------------------------------------------------- + // 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] } as never, + { 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] } as never, + { 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: [] } as never, + { 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: [] } as never, + { 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 } } as never, + { 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", + }, + } as never); + + await publish(created.row.id, created.row.version); + await advancedArticleContent + .translationEditorialService?.(context, { + pluginId: CONFIG_PLUGIN.pluginId, + }) + .publish(created.row.id, "en", { actor: ACTOR }); + + const row = await advancedArticleContent + .publicService?.(context) + .findById(created.row.id, { 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-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/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: { From 6bfb3d715f3a478625145a0ea19d6da346507735 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 11:53:47 +0200 Subject: [PATCH 05/10] feat(content): declare self-relations with `self: true` `target: () => thisContentType` makes a definition's own inferred type 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 Stage 6 adds would disappear, with no error to say so. `self: true` carries no reference at all, and `defineContentType` rebinds the thunk on a *copy* of the field map once the definition exists - so a descriptor const shared by two content types cannot end up pointing both relations at whichever was declared last. Also adds the definition-time and type-level suites: 31 runtime cases covering every advanced-field rule, and 28 type assertions whose interesting half is negative - a group must not leak its flattened column names, a collection must not appear on a list row, a private leaf must not appear in a public response, an index must not accept a repeatable path. One of them pins the variance of `ContentModel`, which a `UnionToIntersection` in the table types had already broken once. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitnode/src/content/advanced.test-d.ts | 314 ++++++++++ packages/vitnode/src/content/advanced.test.ts | 548 ++++++++++++++++++ packages/vitnode/src/content/define.ts | 63 +- packages/vitnode/src/content/fields.ts | 34 +- packages/vitnode/src/content/types.ts | 17 +- .../example/src/content/advanced-article.ts | 15 +- 6 files changed, 973 insertions(+), 18 deletions(-) create mode 100644 packages/vitnode/src/content/advanced.test-d.ts create mode 100644 packages/vitnode/src/content/advanced.test.ts 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..a7aa3ca90 --- /dev/null +++ b/packages/vitnode/src/content/advanced.test-d.ts @@ -0,0 +1,314 @@ +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", + }); + }); +}); + +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..e0f2d73b5 --- /dev/null +++ b/packages/vitnode/src/content/advanced.test.ts @@ -0,0 +1,548 @@ +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 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/define.ts b/packages/vitnode/src/content/define.ts index 2ef3dc1ee..a71086c92 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, @@ -198,7 +199,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) { @@ -1486,7 +1494,14 @@ 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; + 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.", { @@ -1621,7 +1636,18 @@ 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< @@ -1629,7 +1655,8 @@ export const defineContentType = < 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< @@ -1670,4 +1697,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/fields.ts b/packages/vitnode/src/content/fields.ts index 3fbc84657..5929d0ad6 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -15,6 +15,8 @@ import type { ContentUserField, } from "./types"; +import { ContentEngineError } from "./errors"; + interface SharedArgs< TRequired extends boolean = false, TNullable extends boolean = false, @@ -222,18 +224,34 @@ 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. + */ +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({ target: () => articleContentType, multiple: true, ordered: true }) + * related: field.relation({ self: true, multiple: true, ordered: true }) * ``` * - * `target` is a thunk, which is what makes the third line legal inside - * `articleContentType` itself: the reference is resolved on first read rather - * than at declaration, so a self-relation needs nothing the other two do not. + * `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 @@ -254,8 +272,10 @@ const relation = < multiple?: TMultiple; onDelete?: ContentOnDelete; ordered?: TOrdered; - target: () => AnyContentTypeDefinition; - }, + } & ( + | { self: true; target?: never } + | { self?: false; target: () => AnyContentTypeDefinition } + ), ): ContentRelationField => ({ ...args, ...shared(args), @@ -266,6 +286,8 @@ const relation = < multiple: (args.multiple ?? false) as TMultiple, onDelete: args.onDelete ?? "restrict", ordered: (args.ordered ?? false) as TOrdered, + self: args.self === true, + target: args.target ?? unboundSelfTarget, }); /** diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 2b0954852..b2c1ac7db 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -224,7 +224,22 @@ export interface ContentRelationField< * something the author chose. */ ordered: TOrdered; - /** Thunk so two content types - or one content type and itself - can refer. */ + /** + * 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. + */ + self: boolean; + /** Thunk so two content types can refer to each other. */ target: () => AnyContentTypeDefinition; } diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index 5a0a0ef44..b3f02af42 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -10,12 +10,13 @@ import { categoryContentType } from "./category"; * 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**. `target` is a thunk, - * which is what makes pointing at this very content type ordinary rather than - * special: the reference is resolved on first read, exactly like two content - * types referring to each other. `ordered: true` keeps the author's order, and - * `UNIQUE (itemId, position)` is what makes that order a fact rather than a - * hope. + * - **`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 @@ -76,7 +77,7 @@ export const advancedArticleContentType = defineContentType({ multiple: true, onDelete: "cascade", ordered: true, - target: () => advancedArticleContentType, + self: true, }), // Localized whole: both leaves move to the translation table together, with From 5ce0aa4326bb40fe9359acedd570001ebf619e81 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 12:03:19 +0200 Subject: [PATCH 06/10] feat(admin): add the advanced modeling editors Three editors, all rendered through the AutoForm seam `ContentField` already uses - so a leaf inside a group is the same input a top-level field of that kind renders, and Stage 6 grows no second form system. - **Group** renders as a `fieldset`/`legend`, so a screen reader announces "SEO" with every leaf inside it - which is what tells `SEO / Title` from `Article / Title`. A nullable group gets a switch, because `seo: null` is a different stored state from every leaf happening to be empty. - **Repeatable** gets Add / Edit / Remove / Move up / Move down as labelled buttons. Drag-and-drop may be layered on later but can never be the only way to reorder. React keys come from a client-side key that is stable for a row's life, never from `id` - an unsaved row has no `id`, and keying off it would make React reuse one row's DOM state for another's value. - **To-many relation** wraps the existing async combobox as an "add one" control. Reorder buttons appear only for an `ordered: true` relation, since the engine sorts an unordered set by target id whatever the editor does. Each editor controls the nested value the API takes, so nothing is flattened on submit and nothing re-nested on load. The revision diff learned to render the three shapes too, rather than `[object Object]`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/admin/spec.ts | 130 ++++++- packages/vitnode/src/locales/en.json | 11 + .../content/actions/history/revision-diff.tsx | 42 +++ .../content/lib/advanced-fields.test.tsx | 355 ++++++++++++++++++ .../views/content/lib/field-component.tsx | 31 ++ .../admin/views/content/lib/group-field.tsx | 100 +++++ .../admin/views/content/lib/leaf-field.tsx | 69 ++++ .../views/content/lib/relation-set-field.tsx | 160 ++++++++ .../views/content/lib/repeatable-field.tsx | 198 ++++++++++ 9 files changed, 1094 insertions(+), 2 deletions(-) create mode 100644 packages/vitnode/src/views/admin/views/content/lib/advanced-fields.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/group-field.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/leaf-field.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/repeatable-field.tsx 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/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/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..2d86bb39c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/advanced-fields.test.tsx @@ -0,0 +1,355 @@ +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 () => await 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", async () => { + 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 () => + await 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..da1ff2268 --- /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 nextKey = 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]} + /> + ))} +
    +
  • + ); + })} +
+ + +
+ ); +}; From 6d8c27f75640f7182ff7da7f4d415e60226cc75d Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 12:16:31 +0200 Subject: [PATCH 07/10] docs(content): document Advanced Modeling Seven pages under `dev/content-engine/`, registered in `meta.json` after the localization set: - **advanced-modeling** - the three shapes, the canonical-path vocabulary, and why a content type that declares none is unchanged. - **relations** - to-one, to-many, ordered, self, the junction table's constraints, `onDelete` per direction, and the membership filter. - **structured-fields** - leaves, canonical paths, partial updates, the four nullability states and the two rules that keep them distinguishable, and why localization belongs to the group. - **repeatable-fields** - the child table, identity vs position, the two-pass reorder, the atomic form save, and the one lock. - **advanced-modeling-public-api** - leaf-level allowlisting, relations as identifiers, and why there is no expansion to configure. - **advanced-modeling-migrations** - what is generated, and two existing-data migrations written so the destructive statement is in a *different file* from the copy, with a verification step between. - **advanced-modeling-limitations** - every refusal, with the reason and the thing to do instead. `fields.mdx` gains the three new rows and points at them. Co-Authored-By: Claude Opus 5 (1M context) --- .../advanced-modeling-limitations.mdx | 142 ++++++++++++ .../advanced-modeling-migrations.mdx | 204 ++++++++++++++++++ .../advanced-modeling-public-api.mdx | 132 ++++++++++++ .../dev/content-engine/advanced-modeling.mdx | 150 +++++++++++++ .../docs/dev/content-engine/fields.mdx | 43 +++- .../content/docs/dev/content-engine/meta.json | 7 + .../docs/dev/content-engine/relations.mdx | 181 ++++++++++++++++ .../dev/content-engine/repeatable-fields.mdx | 173 +++++++++++++++ .../dev/content-engine/structured-fields.mdx | 185 ++++++++++++++++ .../vitnode/src/content/advanced.test-d.ts | 4 +- packages/vitnode/src/content/advanced.test.ts | 12 +- .../content/lib/advanced-fields.test.tsx | 45 ++-- .../views/content/lib/repeatable-field.tsx | 6 +- .../src/database/advanced-postgres.test.ts | 88 ++++---- 14 files changed, 1307 insertions(+), 65 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/advanced-modeling-limitations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/advanced-modeling-migrations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/advanced-modeling-public-api.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/relations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/structured-fields.mdx 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..7d1853f44 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx @@ -0,0 +1,150 @@ +--- +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 - `service.relations.categories.add(...)`, +`service.repeatable.faq.reorder(...)` - and every one of them is a wrapper over +this same call, so none of them can drift from it. + +## 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..f47952877 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/relations.mdx @@ -0,0 +1,181 @@ +--- +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, +}), +``` + +`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 + +```ts +await service.relations.categories.get(7); // [2, 5, 9] +await service.relations.categories.set(7, [2, 5], opts); +await service.relations.categories.add(7, 12, opts); +await service.relations.categories.remove(7, 5, opts); +await service.relations.categories.reorder(7, [9, 2], opts); +``` + +Every one of them is a wrapper over `update`, so each inherits the same rules: + +- **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. +- **Optimistic locking.** On an editorial content type, `expectedVersion` guards + the source record's version, and the guard runs *before* a single junction row + is touched. A writer that loses the race leaves the relations exactly as it + found them. +- **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.** 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..9d7c9028d --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx @@ -0,0 +1,173 @@ +--- +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 service.repeatable.faq.set( + 7, + [ + { id: 11, question: "Kept?", answer: "Yes" }, + { question: "New?", answer: "Also yes" }, + ], + { 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, and +each is a wrapper over the same call: + +```ts +await service.repeatable.faq.list(7); +await service.repeatable.faq.create(7, { question: "…", answer: "…" }, opts); +await service.repeatable.faq.update(7, 11, { answer: "…" }, opts); +await service.repeatable.faq.delete(7, 11, opts); +await service.repeatable.faq.reorder(7, [15, 11], opts); +``` + +`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/packages/vitnode/src/content/advanced.test-d.ts b/packages/vitnode/src/content/advanced.test-d.ts index a7aa3ca90..307a42099 100644 --- a/packages/vitnode/src/content/advanced.test-d.ts +++ b/packages/vitnode/src/content/advanced.test-d.ts @@ -162,7 +162,9 @@ describe("create values", () => { describe("update values", () => { it("takes one leaf of a group without the others", () => { - assertType>({ seo: { description: "Just this" } }); + assertType>({ + seo: { description: "Just this" }, + }); }); it("takes a collection whole", () => { diff --git a/packages/vitnode/src/content/advanced.test.ts b/packages/vitnode/src/content/advanced.test.ts index e0f2d73b5..c982b56f8 100644 --- a/packages/vitnode/src/content/advanced.test.ts +++ b/packages/vitnode/src/content/advanced.test.ts @@ -263,7 +263,9 @@ describe("groups", () => { fields: { name: field.text({ required: true }), meta: field.group({ - fields: { owner: field.relation({ target: () => target }) } as never, + fields: { + owner: field.relation({ target: () => target }), + } as never, }), }, }), @@ -315,7 +317,9 @@ describe("groups", () => { ...base, fields: { name: field.text({ required: true }), - seo: field.group({ fields: { title: field.text({ required: true }) } }), + seo: field.group({ + fields: { title: field.text({ required: true }) }, + }), }, }), ).toThrow(/every leaf needs a value it can fall back to/); @@ -414,7 +418,9 @@ describe("repeatables", () => { ...base, fields: { faq: field.repeatable({ - fields: { question: field.text({ localized: true, required: true }) }, + fields: { + question: field.text({ localized: true, required: true }), + }, }), name: field.text({ required: 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 index 2d86bb39c..4ed234434 100644 --- 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 @@ -29,7 +29,7 @@ let latest: FieldValues = {}; const Harness = ({ initial, - loadOptions = async () => await Promise.resolve([]), + loadOptions = async () => Promise.resolve([]), spec, }: { initial?: unknown; @@ -129,7 +129,9 @@ const categoriesSpec: ContentFormFieldSpec = { describe("group editor", () => { it("renders a labelled section with one input per leaf", () => { - render(); + render( + , + ); // A `fieldset`/`legend`, so a screen reader announces "SEO" with every leaf // - which is what tells `SEO / Title` from `Article / Title`. @@ -156,7 +158,9 @@ describe("group editor", () => { }); it("turns a nullable group off as null, not as an empty object", async () => { - render(); + render( + , + ); fireEvent.click(screen.getByRole("switch")); @@ -168,7 +172,12 @@ describe("group editor", () => { }); it("shows no switch for a group that cannot be null", () => { - render(); + render( + , + ); expect(screen.queryByRole("switch")).toBeNull(); }); @@ -191,7 +200,7 @@ describe("repeatable editor", () => { }); }); - it("stops at the declared maximum", async () => { + it("stops at the declared maximum", () => { render( { 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], - ); + expect( + (latest.value as { id: number }[]).map(row => row.id), + ).toStrictEqual([2, 1]); }); }); @@ -298,14 +307,18 @@ describe("repeatable editor", () => { describe("to-many relation picker", () => { const loadOptions = async () => - await Promise.resolve([ + 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 @@ -317,7 +330,11 @@ describe("to-many relation picker", () => { it("removes a target without touching the others", async () => { render( - , + , ); fireEvent.click(screen.getByRole("button", { name: "list.remove:1" })); @@ -329,7 +346,11 @@ describe("to-many relation picker", () => { it("offers no reorder controls for an unordered relation", () => { render( - , + , ); // The engine stores an unordered set in ascending target-id order whatever 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 index da1ff2268..b1414a248 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/repeatable-field.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/repeatable-field.tsx @@ -65,7 +65,7 @@ export const ContentRepeatableField = ({ const legendId = `content-repeatable-${spec.name}`; // Monotonic, so a row added and removed and added again never reuses a key. - const nextKey = React.useRef(0); + const nextKeyRef = React.useRef(0); const commit = (next: EditorRow[]) => { field.onChange(next.map(row => row.values)); @@ -181,10 +181,10 @@ export const ContentRepeatableField = ({ className="mt-4" disabled={rows.length >= max} onClick={() => { - nextKey.current += 1; + nextKeyRef.current += 1; // No `id`: that is the whole write protocol. A row without one is // created; a row with one is updated in place and keeps it. - commit([...rows, { key: `new-${nextKey.current}`, values: {} }]); + commit([...rows, { key: `new-${nextKeyRef.current}`, values: {} }]); }} size="sm" type="button" diff --git a/plugins/example/src/database/advanced-postgres.test.ts b/plugins/example/src/database/advanced-postgres.test.ts index 4255d1cd5..b2a4ab790 100644 --- a/plugins/example/src/database/advanced-postgres.test.ts +++ b/plugins/example/src/database/advanced-postgres.test.ts @@ -125,7 +125,7 @@ const createArticle = async ( ): Promise<{ id: number; version: number }> => { const outcome = await advancedArticleContent .editorialService?.(context, { pluginId: CONFIG_PLUGIN.pluginId }) - .create({ ...values } as never, { actor: ACTOR }); + .create({ ...values }, { actor: ACTOR }); if (!outcome) throw new Error("create returned nothing"); return { id: outcome.row.id, version: outcome.version }; @@ -177,7 +177,10 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { if (key === "core") { return { contentModels: [ - { model: advancedArticleContent, pluginId: CONFIG_PLUGIN.pluginId }, + { + model: advancedArticleContent, + pluginId: CONFIG_PLUGIN.pluginId, + }, { model: categoryContent, pluginId: CONFIG_PLUGIN.pluginId }, ], i18n: { @@ -336,12 +339,12 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { await localized.create({ shared: {}, translation: { title: "Shared Title" }, - } as never); + }); const second = await localized.create({ shared: {}, translation: { title: "Another" }, - } as never); + }); const translations = advancedArticleContent.translationService?.(context); if (!translations) throw new Error("no translation service"); @@ -352,7 +355,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { translations.update( second.row.id, "en", - { slug: "shared-title" } as never, + { slug: "shared-title" }, { expectedVersion: 1 }, ), ).rejects.toThrow(); @@ -361,7 +364,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { translations.create(second.row.id, "pl", { slug: "shared-title", title: "Polski", - } as never), + }), ).resolves.toBeTruthy(); }); }); @@ -392,7 +395,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { await editorial()?.update( first.id, - { relatedArticles: [third.id, second.id] } as never, + { relatedArticles: [third.id, second.id] }, { actor: ACTOR, expectedVersion: first.version }, ); @@ -412,7 +415,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const set = await editorial()?.update( article.id, - { relatedArticles: [a, b] } as never, + { relatedArticles: [a, b] }, { actor: ACTOR, expectedVersion: 1 }, ); if (!set) throw new Error("update returned nothing"); @@ -421,7 +424,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { // per-row UPDATE would break against `UNIQUE (itemId, position)`. await editorial()?.update( article.id, - { relatedArticles: [b, a] } as never, + { relatedArticles: [b, a] }, { actor: ACTOR, expectedVersion: set.version }, ); @@ -472,7 +475,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { await createArticle({ categories: [categoryIds[0]] }); const found = await service().findMany({ - filters: { categories: { contains: categoryIds[1] } } as never, + filters: { categories: { contains: categoryIds[1] } }, }); expect(found.edges.map(edge => edge.id)).toStrictEqual([matching.id]); @@ -548,7 +551,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { await expect( service().repeatable.faq.set( mine.id, - [{ answer: "A", id: stolen.id as number, question: "Question?" }], + [{ answer: "A", id: stolen.id, question: "Question?" }], { expectedVersion: 1 }, ), ).rejects.toBeInstanceOf(ContentAdvancedInputError); @@ -600,7 +603,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const result = await editorial()?.update( article.id, - { syndication: { priority: 3 } } as never, + { syndication: { priority: 3 } }, { actor: ACTOR, expectedVersion: article.version }, ); @@ -636,7 +639,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const created = await localized.create({ shared: {}, translation: { title: "No SEO Here" }, - } as never); + }); const translation = await advancedArticleContent .translationService?.(context) @@ -657,12 +660,12 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const results = await Promise.allSettled([ editorial()?.update( article.id, - { categories: [categoryIds[1]] } as never, + { categories: [categoryIds[1]] }, { actor: ACTOR, expectedVersion: article.version }, ), editorial(rivalContext)?.update( article.id, - { categories: [categoryIds[2]] } as never, + { categories: [categoryIds[2]] }, { actor: ACTOR, expectedVersion: article.version }, ), ]); @@ -672,9 +675,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { expect(won).toHaveLength(1); expect(lost).toHaveLength(1); - expect((lost[0] as PromiseRejectedResult).reason).toBeInstanceOf( - ContentVersionConflict, - ); + 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); @@ -688,12 +689,12 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const results = await Promise.allSettled([ editorial()?.update( article.id, - { categories: [categoryIds[0]] } as never, + { categories: [categoryIds[0]] }, { actor: ACTOR, expectedVersion: article.version }, ), editorial(rivalContext)?.update( article.id, - { syndication: { priority: 1 } } as never, + { syndication: { priority: 1 } }, { actor: ACTOR, expectedVersion: article.version }, ), ]); @@ -721,12 +722,12 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const results = await Promise.allSettled([ editorial()?.update( article.id, - { faq: [{ answer: "Mine", question: "Mine?" }] } as never, + { faq: [{ answer: "Mine", question: "Mine?" }] }, { actor: ACTOR, expectedVersion: 1 }, ), editorial(rivalContext)?.update( article.id, - { faq: [{ answer: "Theirs", question: "Theirs?" }] } as never, + { faq: [{ answer: "Theirs", question: "Theirs?" }] }, { actor: ACTOR, expectedVersion: 1 }, ), ]); @@ -759,14 +760,14 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { article.id, { faq: [ids[2], ids[1], ids[0]].map(id => ({ id })), - } as never, + }, { actor: ACTOR, expectedVersion: 1 }, ), editorial(rivalContext)?.update( article.id, { faq: [ids[1], ids[0], ids[2]].map(id => ({ id })), - } as never, + }, { actor: ACTOR, expectedVersion: 1 }, ), ]); @@ -786,12 +787,12 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const results = await Promise.all([ editorial()?.update( first.id, - { categories: [categoryIds[0]] } as never, + { categories: [categoryIds[0]] }, { actor: ACTOR, expectedVersion: first.version }, ), editorial(rivalContext)?.update( second.id, - { categories: [categoryIds[1]] } as never, + { categories: [categoryIds[1]] }, { actor: ACTOR, expectedVersion: second.version }, ), ]); @@ -848,7 +849,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const first = await editorial()?.update( article.id, - { relatedArticles: [a, b] } as never, + { relatedArticles: [a, b] }, { actor: ACTOR, expectedVersion: 1 }, ); if (!first) throw new Error("update returned nothing"); @@ -858,7 +859,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const second = await editorial()?.update( article.id, - { relatedArticles: [b, a] } as never, + { relatedArticles: [b, a] }, { actor: ACTOR, expectedVersion: first.version }, ); if (!second) throw new Error("update returned nothing"); @@ -885,7 +886,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const removed = await editorial()?.update( article.id, - { faq: [] } as never, + { faq: [] }, { actor: ACTOR, expectedVersion: 1 }, ); if (!removed) throw new Error("update returned nothing"); @@ -901,10 +902,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const restored = await service().repeatable.faq.list(article.id); - expect(restored.map(row => row.question)).toStrictEqual([ - "One?", - "Two?", - ]); + 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); }); @@ -916,7 +914,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const cleared = await editorial()?.update( article.id, - { categories: [] } as never, + { categories: [] }, { actor: ACTOR, expectedVersion: 1 }, ); if (!cleared) throw new Error("update returned nothing"); @@ -945,7 +943,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const changed = await editorial()?.update( article.id, - { syndication: { indexable: false, priority: 1 } } as never, + { syndication: { indexable: false, priority: 1 } }, { actor: ACTOR, expectedVersion: 1 }, ); if (!changed) throw new Error("update returned nothing"); @@ -970,7 +968,10 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { describe("public projection", () => { const publish = async (id: number, version: number) => - await editorial()?.publish(id, { actor: ACTOR, expectedVersion: version }); + await editorial()?.publish(id, { + actor: ACTOR, + expectedVersion: version, + }); it("exposes the named leaves and nothing else", async () => { const localized = advancedArticleContent.localizedService?.(context, { @@ -988,18 +989,22 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { seo: { description: "Public description", title: "Public SEO" }, title: "Public Article", }, - } as never); + }); + + // `localizedService.create` is typed against the erased definition, so + // its row comes back widened - narrowed here rather than at every use. + const itemId = Number(created.row.id); - await publish(created.row.id, created.row.version); + await publish(itemId, Number(created.row.version)); await advancedArticleContent .translationEditorialService?.(context, { pluginId: CONFIG_PLUGIN.pluginId, }) - .publish(created.row.id, "en", { actor: ACTOR }); + .publish(itemId, "en", { actor: ACTOR }); const row = await advancedArticleContent .publicService?.(context) - .findById(created.row.id, { locale: "en" }); + .findById(itemId, { locale: "en" }); expect(row).toMatchObject({ categories: [categoryIds[0]], @@ -1012,7 +1017,8 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { // The private leaf is absent from the response *and* from the SELECT. expect( - (row as unknown as { syndication: Record }).syndication, + (row as unknown as { syndication: Record }) + .syndication, ).not.toHaveProperty("indexable"); // A private collection is absent altogether. expect(row).not.toHaveProperty("relatedArticles"); From d25f48e387daa6556365c29922ac0ac9da72cbbc Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 12:20:58 +0200 Subject: [PATCH 08/10] test(content): cover the Stage 6 projections without a database Two pure-function suites, and both found a real bug. `paths.test.ts` pins the one leaf-path <-> column mapping every subsystem reads: what is and is not a path, the camelCase compilation, the flatten, the fold back, and the two directions of a nullable group (`seo: null` writes NULL to every leaf; every leaf NULL reads back as `seo: null`). `advanced-projection.test.ts` covers the diff, the column patch, the snapshot, the search document and the public projector. It caught: - `diffChangedPaths` reporting a collection as a changed *column* when handed a field map that still contained one. Harmless at the call sites, which pass the shared half - but a function that is only correct because of who calls it is a function waiting to be called by somebody else. - `contentSearchDocument` normalizing a repeatable's joined text a second time, collapsing the newlines *between* children into single spaces. The values were all there; the boundaries were not, so two adjacent answers ran together into a phrase neither of them contains. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/paths.test.ts | 227 ++++++++++++ .../server/advanced-projection.test.ts | 338 ++++++++++++++++++ packages/vitnode/src/content/server/query.ts | 4 + .../src/content/server/search-document.ts | 5 +- 4 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 packages/vitnode/src/content/paths.test.ts create mode 100644 packages/vitnode/src/content/server/advanced-projection.test.ts 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/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/query.ts b/packages/vitnode/src/content/server/query.ts index 41344bd18..05a629241 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -22,6 +22,7 @@ import { contentLeafColumnName, contentStorageColumns, contentValuesToColumns, + isContentCollectionField, isContentRelationCollection, splitContentFieldPath, } from "../paths"; @@ -272,6 +273,9 @@ export const diffChangedPaths = ( 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); diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index 9b4507a89..87c7c7a96 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -149,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(readSearchValue(values, name))) + .map(name => readSearchValue(values, name)) .filter(value => value !== "") .join("\n\n"), createdAt, From d3ca3c66f008a037d954844ce93fea00892d2ba4 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 12:35:16 +0200 Subject: [PATCH 09/10] fix(content): resolve a self-relation's foreign key from the table itself `references: { related: () => thisContent.table.id }` names 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. The definition-level version of this trap was fixed by `self: true`; this is the same trap one layer down. `ContentReferences` now subtracts self-relations from the map it demands, and the table generators resolve them from the table being built - the only place that reference exists anyway. Which meant `self` had to become a literal type parameter, which meant the "exactly one of `self` and `target`" rule had to move out of the signature (a union there defeats literal inference) and into `defineContentType`, before the rebind - the only moment a supplied target and the placeholder are still distinguishable. Also adds the route-level suite the example plugin was missing: mounting the generated admin and public routes converts every request and response schema to JSON Schema, so a nested group or repeatable array Zod could not describe fails at `pnpm test` rather than at the first request. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/advanced.test.ts | 24 ++++ packages/vitnode/src/content/advanced.ts | 42 +++++++ packages/vitnode/src/content/define.ts | 9 +- packages/vitnode/src/content/fields.ts | 21 ++-- .../src/content/server/advanced-tables.ts | 7 +- packages/vitnode/src/content/server/table.ts | 4 +- packages/vitnode/src/content/server/types.ts | 12 +- packages/vitnode/src/content/types.ts | 7 +- .../example/src/database/advanced-articles.ts | 11 +- .../src/database/advanced-postgres.test.ts | 21 ++-- .../src/database/advanced-routes.test.ts | 119 ++++++++++++++++++ 11 files changed, 249 insertions(+), 28 deletions(-) create mode 100644 plugins/example/src/database/advanced-routes.test.ts diff --git a/packages/vitnode/src/content/advanced.test.ts b/packages/vitnode/src/content/advanced.test.ts index c982b56f8..258f87fea 100644 --- a/packages/vitnode/src/content/advanced.test.ts +++ b/packages/vitnode/src/content/advanced.test.ts @@ -159,6 +159,30 @@ describe("relations", () => { ).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({ diff --git a/packages/vitnode/src/content/advanced.ts b/packages/vitnode/src/content/advanced.ts index 63e4017b6..9f94a101c 100644 --- a/packages/vitnode/src/content/advanced.ts +++ b/packages/vitnode/src/content/advanced.ts @@ -19,6 +19,7 @@ import { CONTENT_REPEATABLE_SYSTEM_FIELDS, } from "./const"; import { ContentEngineError } from "./errors"; +import { unboundSelfTarget } from "./fields"; import { clampWithFingerprint } from "./fingerprint"; import { toSnakeCase } from "./indexes"; import { @@ -434,6 +435,47 @@ const assertGeneratedTableNames = ( } }; +/** + * 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, diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index a71086c92..db0bb63c3 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -27,7 +27,10 @@ import type { ResolvedContentSearchConfig, } from "./types"; -import { resolveContentAdvanced } from "./advanced"; +import { + assertContentRelationTargets, + resolveContentAdvanced, +} from "./advanced"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, @@ -1494,6 +1497,10 @@ 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. + // 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 diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index 5929d0ad6..0a2b0d46b 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -231,7 +231,7 @@ const user = < * `defineContentType` did not rebind the thunk, and a relation silently * pointing at the wrong table is a data bug rather than a crash. */ -const unboundSelfTarget = (): AnyContentTypeDefinition => { +export const unboundSelfTarget = (): AnyContentTypeDefinition => { throw new ContentEngineError( "A `self: true` relation was read before `defineContentType` bound it. Build the field inside a `defineContentType` call.", ); @@ -261,22 +261,29 @@ const unboundSelfTarget = (): AnyContentTypeDefinition => { * `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; ordered?: TOrdered; - } & ( - | { self: true; target?: never } - | { self?: false; target: () => AnyContentTypeDefinition } - ), -): ContentRelationField => ({ + /** The target is this content type. Mutually exclusive with `target`. */ + self?: TSelf; + target?: () => AnyContentTypeDefinition; + }, +): ContentRelationField => ({ ...args, ...shared(args), kind: "relation", @@ -286,7 +293,7 @@ const relation = < multiple: (args.multiple ?? false) as TMultiple, onDelete: args.onDelete ?? "restrict", ordered: (args.ordered ?? false) as TOrdered, - self: args.self === true, + self: (args.self ?? false) as TSelf, target: args.target ?? unboundSelfTarget, }); diff --git a/packages/vitnode/src/content/server/advanced-tables.ts b/packages/vitnode/src/content/server/advanced-tables.ts index 0d62b1001..c98fe0670 100644 --- a/packages/vitnode/src/content/server/advanced-tables.ts +++ b/packages/vitnode/src/content/server/advanced-tables.ts @@ -235,7 +235,12 @@ export const createContentAdvancedTables = < const fieldValue = asContentRelationCollection(fields[entry.field]); if (!fieldValue) continue; - const relatedReference = referenceThunks[entry.field]; + // 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.`, diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 363d7b32d..ac2c495d3 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -97,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 }, ); } diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 0fd9dc48c..5139fa726 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -356,10 +356,20 @@ export type ContentColumnName = * 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; }; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index b2c1ac7db..c55eac794 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -204,6 +204,7 @@ export interface ContentRelationField< TNullable extends boolean = boolean, TMultiple extends boolean = boolean, TOrdered extends boolean = boolean, + TSelf extends boolean = boolean, > extends ContentFieldShared { kind: "relation"; /** @@ -237,8 +238,12 @@ export interface ContentRelationField< * `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: boolean; + self: TSelf; /** Thunk so two content types can refer to each other. */ target: () => AnyContentTypeDefinition; } diff --git a/plugins/example/src/database/advanced-articles.ts b/plugins/example/src/database/advanced-articles.ts index e07c5bd46..dfce66d13 100644 --- a/plugins/example/src/database/advanced-articles.ts +++ b/plugins/example/src/database/advanced-articles.ts @@ -13,11 +13,12 @@ export const advancedArticleContent = createContentModel( // 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, - // The self-relation points at this content type's own table, which is - // being declared right here. The thunk is what makes that legal: Drizzle - // leaves it unevaluated until it serializes the table, so there is no - // circular initialization to trip over. - relatedArticles: () => advancedArticleContent.table.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. }, }, ); diff --git a/plugins/example/src/database/advanced-postgres.test.ts b/plugins/example/src/database/advanced-postgres.test.ts index b2a4ab790..437f3d581 100644 --- a/plugins/example/src/database/advanced-postgres.test.ts +++ b/plugins/example/src/database/advanced-postgres.test.ts @@ -751,23 +751,22 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { { answer: "C", question: "Three?" }, ], }); - const ids = (await service().repeatable.faq.list(article.id)).map( - row => row.id as number, - ); + const stored = await service().repeatable.faq.list(article.id); + const at = (index: number) => ({ + answer: String(stored[index].answer), + id: Number(stored[index].id), + question: String(stored[index].question), + }); await Promise.allSettled([ editorial()?.update( article.id, - { - faq: [ids[2], ids[1], ids[0]].map(id => ({ id })), - }, + { faq: [at(2), at(1), at(0)] }, { actor: ACTOR, expectedVersion: 1 }, ), editorial(rivalContext)?.update( article.id, - { - faq: [ids[1], ids[0], ids[2]].map(id => ({ id })), - }, + { faq: [at(1), at(0), at(2)] }, { actor: ACTOR, expectedVersion: 1 }, ), ]); @@ -993,9 +992,9 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { // `localizedService.create` is typed against the erased definition, so // its row comes back widened - narrowed here rather than at every use. - const itemId = Number(created.row.id); + const itemId = created.row.id; - await publish(itemId, Number(created.row.version)); + await publish(itemId, created.row.version); await advancedArticleContent .translationEditorialService?.(context, { pluginId: CONFIG_PLUGIN.pluginId, 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"]); + }); +}); From 02c677a1d4105911f6875a22f75f38d1bc2a1516 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 14:16:06 +0200 Subject: [PATCH 10/10] fix(content): repair four Stage 6 correctness bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Translation revisions lost every localized group.** Stage 6 taught the base snapshot about groups and left the translation one running each 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. Both halves now share `toFieldSnapshot` and one `projectSnapshotFields`, and the leaf read that made that possible - `readContentLeaf` - is also what lets the diff work against a logical row, so the restore no longer reports an unchanged group as changed. **Localized search documents were missing their collections.** Three paths build one - live sync, translation effects, base editorial effects - and none supplied the shared collections, so a document made of `faq.answer` contained none of it, and editing one localized leaf silently rewrote the document without it. The rebuild was worse: it classified indexed names through the top-level field maps, where neither `seo.description` nor `faq.question` is a key, and dropped both. `contentSearchAdvancedValues` is now the single place that decides whether a document depends on collections, and `resolveSearchSources` resolves each path to the column or child table it actually lives in. Collections are batched per page and deduplicated across locales. **Collection helpers read before taking the lock.** `add` read the current list, then called `update`, which opened the transaction - so two concurrent additions each computed from the same empty list and one disappeared. The read now happens after `SELECT ... FOR UPDATE`, inside the transaction that writes. The plain service merges; the editorial service - which now has its own `relations` and `repeatable` - arbitrates on `expectedVersion`. `ContentWriteOptions` no longer offers a version the plain service would have ignored. **The typed collection API was `Record`.** `service.relations.typo` compiled. Both maps are now keyed by the content type's actual field names, and a repeatable's create/update/list values are inferred from its own leaves. Also: the localized preview read had the same top-level classification bug as the rebuild, and the public projection now loads only the collections it exposes. Co-Authored-By: Claude Opus 5 (1M context) --- .../dev/content-engine/advanced-modeling.mdx | 23 +- .../docs/dev/content-engine/relations.mdx | 74 +- .../dev/content-engine/repeatable-fields.mdx | 26 +- .../vitnode/src/content/advanced.test-d.ts | 80 +++ packages/vitnode/src/content/paths.ts | 30 + packages/vitnode/src/content/search.ts | 47 +- .../src/content/server/advanced-store.ts | 34 +- .../src/content/server/collection-api.ts | 286 ++++++++ .../src/content/server/editorial-effects.ts | 28 +- .../src/content/server/editorial-service.ts | 265 +++++++- .../server/localized-public-service.ts | 9 +- packages/vitnode/src/content/server/model.ts | 13 + .../src/content/server/public-routes.ts | 38 +- .../src/content/server/public-service.ts | 9 +- packages/vitnode/src/content/server/query.ts | 12 +- .../src/content/server/revision-snapshot.ts | 61 +- .../vitnode/src/content/server/routes.test.ts | 3 + packages/vitnode/src/content/server/routes.ts | 14 +- .../src/content/server/search-indexer.ts | 198 +++++- .../src/content/server/search-sync.test.ts | 1 + .../vitnode/src/content/server/search-sync.ts | 50 +- .../vitnode/src/content/server/service.ts | 472 +++++++------ .../translation-advanced-revisions.test.ts | 562 +++++++++++++++ .../server/translation-editorial-service.ts | 48 +- .../src/content/server/translation-effects.ts | 9 +- packages/vitnode/src/content/types.ts | 18 + .../vitnode/src/tests/content-fixtures.ts | 74 ++ .../src/database/advanced-postgres.test.ts | 639 +++++++++++++++++- 28 files changed, 2737 insertions(+), 386 deletions(-) create mode 100644 packages/vitnode/src/content/server/collection-api.ts create mode 100644 packages/vitnode/src/content/server/translation-advanced-revisions.test.ts diff --git a/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx b/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx index 7d1853f44..6613dacb8 100644 --- a/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx +++ b/apps/docs/content/docs/dev/content-engine/advanced-modeling.mdx @@ -121,10 +121,25 @@ await editorialService.update( ); ``` -One request, one version guard, one revision, one event. There are typed -shortcuts too - `service.relations.categories.add(...)`, -`service.repeatable.faq.reorder(...)` - and every one of them is a wrapper over -this same call, so none of them can drift from it. +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 diff --git a/apps/docs/content/docs/dev/content-engine/relations.mdx b/apps/docs/content/docs/dev/content-engine/relations.mdx index f47952877..d079f6a11 100644 --- a/apps/docs/content/docs/dev/content-engine/relations.mdx +++ b/apps/docs/content/docs/dev/content-engine/relations.mdx @@ -68,6 +68,17 @@ relatedArticles: field.relation({ }), ``` +`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 @@ -137,28 +148,67 @@ categories: field.relation({ ## 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 -await service.relations.categories.get(7); // [2, 5, 9] -await service.relations.categories.set(7, [2, 5], opts); -await service.relations.categories.add(7, 12, opts); -await service.relations.categories.remove(7, 5, opts); -await service.relations.categories.reorder(7, [9, 2], opts); +const editorial = model.editorialService?.(c, { pluginId }); + +await editorial.relations.categories.add(7, 12, { + actor, + expectedVersion: 4, +}); ``` -Every one of them is a wrapper over `update`, so each inherits the same rules: +`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. -- **Optimistic locking.** On an editorial content type, `expectedVersion` guards - the source record's version, and the guard runs *before* a single junction row - is touched. A writer that loses the race leaves the relations exactly as it - found them. + 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.** A list that added or dropped a target is +- **`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. diff --git a/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx b/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx index 9d7c9028d..c792d90cb 100644 --- a/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/repeatable-fields.mdx @@ -76,28 +76,38 @@ once. No deferrable constraint, no delete-and-recreate, and identity survives. One call, not one per row: ```ts -await service.repeatable.faq.set( +await model.editorialService?.(c, { pluginId }).repeatable.faq.set( 7, [ { id: 11, question: "Kept?", answer: "Yes" }, { question: "New?", answer: "Also yes" }, ], - { expectedVersion: 4 }, + { 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, and -each is a wrapper over the same call: +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: "…" }, opts); -await service.repeatable.faq.update(7, 11, { answer: "…" }, opts); -await service.repeatable.faq.delete(7, 11, opts); -await service.repeatable.faq.reorder(7, [15, 11], opts); +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. diff --git a/packages/vitnode/src/content/advanced.test-d.ts b/packages/vitnode/src/content/advanced.test-d.ts index 307a42099..7c4d1dc06 100644 --- a/packages/vitnode/src/content/advanced.test-d.ts +++ b/packages/vitnode/src/content/advanced.test-d.ts @@ -294,6 +294,86 @@ describe("admin surfaces", () => { }); }); +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. diff --git a/packages/vitnode/src/content/paths.ts b/packages/vitnode/src/content/paths.ts index e3ba62363..53d29797f 100644 --- a/packages/vitnode/src/content/paths.ts +++ b/packages/vitnode/src/content/paths.ts @@ -325,6 +325,36 @@ export const contentColumnsToValues = ( 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. * diff --git a/packages/vitnode/src/content/search.ts b/packages/vitnode/src/content/search.ts index c52ec6a5c..db0f2049c 100644 --- a/packages/vitnode/src/content/search.ts +++ b/packages/vitnode/src/content/search.ts @@ -114,6 +114,39 @@ export const contentSearchIndexedPaths = ( ); }; +/** + * 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. * @@ -125,16 +158,4 @@ export const contentSearchIndexedPaths = ( */ export const contentSearchIndexesCollections = ( definition: AnyContentTypeDefinition, -): boolean => - contentSearchIndexedFieldNames(definition).some(name => { - const path = splitContentFieldPath(name); - if (!path) return false; - - const fieldValue = definition.fields[path[0]]; - - return ( - fieldValue !== undefined && - (fieldValue.kind === "repeatable" || - isContentRelationCollection(fieldValue)) - ); - }); +): boolean => contentSearchIndexedCollections(definition).length > 0; diff --git a/packages/vitnode/src/content/server/advanced-store.ts b/packages/vitnode/src/content/server/advanced-store.ts index a9e30c088..08cc4dabb 100644 --- a/packages/vitnode/src/content/server/advanced-store.ts +++ b/packages/vitnode/src/content/server/advanced-store.ts @@ -64,10 +64,18 @@ export interface ContentAdvancedStore { readonly enabled: boolean; /** The collection field names, in declaration order. */ readonly fields: string[]; - /** Every collection of one record, in logical shape. */ + /** + * 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** @@ -80,6 +88,7 @@ export interface ContentAdvancedStore { loadMany: ( itemIds: readonly number[], database: ContentDatabase, + only?: readonly string[], ) => Promise>>; /** * An indexed `EXISTS` over one relation's junction table. @@ -574,6 +583,18 @@ export const createContentAdvancedStore = < 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 @@ -600,9 +621,9 @@ export const createContentAdvancedStore = < fields: collectionNames, - load: async (itemId, database) => { + load: async (itemId, database, only) => { const loaded = await Promise.all( - collectionNames.map(async field => { + selected(only).map(async field => { if (tables.junctions[field]) { const rows = (await readJunction(field, [itemId], database)).get(itemId) ?? []; @@ -620,17 +641,18 @@ export const createContentAdvancedStore = < return Object.fromEntries(loaded); }, - loadMany: async (itemIds, database) => { + loadMany: async (itemIds, database, only) => { + const wanted = selected(only); const result = new Map>(); for (const itemId of itemIds) { result.set( itemId, - Object.fromEntries(collectionNames.map(field => [field, []])), + Object.fromEntries(wanted.map(field => [field, []])), ); } if (itemIds.length === 0) return result; - for (const field of collectionNames) { + for (const field of wanted) { if (tables.junctions[field]) { const rows = await readJunction(field, itemIds, database); for (const [itemId, list] of rows) { 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/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index 9e350bf41..536551c3b 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -7,9 +7,12 @@ import type { ContentEditorialOutcome } from "./editorial-service"; import type { AnyContentModel } from "./model"; import type { ContentSearchSyncOutcome } from "./search-sync"; -import { contentSearchIndexesCollections } from "../search"; 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< @@ -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,13 +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. - // A content type that indexes none - which is every Stage 1-5 one - pays - // for nothing here. - advanced: - model && contentSearchIndexesCollections(definition) - ? await model.service(c).advanced(idOf(outcome.row)) - : undefined, + // 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, diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 84391b550..a18f76114 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -14,8 +14,14 @@ import type { AnyContentTypeDefinition, ContentChangedPath, ContentCreateInput, + ContentInnerFieldsOf, + ContentRelationCollectionName, + ContentRepeatableFieldName, + ContentRepeatableInputRow, + ContentRepeatableRow, ContentSelect, ContentUpdateInput, + ContentValuesOf, } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentRevisionsModel } from "./revisions-model"; @@ -34,6 +40,11 @@ import { } from "../errors"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; +import { + buildContentRelationOperations, + buildContentRepeatableOperations, + contentCollectionKinds, +} from "./collection-api"; import { changedPathsToColumns, diffChangedPaths, @@ -108,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, @@ -134,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. * @@ -329,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, @@ -436,7 +565,127 @@ 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; @@ -661,6 +910,11 @@ export const createContentEditorialService = < }; }), + relations: mutableRelations, + + repeatable: + mutableRepeatables as ContentEditorialService["repeatable"], + revisions, schedules, @@ -681,6 +935,13 @@ 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; @@ -751,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 d42eba815..67cf47cb5 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -206,7 +206,14 @@ export const createContentLocalizedPublicService = < const ids = nested .map(row => row.id) .filter((id): id is number => typeof id === "number"); - const loaded = await advanced?.loadMany(ids, c.get("db")); + // 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, diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index eb3d2db1a..5329b1667 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -39,6 +39,18 @@ 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. * @@ -262,6 +274,7 @@ export const createContentModel = < }; return { + advanced, advancedTables, 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 1fe9384ad..9ae1949a1 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -386,7 +386,14 @@ export const createContentPublicService = < const ids = nested .map(row => row.id) .filter((id): id is number => typeof id === "number"); - const loaded = await advanced?.loadMany(ids, c.get("db")); + // 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, diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index 05a629241..1cd923a51 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -24,6 +24,7 @@ import { contentValuesToColumns, isContentCollectionField, isContentRelationCollection, + readContentLeaf, splitContentFieldPath, } from "../paths"; @@ -286,8 +287,8 @@ export const diffChangedPaths = ( if (next === null) { for (const leaf of Object.keys(inner)) { - const column = contentLeafColumnName(name, leaf); - if (current[column] === null || current[column] === undefined) continue; + const stored = readContentLeaf(current, name, leaf); + if (stored === null || stored === undefined) continue; changed.push(contentFieldPath(name, leaf)); } @@ -301,8 +302,11 @@ export const diffChangedPaths = ( )) { if (!(leaf in inner) || leafValue === undefined) continue; - const column = contentLeafColumnName(name, leaf); - if (sameValue(current[column], leafValue)) 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)); } diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index a7ceb78cd..22e52f255 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -10,8 +10,8 @@ import { CONTENT_REVISION_SNAPSHOT_VERSION } from "../const"; import { partitionContentFields } from "../localization"; import { contentInnerFields, - contentLeafColumnName, isContentRelationCollection, + readContentLeaf, } from "../paths"; const toIso = (value: unknown): string => { @@ -73,14 +73,10 @@ const toFieldSnapshot = ( let allNull = true; for (const leaf of leaves) { - // Read from the column when the row is a database row, and from the - // nested value when it is already logical - a restore hands over the - // second, and both have to snapshot identically. - const raw = - values[contentLeafColumnName(name, leaf)] ?? - (values[name] as null | Record | undefined)?.[leaf] ?? - null; - const snapshot = toSnapshotValue(raw); + // 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; @@ -208,19 +204,35 @@ export const projectRevisionSnapshot = ( definition: AnyContentTypeDefinition, snapshot: ContentRevisionSnapshot, ): Record => { - const projected: Record = {}; const { collectionFields, sharedFields } = partitionContentFields( definition.fields, ); - const restorable: ContentFieldMap = { - ...sharedFields, - ...collectionFields, - }; + + 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 = {}; for (const [name, fieldValue] of Object.entries(restorable)) { - if (!(name in snapshot.fields)) continue; + if (!(name in stored)) continue; - const value = snapshot.fields[name]; + const value = stored[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 @@ -283,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 = { @@ -336,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 98e83658a..192ccf3f5 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -113,6 +113,7 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { create: vi.fn(), delete: vi.fn(), advanced: vi.fn(), + advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), findMany: vi.fn(), @@ -160,6 +161,7 @@ const publicationHarness = ({ allow = true }: { allow?: boolean } = {}) => { create: vi.fn(), delete: vi.fn(), advanced: vi.fn(), + advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), findMany: vi.fn(), @@ -705,6 +707,7 @@ describe("generated content routes", () => { create: vi.fn(), delete: vi.fn(), advanced: vi.fn(), + advancedFields: vi.fn(), findDetail: vi.fn(), relations: {}, repeatable: {}, diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 1cadc8c87..79b273af1 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -46,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()); @@ -80,21 +80,19 @@ const identifier = (c: Context): number => { * 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: a document built from `faq.answer` is built - * from child rows, which are not on the row the mutation gave back. Every - * Stage 1-5 content type indexes none, so this is a boolean check and no query. + * 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> => { - if (!contentSearchIndexesCollections(model.definition)) return undefined; - const id = (row as { id?: unknown }).id; - if (typeof id !== "number") return undefined; - return await model.service(c).advanced(id); + return typeof id === "number" + ? await contentSearchAdvancedValues(c, model, id) + : undefined; }; export const buildContentRoutes = < 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 9b00449f1..2900c7dd5 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -80,6 +80,7 @@ const harness = ({ create: vi.fn(), delete: vi.fn(), advanced: vi.fn(), + advancedFields: vi.fn(), findDetail: vi.fn(), findById: vi.fn(), relations: {}, diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index ac27065fa..aacd9c746 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -8,7 +8,11 @@ import type { AnyContentModel } from "./model"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; -import { contentSearchDocumentId, contentSearchIndexedPaths } from "../search"; +import { + contentSearchDocumentId, + contentSearchIndexedCollections, + contentSearchIndexedPaths, +} from "../search"; import { listContentLanguages } from "./language-resolver"; import { contentSearchDocument, @@ -204,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. @@ -371,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 }, ); @@ -399,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 148843604..3e5dc85c7 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -16,10 +16,16 @@ import type { ContentCreateInput, ContentDetail, ContentFilterInput, + ContentInnerFieldsOf, ContentOrderableFieldName, ContentReferenceFieldName, + ContentRelationCollectionName, + ContentRepeatableFieldName, + ContentRepeatableInputRow, + ContentRepeatableRow, ContentSelect, ContentUpdateInput, + ContentValuesOf, } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; @@ -35,6 +41,11 @@ 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, @@ -101,13 +112,19 @@ export interface ContentUpdateResult { } /** - * The typed collection API of one content type. + * 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. * - * Every method is a thin wrapper over `update`, and deliberately so: a relation - * or repeatable mutation *is* an edit of the source record, so it has to take - * the same lock, bump the same version, leave the same revision, emit the same - * event and invalidate the same tags. Routing them through one write path is - * what makes that true by construction instead of by six call sites remembering. + * 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. */ @@ -144,11 +161,17 @@ export interface ContentRelationMethods { ) => Promise | null>; } -export interface ContentRepeatableMethods { - /** Appends one child. */ +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: Record, + values: ContentValuesOf>, options?: ContentWriteOptions, ) => Promise | null>; /** Removes one child by its stable identifier. */ @@ -161,7 +184,9 @@ export interface ContentRepeatableMethods { list: ( itemId: number, options?: ContentServiceOptions, - ) => Promise[]>; + ) => Promise< + ContentRepeatableRow>[] + >; /** Rearranges the existing children. Refuses a non-permutation. */ reorder: ( itemId: number, @@ -177,30 +202,41 @@ export interface ContentRepeatableMethods { */ set: ( itemId: number, - rows: readonly Record[], + rows: readonly ContentRepeatableInputRow< + ContentInnerFieldsOf + >[], options?: ContentWriteOptions, ) => Promise | null>; - /** Updates one child by its stable identifier. */ + /** + * 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: Record, + values: Partial>>, options?: ContentWriteOptions, ) => Promise | null>; } /** - * Options for a collection mutation. + * 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. * - * `expectedVersion` is the source record's, and it is optional here for the same - * reason it does not exist at all on the plain `update`: this service is the - * non-editorial one, which has no `version` column to guard on and serialises - * concurrent writers with `SELECT ... FOR UPDATE` instead. The editorial service - * takes a required one - see `ContentEditorialWriteOptions`. + * 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 interface ContentWriteOptions extends ContentServiceOptions { - expectedVersion?: number; -} +export type ContentWriteOptions = ContentServiceOptions; export interface ContentPublicationResult { /** @@ -251,6 +287,20 @@ export interface ContentServiceBase { 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, @@ -284,15 +334,28 @@ export interface ContentServiceBase { search?: string, ) => Promise<{ label: string; value: number }[]>; /** - * Typed to-many relation operations, keyed by field name. + * 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 `service.relations.categories` is a compile error unless there is - * a `categories`. + * exists and every key on it is one the definition has. */ - relations: Record>; - /** Typed repeatable operations, keyed by field name. */ - repeatable: Record>; + 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, @@ -371,6 +434,23 @@ 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"); @@ -518,6 +598,21 @@ export const createContentService = < return await c.get("db").transaction(async tx => await body(tx)); }; + /** + * 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. @@ -528,8 +623,15 @@ export const createContentService = < * result rather than the state they both read. Records are independent because * the lock is per row. */ - const lockRow = async (tx: ContentDatabase, id: number): Promise => { - if (!store?.enabled) return true; + 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 }) @@ -546,6 +648,9 @@ export const createContentService = < ((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 @@ -692,9 +797,9 @@ export const createContentService = < }); }, - relations: {}, + relations: mutableRelations, - repeatable: {}, + repeatable: mutableRepeatables as ContentService["repeatable"], update: async (id, values, options) => await transact(options, async tx => { @@ -706,217 +811,144 @@ export const createContentService = < if (!(await lockRow(tx, id))) return null; - const current = await readOne(id, tx); - if (!current) return null; - - 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. A reorder to the order - // that is already stored lands here. - if (changedFields.length === 0) { - return { - changedFields: changedFields as ContentChangedPath[], - row: toRow(current), - }; - } - - 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( - changedPaths.length > 0 - ? changedPathsToColumns(fields, patch, changedPaths) - : { updatedAt: new Date() }, - ) - .where(eq(primaryCursor, id)) - .returning(ownSelection()); + 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 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. A reorder to the order + // that is already stored lands here. + if (changedFields.length === 0) { return { changedFields: changedFields as ContentChangedPath[], - row: toRow(row), + row: toRow(current), }; - }), + } + + 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( + changedPaths.length > 0 + ? changedPathsToColumns(fields, patch, changedPaths) + : { updatedAt: new Date() }, + ) + .where(eq(primaryCursor, id)) + .returning(ownSelection()); + + return { + changedFields: changedFields as ContentChangedPath[], + row: toRow(row), + }; + } }; /** - * Turns a collection field into the five typed operations callers use. + * Locks the source record, reads one collection and applies what `compute` + * makes of it - all in one transaction. * - * Each one reads the current state, computes the whole new list and hands it - * to `update` - so every one of them inherits the no-op rule, the row lock, - * the `updatedAt` bump and, on the editorial service, the version guard, the - * revision and the event. There is no second write path to keep in step. + * 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 relationMethods = ( + const runCollection = async ( + itemId: number, field: string, - ): ContentRelationMethods => { - const read = async ( - itemId: number, - options?: ContentServiceOptions, - ): Promise => { - const loaded = await store?.load(itemId, db(options)); - const value = loaded?.[field]; + compute: (current: unknown[]) => unknown[], + options: ContentServiceOptions | undefined, + ): Promise | null> => + await inTransaction(options, async tx => { + if (!(await lockRow(tx, itemId, { force: true }))) return null; - return Array.isArray(value) ? (value as number[]) : []; - }; + const current = await store?.load(itemId, tx, [field]); + const value = current?.[field]; + const next = compute(Array.isArray(value) ? value : []); - const write = async ( - itemId: number, - next: readonly number[], - options?: ContentWriteOptions, - ): Promise | null> => - service.update( + return await applyPatch( + tx, itemId, - { [field]: [...next] } as ContentUpdateInput, - options, + schemas.update.parse({ [field]: next }), ); + }); - return { - add: async (itemId, relatedItemId, options) => { - const current = await read(itemId, options); - - return await write( - itemId, - current.includes(relatedItemId) - ? current - : [...current, relatedItemId], - options, - ); - }, - - get: read, - - remove: async (itemId, relatedItemId, options) => - await write( - itemId, - (await read(itemId, options)).filter(id => id !== relatedItemId), - options, - ), - - reorder: async (itemId, relatedItemIds, options) => { - const current = await read(itemId, options); - assertPermutation(field, current, relatedItemIds, "target"); - - return await write(itemId, relatedItemIds, options); - }, - - set: async (itemId, relatedItemIds, options) => - await write(itemId, relatedItemIds, options), - }; - }; - - const repeatableMethods = ( - field: string, - ): ContentRepeatableMethods => { - const read = async ( - itemId: number, - options?: ContentServiceOptions, - ): Promise[]> => { - const loaded = await store?.load(itemId, db(options)); + 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 as Record[]) : []; - }; - - const write = async ( + return Array.isArray(value) ? value : []; + }, + run: runCollection, + write: async ( itemId: number, - rows: readonly Record[], - options?: ContentWriteOptions, - ): Promise | null> => - service.update( + 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]: [...rows] } as ContentUpdateInput, + { [field]: [...next] } as ContentUpdateInput, options, - ); - - return { - create: async (itemId, values, options) => - await write( - itemId, - [...(await read(itemId, options)), values], - options, - ), - - delete: async (itemId, childId, options) => - await write( - itemId, - (await read(itemId, options)).filter(row => row.id !== childId), - options, - ), - - list: read, - - reorder: async (itemId, childIds, options) => { - const current = await read(itemId, options); - assertPermutation( - field, - current.map(row => Number(row.id)), - childIds, - "entry", - ); - - const byId = new Map(current.map(row => [Number(row.id), row])); - - return await write( - itemId, - childIds.map(childId => byId.get(childId) ?? {}), - options, - ); - }, - - set: async (itemId, rows, options) => await write(itemId, rows, options), - - update: async (itemId, childId, values, options) => - await write( - itemId, - (await read(itemId, options)).map(row => - row.id === childId ? { ...row, ...values, id: childId } : row, - ), - options, - ), - }; + ), }; - /** - * 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. - */ - const assertPermutation = ( - field: string, - current: readonly number[], - 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 }, - ); - }; + // 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 store?.fields ?? []) { - if (definition.fields[field]?.kind === "repeatable") { - service.repeatable[field] = repeatableMethods(field); - continue; - } - service.relations[field] = relationMethods(field); + 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 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 3c1dd9c7b..748809bca 100644 --- a/packages/vitnode/src/content/server/translation-effects.ts +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -8,7 +8,10 @@ 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< @@ -140,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/types.ts b/packages/vitnode/src/content/types.ts index c55eac794..9a7538a7d 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -412,6 +412,24 @@ export type ContentRepeatableRow = Prettify< } & { 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. * 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/plugins/example/src/database/advanced-postgres.test.ts b/plugins/example/src/database/advanced-postgres.test.ts index 437f3d581..632cacc54 100644 --- a/plugins/example/src/database/advanced-postgres.test.ts +++ b/plugins/example/src/database/advanced-postgres.test.ts @@ -1,3 +1,4 @@ +import type { SearchDocument } from "@vitnode/core/api/models/search"; import type { Context } from "hono"; import { @@ -5,6 +6,11 @@ import { 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"; @@ -13,6 +19,7 @@ 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"; @@ -94,6 +101,16 @@ 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. @@ -120,6 +137,16 @@ const pgErrorCode = async (run: () => Promise) => { /** 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 }> => { @@ -149,6 +176,11 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { 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; @@ -174,6 +206,30 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { ({ 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: [ @@ -216,6 +272,8 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { RETURNING "id" `; categoryIds = rows.map(row => row.id); + indexed.length = 0; + deleted.length = 0; }); // ------------------------------------------------------------------------- @@ -290,8 +348,12 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { // `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("23503"); - expect(article.id).toBeGreaterThan(0); + 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 () => { @@ -377,11 +439,11 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { 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]], - { expectedVersion: article.version }, - ); + await service().relations.categories.set(article.id, [ + categoryIds[2], + categoryIds[0], + categoryIds[1], + ]); await expect( service().relations.categories.get(article.id), @@ -437,9 +499,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const article = await createArticle(); await expect( - service().relations.categories.set(article.id, [999999], { - expectedVersion: article.version, - }), + service().relations.categories.set(article.id, [999999]), ).rejects.toBeInstanceOf(ContentAdvancedInputError); }); @@ -450,7 +510,6 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const result = await service().relations.categories.reorder( article.id, stored, - { expectedVersion: 1 }, ); expect(result?.changedFields).toStrictEqual([]); @@ -492,11 +551,9 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { }); const before = await service().repeatable.faq.list(article.id); - const ids = before.map(row => row.id as number); + const ids = before.map(row => row.id); - await service().repeatable.faq.reorder(article.id, [ids[1], ids[0]], { - expectedVersion: 1, - }); + await service().repeatable.faq.reorder(article.id, [ids[1], ids[0]]); const after = await service().repeatable.faq.list(article.id); @@ -516,16 +573,12 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { }); const current = await service().repeatable.faq.list(article.id); - const keptId = current[0].id as number; + 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?" }, - ], - { expectedVersion: 1 }, - ); + 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"]); @@ -549,11 +602,9 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const [stolen] = await service().repeatable.faq.list(theirs.id); await expect( - service().repeatable.faq.set( - mine.id, - [{ answer: "A", id: stolen.id, question: "Question?" }], - { expectedVersion: 1 }, - ), + service().repeatable.faq.set(mine.id, [ + { answer: "A", id: stolen.id, question: "Question?" }, + ]), ).rejects.toBeInstanceOf(ContentAdvancedInputError); }); @@ -566,7 +617,6 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { const result = await service().repeatable.faq.set( article.id, current.map(row => ({ ...row })), - { expectedVersion: 1 }, ); expect(result?.changedFields).toStrictEqual([]); @@ -751,12 +801,10 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { { 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) => ({ - answer: String(stored[index].answer), - id: Number(stored[index].id), - question: String(stored[index].question), - }); + const at = (index: number) => stored[index]; await Promise.allSettled([ editorial()?.update( @@ -779,6 +827,255 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { 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(); @@ -806,6 +1103,278 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { }); }); + // ------------------------------------------------------------------------- + // 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 // -------------------------------------------------------------------------