diff --git a/.changeset/reference-field-admin.md b/.changeset/reference-field-admin.md new file mode 100644 index 0000000000..3a9293cb48 --- /dev/null +++ b/.changeset/reference-field-admin.md @@ -0,0 +1,25 @@ +--- +"@emdash-cms/admin": minor +--- + +Reference fields are now a real, working field type. Previously "reference" was just a plain text box with nowhere to point; now you get a proper relationship picker. Configure it in the schema editor (choose the target collection and single vs. multiple), then search for, pick, and reorder linked entries right in the entry editor — all saved together with the entry in one request. Referenced entries show a read-only "Referenced by" panel so you can see what points at them, and you can jump straight to any linked entry from the picker or the backlinks. + +A reference field created before this release has no target collection, so the entry editor keeps showing it as the text box it has always been, alongside a note about setting a target collection under Content Types to get the picker. Its field dialog offers the collection picker rather than disabling it, pre-filled with whatever target the field already named, and says what saving one does: the field becomes an entry picker, its stored entry IDs move to the relationship, and it stops being searchable and filterable. A field that never recorded whether it allowed more than one entry now shows as single rather than multiple, which is what the API and the upgrade migration both assume — binding one by hand and letting it upgrade on its own now give the same limit. New reference fields default to single for the same reason. + +#### Relationships have their own page + +Content Types links to a Relations page listing every relationship on the site: the two content types it joins, the reference fields bound to each end and which end they pick from, and how many links it holds. Relationships with no field bound to them are listed too — clearing the checkbox on a field delete leaves one behind, and without this they would be unreachable. + +A content type's own page repeats the ones it is an end of, in a Relations panel under its fields: which content types the relationship joins, the role this content type plays in it, the fields on this content type bound to it, and how many links it holds. A relationship no field here uses says so, which is what a field delete leaves behind. New Relation opens the same form the Relations page uses, as a dialog, with this content type filled in as the linking end. + +A relationship can be created from either, ahead of any field that uses it, and its role names and limits edited afterwards. The two content types and the slug are fixed once it exists: a reference field stores the slug and every link is keyed by the relationship, so moving an end would repoint stored links at content of the wrong type. Limits read as One, Any number, or an explicit maximum, per side, and live on the relationship rather than on each field so two fields bound to it cannot disagree about the same links. + +The field dialog offers the relationships this content type can still bind to, ahead of the referenced-collection picker; choosing one takes the referenced collection and the limits from it. The direction is a choice only for a relationship whose two ends are the same content type, and stated read-only everywhere else, with a note on why the linked end offers no reordering: a link's position is scoped to the entry that made it, so only the linking end can order its selection. + +The first choice in that picker is Quick create a relationship, which is also what a site with no relationships yet gets. Picking it still makes a relationship, named after the field and its content type, taking its linking-side limit from the Allow multiple references switch and leaving the other side unlimited, and the dialog now says so — and links to the Relations page, in a new tab so the half-filled field survives, for anyone who would rather set the slug, the role names, and the limits themselves and then come back and pick it. + +#### Deleting says what goes with it + +Deleting a reference field offers to delete the relationship it uses, checked by default, and the dialog names what that takes: the relationship, how many links it holds, and the field on the other content type with the direction it picks from. That last part matters most when the field being deleted is the inverse one — deleting a field on Authors would otherwise silently remove the primary field on Posts. Clearing the checkbox keeps the relationship and its links. + +A relationship can be deleted from its own page. It does not refuse when fields are bound to it: it names them and removes them. Deleting a content type lists every relationship it is an end of, and the fields on other content types that go with them, next to the existing content warning. diff --git a/.changeset/reference-field-core.md b/.changeset/reference-field-core.md new file mode 100644 index 0000000000..4935309617 --- /dev/null +++ b/.changeset/reference-field-core.md @@ -0,0 +1,153 @@ +--- +"emdash": minor +--- + +Adds reference fields that store relationships between entries. Selections are written atomically with the entry and are hydrated on read alongside SEO and bylines. Each resolved reference includes a display title from the referenced entry's configured title field, `title`, or `name`, so pickers and backlinks show a readable label. + +A selection is addressed by field slug, in the entry create and update bodies and in the `references` an entry read returns: + +```jsonc +// POST /_emdash/api/content/posts +{ "data": { "title": "Hello" }, "references": { "author": ["01HXK5MZSN..."] } } +``` + +A field bound to the child end of its relation selects the entries pointing at it, and those have no order of their own: `sort_order` positions children within one parent, and nothing positions a child's parents. The relation-scoped routes, `/content/{collection}/{id}/references/{relation}/children` and `/parents`, still address a relation — that is what they are about. + +#### Reference selections are versioned + +On a collection that keeps revisions, changing a picker on a published entry no longer changes the published page. The new selection is staged in the entry's draft alongside its other pending edits and becomes live when the entry is published — through the publish action, a scheduled publish, or restoring a revision. Discarding the draft discards the selection with it, and duplicating an entry copies the published selection rather than the source's pending one. + +An entry read that includes drafts, which is what the admin does, reports the staged selection; a public read reports the published one. A collection created without `revisions` support keeps writing a selection straight through, as do entry creations, which have no published version to differ from. + +Publishing re-checks the staged selection against the relation's limits, so a draft cannot carry a selection past a schema change that would now reject it — the publish fails with `VALIDATION_ERROR` and the published selection stands. + +Comparing an entry's live and draft revisions now reports `_references` on both sides, filled in from the published selection for the fields a draft did not stage, so an unchanged reference field does not read as one the draft removed. + +#### Reading references from site code + +`getEmDashEntry` takes a `references` option naming the fields a page actually renders, by field slug, and returns a page of entries for each: + +```ts +const { entry: post } = await getEmDashEntry("posts", slug, { + references: { author: true, related_posts: { limit: 6 } }, +}); + +const author = post?.references?.author.entries[0]; +for (const related of post?.references?.related_posts.entries ?? []) { + // related.id, related.data.title, related.edit +} +``` + +It is opt-in in both directions: a call that passes no `references` issues no extra queries, and a field left out of the selection is not read. A call that does select fields costs one read of the collection's reference-field map, then one link read per field plus one entry read per _distinct_ target collection, however many entries each field holds — so a page asking for an author and six related posts is one field-map read, two link reads and two entry reads, not eight. The link reads run concurrently, and the field map is cached per request and in the schema object-cache namespace. + +Those reads go into the entry's cached snapshot, so a site with an object cache configured pays them on a miss and not on a hit, and publishing a referenced entry drops the snapshots that carry it. The `cacheHint` the call returns names every referenced row the render read and takes the newest modification time across the entry and its references, so passing it to `Astro.cache.set` expires a route-cached page when a referenced entry changes, not only when the entry itself does. + +A referenced entry is a `ContentEntry` like any other: the same `id`, the same `data` — dates as `Date`, booleans as booleans, media values resolved — and a working `edit` proxy in visual editing, scoped to the referenced entry so clicking through opens the entry the card is about. Bylines and taxonomy terms are not hydrated onto referenced entries; read those from the entry itself when a card needs them. + +Entries come back in the order the editor arranged them for a field on the parent end of its relation. A field on the child end lists whatever points at it, which has no order of its own. + +A public render sees published entries only, and sees the published selection. A preview of that entry, or an editor in visual editing, sees unpublished entries and the pending selection staged in the draft — so a preview link shows the references the page will have once it is published. + +`getEmDashReferences` walks past the first page using the cursor that page returned, for a field holding more entries than one page shows: + +```ts +const more = await getEmDashReferences("posts", post.id, "related_posts", { + cursor, + limit: 20, +}); +``` + +Both default to 50 entries per field and accept at most 100. + +Generated types cover references. A collection with at least one bound reference field gets a `{Collection}References` interface beside its data interface, registered under the collection slug the same way, so `getEmDashEntry` narrows its result to the fields the call named and each page's entries carry the target collection's interface: + +```ts +const { entry: post } = await getEmDashEntry("posts", slug, { references: { author: true } }); + +// post?.references?.author.entries[0].data is an Author +// post?.references?.related_posts is a type error: it was not selected +``` + +Re-run `emdash types`, or restart the dev server, to pick the interfaces up. A reference field that is not bound to a relation keeps its `string` key in the data interface, as it keeps its column. + +Reference fields enforce required and selection-limit constraints for entry saves and direct reference requests, on both ends of the relation: a field on the parent side that would hand a selected entry more parents than the relation allows is refused, not only one that selects too many children itself. A collection that keeps drafts re-checks the whole selection at publish rather than only the fields a draft happens to have staged, so a required reference field added to a collection that already holds entries blocks publishing them until it is filled in. + +Reference selections are shared across translations, so creating a translation reuses the source entry's selection. Duplicating an entry carries its selections onto the copy, including a field bound to the child end of its relation, whose value _is_ the entries pointing at it. Backlinks no field views still point only at the original. + +A reference field stores no column of its own once it is bound to a relation; its selection lives as edges in `_emdash_content_references`. A reference field created before relations existed is not bound to one, so it keeps the column it has and behaves as it always has: the entry id it holds saves, loads, validates against the collection schema, and appears in generated types as a `string`, and the field can still be indexed and used as a content-list filter. Seed files continue to use `$ref:` values, which resolve to an edge for a bound field and to a column value for an unbound one. + +A bound reference field cannot be marked as indexed, because it has no column to index. Large reference replacements are split into D1-safe writes while preserving selection order. + +#### Upgrading a site with existing reference fields + +Migration 077 binds each reference field that named its target collection — in `options.collection`, as the `reference()` field helper and the documented seed shape do — to a new relation, and copies the entry ids in its column in as links. Those fields become working pickers on upgrade with their existing selections intact. + +A reference field is left alone, and keeps behaving exactly as it did, when: + +- it names no target collection, or names one that no longer exists. A reference field created in the admin before this release has no target, since the admin had nowhere to record one. +- it is marked searchable or indexed. Both mean the site queries that column through an index, and binding the field stops the column being written. +- the relation slug it would take, `{collection}_{field}`, is already in use. + +To bind one of those fields yourself, open it under Content Types and choose a referenced collection. EmDash creates the relation, copies the column's ids in as links, and clears the field's searchable and indexed flags — after which `fields` filters and site search no longer cover it. + +The column is left in place and stops being written. On a site that predates pickers it was a free-text box that could hold anything an editor typed, and only the ids that resolved to an entry became links, so nothing is deleted. Generated types no longer declare the key for a bound field, but a content read still reports the frozen column value in `data` beside the live `references`. + +`relations` joins the reserved collection slugs: the admin serves the relations screen at that path, so a collection with that slug could never be opened. + +Relations are now first-class schema objects rather than a hidden detail of each reference field. A relation joins two collections under a slug that is unique across the site, and a reference field records which end of that relation it sits on — so the same relation can back a field on either side. A relation carries a label and an optional singular form for each role, plus an optional limit on how many entries each side may hold. + +Migration 076 restructures `_emdash_relations` to match: the per-locale rows collapse into one row per relation, keyed by a new unique `slug`, and `_emdash_content_references.relation_group` becomes `relation_id`. Relation ids are preserved, so existing reference edges stay valid. Relations are no longer localized — like collections and fields, their labels are single-valued. Where per-locale rows existed, the lowest locale code's labels win. + +Deleting a reference field no longer deletes its relation by default. The relation and its edges survive until they are deleted deliberately, either from the relations admin or by opting in on the field delete, which also removes the field bound to the relation's other side. Deleting a collection removes every relation it is an end of, along with the reference fields viewing them — including fields on the collection at the far end, which would otherwise address a collection that no longer exists. + +Reading a relation now reports what deleting it would take: the reference fields bound to it and how many links it holds. + +Seed files gain a top-level `relations` array, so a relation can be declared with its labels and limits instead of being created as a side effect of the first reference field that needs one: + +```json +{ + "relations": [ + { + "slug": "post_authors", + "parentCollection": "posts", + "childCollection": "authors", + "parentLabel": "Posts", + "childLabel": "Authors", + "maxChildrenPerParent": 1 + } + ], + "collections": [ + { + "slug": "posts", + "label": "Posts", + "fields": [ + { + "slug": "author", + "label": "Author", + "type": "reference", + "validation": { "relation": "post_authors" } + } + ] + } + ] +} +``` + +A reference field created through the schema API can name a relation the same way. `POST /_emdash/api/schema/collections/{slug}/fields` accepts `validation.relation`, and `validation.relationSide` for a relation whose two ends are the same collection, and binds the field to it instead of creating a relation: + +```jsonc +{ + "slug": "author", + "label": "Author", + "type": "reference", + "validation": { "relation": "post_authors" }, +} +``` + +The referenced collection and the selection limits come from the relation, so a `targetCollection` sent alongside a relation is ignored. Naming a relation the collection is not an end of, a side that contradicts the end that matches, or an end another field already picks from is refused — two fields picking from the same end of a relation would write the same links, and the second save would overwrite the first. A field that names only a `targetCollection` still gets a relation created for it, as before. + +A field that names a relation binds to it; the side it views follows from which end its collection sits on, and `relationSide` is needed only for a relation whose two ends are the same collection. A field that names only a `targetCollection` still gets a relation created for it. Re-applying a seed updates a relation's labels and limits under `onConflict: "update"`, but a seed naming different collections for an existing relation fails rather than leaving its links pointing into a collection that is no longer an end of it. + +Re-applying a seed that names a `targetCollection` for a reference field that predates relations binds that field, creating the relation and copying the column's ids in as links — the same path the admin takes — rather than leaving an upgraded site's field unbound forever. A seed can select from either end: a field bound to the child side takes the entries that point at it. A `$ref:` that names a collection emitted later in the file is skipped with a warning instead of aborting the whole apply. + +`emdash export-seed` emits those relations, and `--with-content` emits each entry's links as `$ref:` values on the parent side of the relation, so a site's reference selections survive an export and re-apply. Entry IDs in a reference field with no relation are emitted as `$ref:` too; previously they were emitted as a reference to the source database's row id, which resolved to nothing on apply. diff --git a/demos/simple/emdash-env.d.ts b/demos/simple/emdash-env.d.ts index 4c380e8e87..3fa12a1974 100644 --- a/demos/simple/emdash-env.d.ts +++ b/demos/simple/emdash-env.d.ts @@ -26,6 +26,7 @@ export interface Post { featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; content?: PortableTextBlock[]; excerpt?: string; + relevant_posts?: string; createdAt: Date; updatedAt: Date; publishedAt: Date | null; diff --git a/docs/src/content/docs/concepts/collections.mdx b/docs/src/content/docs/concepts/collections.mdx index 5d6b86cab9..38d72ba8ad 100644 --- a/docs/src/content/docs/concepts/collections.mdx +++ b/docs/src/content/docs/concepts/collections.mdx @@ -190,15 +190,16 @@ EmDash supports 16 field types that map to SQLite column types. ``` - Reference to another collection's entry. Stores entry ID as `TEXT`. + Links to entries in another collection. Adds no column: the links live in + `_emdash_content_references` and reads return them under `references`. ```ts { slug: "author", type: "reference", label: "Author", - options: { - collection: "authors" + validation: { + targetCollection: "authors" } } ``` @@ -291,13 +292,16 @@ The following reference field links to multiple products: slug: "relatedProducts", type: "reference", label: "Related Products", - options: { - collection: "products", - allowMultiple: true + validation: { + targetCollection: "products", + multiple: true } } ``` +See [`reference`](/reference/field-types/#reference) for where a reference field's links are stored +and how a field with no target collection behaves. + ## Querying collections Use the provided query functions to fetch content. These follow Astro's live collections pattern, returning structured results. The following example shows the common query options: diff --git a/docs/src/content/docs/guides/querying-content.mdx b/docs/src/content/docs/guides/querying-content.mdx index 529febeaf8..80a8d20495 100644 --- a/docs/src/content/docs/guides/querying-content.mdx +++ b/docs/src/content/docs/guides/querying-content.mdx @@ -196,6 +196,61 @@ interface ContentEntry { The `data` object within `entry` contains all fields defined for the content type. The `edit` proxy provides visual editing annotations (see below). +## Read Reference Fields + +A [`reference` field](/reference/field-types/#reference) links an entry to entries in another collection. Its value is not part of `data`. Pass a `references` option to `getEmDashEntry` naming the fields the page renders, keyed by field slug, and each one comes back as a page of entries: + +```astro title="src/pages/posts/[slug].astro" +--- +import { getEmDashEntry } from "emdash"; + +const { entry: post } = await getEmDashEntry("posts", Astro.params.slug, { + references: { author: true, related_posts: { limit: 6 } }, +}); + +if (!post) return Astro.redirect("/404"); + +const author = post.references?.author.entries[0]; +--- + +
+

{post.data.title}

+ {author &&

By {author.data.name}

} + +
+``` + +`true` requests the first page at the default limit of 50 entries. Use `{ limit, cursor }` for a field that holds more, up to 100 per page. + +Each referenced entry is a `ContentEntry` with the same shape as one loaded directly: an `id`, a `data` object with dates as `Date` objects and media values resolved, and an `edit` proxy scoped to the referenced entry, so clicking a card in visual editing opens the entry the card is about. Bylines and taxonomy terms are the exception — EmDash does not hydrate them onto referenced entries, so read `data.bylines` and `data.terms` from the entry itself. + +Entries arrive in the order the editor arranged them when the field sits on the parent end of its relation. A field on the child end lists the entries pointing at it, which have no order of their own. + +The option is opt-in in both directions. A call that passes no `references` runs no extra queries, and a field left out of the selection is not read. A call that selects fields costs one link query per field, plus one entry query per distinct target collection however many entries each field holds. + +### Paginate a reference field + +`getEmDashReferences` fetches the next page of a single field, using the cursor the previous page returned: + +```ts +import { getEmDashReferences } from "emdash"; + +const { entries, nextCursor } = await getEmDashReferences("posts", post.id, "related_posts", { + cursor, + limit: 20, +}); +``` + +It reads draft visibility from the same request context as `getEmDashEntry`, so a walk started in preview keeps seeing the pending selection. + +### Reference fields in preview + +A public render sees published entries and the published selection. A preview of the entry, or an editor in visual editing, sees unpublished entries and the selection staged in the entry's draft, so a preview link shows the references the page will have once it is published. + ## Rendering SEO Panel Data For collections with `supports: ["seo"]`, editors can set an SEO title, meta description, OG image, canonical URL, and a "hide from search engines" (noindex) toggle in the admin's SEO panel. That data is delivered on the entry as `entry.data.seo`. On server-rendered pages that include the `` component and fetch their entry through `getEmDashEntry()`, the panel values are applied to the rendered head automatically — description, image, canonical, and noindex need no template wiring, and the panel title feeds `og:title`/`twitter:title`. The `` element, prerendered pages, and templates that don't render `<EmDashHead>` still use `getSeoMeta`, which resolves the panel fields (with sensible fallbacks to `data.title` / `data.excerpt`) into ready-to-render meta tags: @@ -382,19 +437,37 @@ Generate TypeScript types for your collections: npx emdash types ``` -This creates `.emdash/types.ts` with interfaces for each collection. Use them for type safety: +This creates `.emdash/types.ts` with an interface for each collection. The file also registers every collection under its slug, so the query functions infer the right interface from the name you pass: ```ts import { getEmDashCollection, getEmDashEntry } from "emdash"; -import type { Post } from "../.emdash/types"; -// Type-safe collection query -const { entries: posts } = await getEmDashCollection<Post>("posts"); +const { entries: posts } = await getEmDashCollection("posts"); // posts is ContentEntry<Post>[] -// Type-safe entry query -const { entry: post } = await getEmDashEntry<Post>("posts", "my-post"); -// post is ContentEntry<Post> | null +const { entry: post } = await getEmDashEntry("posts", "my-post"); +// post.data.title is typed +``` + +Each collection that has a reference field bound to a relation gets a second interface, `{Collection}References`, registered the same way. `getEmDashEntry` narrows its result to the fields the `references` option named, and each page's entries carry the target collection's interface: + +```ts +const { entry: post } = await getEmDashEntry("posts", "my-post", { + references: { author: true }, +}); + +// post?.references?.author.entries[0] carries the Author interface +// post?.references?.related_posts is a type error: it was not selected +``` + +Import an interface by name when a component or helper needs to declare the shape it accepts: + +```ts +import type { Post } from "../.emdash/types"; + +function excerptOf(post: Post): string { + return post.excerpt ?? ""; +} ``` ## Static vs. Server Rendering diff --git a/docs/src/content/docs/reference/api.mdx b/docs/src/content/docs/reference/api.mdx index c1ef535e85..a79a9bf9bb 100644 --- a/docs/src/content/docs/reference/api.mdx +++ b/docs/src/content/docs/reference/api.mdx @@ -118,17 +118,19 @@ if (!post) { | ------------ | -------- | ---------------- | | `collection` | `string` | Collection slug | | `slugOrId` | `string` | Entry slug or ID | -| `options` | `{ locale?: string }` | Optional. Locale for slug resolution | +| `options` | `{ locale?: string; references?: ReferenceSelection }` | Optional. Locale for slug resolution, and the reference fields to load | -Preview mode is handled automatically — the middleware detects `_preview` tokens and serves draft content via `AsyncLocalStorage`. The optional `options` parameter only accepts a `locale` for slug resolution; preview state requires no parameter. +Preview mode is handled automatically — the middleware detects `_preview` tokens and serves draft content via `AsyncLocalStorage`. Preview state requires no parameter. + +`references` names the reference fields to load, keyed by field slug. `true` requests the first page at the default limit of 50 entries; the object form takes a `limit` (at most 100) and the `cursor` from a previous page. Fields left out are not read, and a call that omits the option runs no reference queries. #### Returns The function resolves to an `EntryResult`: ```ts -interface EntryResult<T> { - entry: ContentEntry<T> | null; // null if not found +interface EntryResult<T, R> { + entry: ContentEntry<T, R> | null; // null if not found error?: Error; // Set only for actual errors, not "not found" isPreview: boolean; // true if draft content is being served } @@ -164,9 +166,10 @@ if (!entry) { Query functions return entries in the following shape: ```ts -interface ContentEntry<T = Record<string, unknown>> { +interface ContentEntry<T = Record<string, unknown>, R = ReferencePages> { id: string; data: T; + references?: R; // One page per reference field requested; absent otherwise edit: EditProxy; // Visual editing annotations } ``` @@ -183,6 +186,45 @@ The `data` object contains all content fields plus system fields: - `publishedAt` - Publication timestamp or null; retained when content is unpublished - Plus all custom fields defined in your collection schema +### `ReferencePage` + +`entry.references` holds one page per reference field that `getEmDashEntry` was asked for, keyed by field slug: + +```ts +interface ReferencePage<T = Record<string, unknown>> { + entries: ContentEntry<T>[]; + nextCursor?: string; // Set when the field holds more entries than the limit +} +``` + +Each entry is mapped the way a directly loaded entry is, with one exception: bylines and taxonomy terms are not hydrated, so `data.bylines` and `data.terms` are absent. + +### `getEmDashReferences()` + +Fetch one page of a single reference field, without re-reading the entry it hangs off. Use it to walk past the first page with the `nextCursor` that page returned. + +| Parameter | Type | Description | +| ------------ | -------- | ---------------- | +| `collection` | `string` | Collection slug of the entry that holds the field | +| `slugOrId` | `string` | Entry slug or ID | +| `field` | `string` | Reference field slug | +| `options` | `{ limit?: number; cursor?: string; locale?: string }` | Optional. Defaults to 50 entries, at most 100 | + +The following example loads the next 20 linked entries: + +```ts +import { getEmDashReferences } from "emdash"; + +const { entries, nextCursor, error } = await getEmDashReferences( + "posts", + post.id, + "related_posts", + { cursor, limit: 20 }, +); +``` + +`error` is set only for actual errors. An unknown field, a missing entry, or one the request may not see all resolve to an empty `entries` array. + ## Preview system ### `generatePreviewToken()` diff --git a/docs/src/content/docs/reference/field-types.mdx b/docs/src/content/docs/reference/field-types.mdx index 2d5b94f664..b9da0b6838 100644 --- a/docs/src/content/docs/reference/field-types.mdx +++ b/docs/src/content/docs/reference/field-types.mdx @@ -11,24 +11,24 @@ EmDash supports 16 field types for defining content schemas. Each type maps to a The following table lists every field type and its SQLite column: -| Type | SQLite Column | Description | -| -------------- | ------------- | -------------------------- | -| `string` | TEXT | Short text input | -| `text` | TEXT | Multi-line text | -| `url` | TEXT | URL value | -| `number` | REAL | Decimal number | -| `integer` | INTEGER | Whole number | -| `boolean` | INTEGER | True/false | -| `datetime` | TEXT | Date and time | -| `select` | TEXT | Single choice from options | -| `multiSelect` | JSON | Multiple choices | -| `portableText` | JSON | Rich text content | -| `image` | TEXT | Image reference | -| `file` | TEXT | File reference | -| `reference` | TEXT | Reference to another entry | -| `json` | JSON | Arbitrary JSON data | -| `slug` | TEXT | URL-safe identifier | -| `repeater` | JSON | Repeating group of fields | +| Type | SQLite Column | Description | +| -------------- | ------------- | -------------------------------------- | +| `string` | TEXT | Short text input | +| `text` | TEXT | Multi-line text | +| `url` | TEXT | URL value | +| `number` | REAL | Decimal number | +| `integer` | INTEGER | Whole number | +| `boolean` | INTEGER | True/false | +| `datetime` | TEXT | Date and time | +| `select` | TEXT | Single choice from options | +| `multiSelect` | JSON | Multiple choices | +| `portableText` | JSON | Rich text content | +| `image` | TEXT | Image reference | +| `file` | TEXT | File reference | +| `reference` | none | Links to entries in another collection | +| `json` | JSON | Arbitrary JSON data | +| `slug` | TEXT | URL-safe identifier | +| `repeater` | JSON | Repeating group of fields | ## Text Types @@ -350,7 +350,7 @@ canonical lookup APIs when you need fresh metadata or a provider-specific URL. ### `reference` -Reference to another content entry. +Links an entry to entries in another collection. ```ts { @@ -358,29 +358,51 @@ Reference to another content entry. label: "Author", type: "reference", required: true, - options: { - collection: "authors", + validation: { + targetCollection: "authors", + multiple: false, }, } ``` -**Widget options:** +**Validation:** + +- `targetCollection` — Slug of the collection this field links to (required) +- `multiple` — Allow more than one linked entry (default: false) -- `collection` — Target collection slug (required) -- `allowMultiple` — Allow multiple references (default: false) +A reference field adds no column to the collection table. Its links live in +`_emdash_content_references`, keyed by each entry's translation group, so a selection is shared +across an entry's translations rather than set per locale. Content reads return the linked entries +under `references`, keyed by field slug, instead of in `data`. In a template, ask for the field by +name — see [Read reference fields](/guides/querying-content/#read-reference-fields). -A single reference is stored as the target entry ID: +Creating the field also creates the relation behind it: a schema object naming the two collections it +joins, with a label for each side and the limit on how many entries each side may link. +`targetCollection` is fixed once that relation exists — change it by deleting the field and adding a +new one. + +#### Fields with no target collection + +A reference field can exist without a target: one created before EmDash modelled relations named its +target in `options.collection`, which the admin had no way to set, and a seed can leave it out. Such +a field keeps a `TEXT` column and behaves like a plain string holding one entry ID: ```json "01HXK5MZSN..." ``` -Multiple references are stored as an array of entry IDs: +Or, for a field whose `options.allowMultiple` was set, a JSON array of entry IDs: ```json ["01HXK5MZSN...", "01HXK6NATS..."] ``` +Its value saves, loads, and validates like any other string, and the field can be indexed and used +as a content-list filter. To turn it into a picker, open it under Content Types and choose a +referenced collection: EmDash creates the relation, copies the entry IDs in the column in as links, +and clears the field's searchable and indexed flags. The column itself is left alone and stops being +written. + ## Flexible Types ### `json` diff --git a/docs/src/content/docs/themes/seed-files.mdx b/docs/src/content/docs/themes/seed-files.mdx index 6e5e745c2c..75c3d4bff5 100644 --- a/docs/src/content/docs/themes/seed-files.mdx +++ b/docs/src/content/docs/themes/seed-files.mdx @@ -18,6 +18,7 @@ A seed file has the following top-level shape: "meta": {}, "settings": {}, "collections": [], + "relations": [], "taxonomies": [], "bylines": [], "menus": [], @@ -28,20 +29,21 @@ A seed file has the following top-level shape: } ``` -| Field | Type | Required | Description | -| ------------- | -------- | -------- | ------------------------------------- | -| `$schema` | `string` | No | JSON schema URL for editor validation | -| `version` | `"1"` | Yes | Seed format version | -| `meta` | `object` | No | Metadata about the seed | -| `settings` | `object` | No | Site settings | -| `collections` | `array` | No | Collection definitions | -| `taxonomies` | `array` | No | Taxonomy definitions | -| `bylines` | `array` | No | Byline profile definitions | -| `menus` | `array` | No | Navigation menus | -| `redirects` | `array` | No | Redirect rules | -| `widgetAreas` | `array` | No | Widget area definitions | -| `sections` | `array` | No | Reusable content blocks | -| `content` | `object` | No | Sample content entries | +| Field | Type | Required | Description | +| ------------- | -------- | -------- | --------------------------------------- | +| `$schema` | `string` | No | JSON schema URL for editor validation | +| `version` | `"1"` | Yes | Seed format version | +| `meta` | `object` | No | Metadata about the seed | +| `settings` | `object` | No | Site settings | +| `collections` | `array` | No | Collection definitions | +| `relations` | `array` | No | Relations that reference fields bind to | +| `taxonomies` | `array` | No | Taxonomy definitions | +| `bylines` | `array` | No | Byline profile definitions | +| `menus` | `array` | No | Navigation menus | +| `redirects` | `array` | No | Redirect rules | +| `widgetAreas` | `array` | No | Widget area definitions | +| `sections` | `array` | No | Reusable content blocks | +| `content` | `object` | No | Sample content entries | ## Meta @@ -157,23 +159,90 @@ Each collection definition creates a content type in the database: ### Field Types -| Type | Description | Stored As | -| -------------- | -------------------------- | ----------------- | -| `string` | Short text | `TEXT` | -| `text` | Long text (textarea) | `TEXT` | -| `number` | Numeric value | `REAL` | -| `integer` | Whole number | `INTEGER` | -| `boolean` | True/false | `INTEGER` | -| `date` | Date value | `TEXT` (ISO 8601) | -| `datetime` | Date and time | `TEXT` (ISO 8601) | -| `email` | Email address | `TEXT` | -| `url` | URL | `TEXT` | -| `slug` | URL-safe string | `TEXT` | -| `portableText` | Rich text content | `JSON` | -| `image` | Image reference | `JSON` | -| `file` | File reference | `JSON` | -| `json` | Arbitrary JSON | `JSON` | -| `reference` | Reference to another entry | `TEXT` | +| Type | Description | Stored As | +| -------------- | --------------------------- | ------------------------------------ | +| `string` | Short text | `TEXT` | +| `text` | Long text (textarea) | `TEXT` | +| `number` | Numeric value | `REAL` | +| `integer` | Whole number | `INTEGER` | +| `boolean` | True/false | `INTEGER` | +| `date` | Date value | `TEXT` (ISO 8601) | +| `datetime` | Date and time | `TEXT` (ISO 8601) | +| `email` | Email address | `TEXT` | +| `url` | URL | `TEXT` | +| `slug` | URL-safe string | `TEXT` | +| `portableText` | Rich text content | `JSON` | +| `image` | Image reference | `JSON` | +| `file` | File reference | `JSON` | +| `json` | Arbitrary JSON | `JSON` | +| `reference` | Links to another collection | nothing; see [Relations](#relations) | + +## Relations + +A relation joins two collections and owns the links between their entries. A reference field binds to +one and views its links from one end: + +```json +{ + "relations": [ + { + "slug": "post_authors", + "parentCollection": "posts", + "childCollection": "authors", + "parentLabel": "Posts", + "parentLabelSingular": "Post", + "childLabel": "Authors", + "childLabelSingular": "Author", + "maxChildrenPerParent": 1 + } + ] +} +``` + +| Property | Type | Required | Description | +| ---------------------- | ---------------- | -------- | ------------------------------------------------------ | +| `slug` | `string` | Yes | Unique name a reference field addresses it by | +| `parentCollection` | `string` | Yes | Collection on the parent end | +| `childCollection` | `string` | Yes | Collection on the child end | +| `parentLabel` | `string` | Yes | Names the parent's role, as seen from the child | +| `parentLabelSingular` | `string` | No | Singular form of `parentLabel` | +| `childLabel` | `string` | Yes | Names the child's role, as seen from the parent | +| `childLabelSingular` | `string` | No | Singular form of `childLabel` | +| `maxChildrenPerParent` | `number \| null` | No | How many children one parent may link (null: no limit) | +| `maxParentsPerChild` | `number \| null` | No | How many parents one child may link (null: no limit) | + +A reference field names the relation it binds to: + +```json +{ + "slug": "author", + "label": "Author", + "type": "reference", + "validation": { "relation": "post_authors" } +} +``` + +The side follows from which end of the relation the field's collection sits on, and the target +collection is the other end. Add `"relationSide": "parent"` or `"child"` only for a relation whose +two ends are the same collection, where both would match. + +A field can name a `targetCollection` instead of a relation, and gets one created for it: + +```json +{ + "slug": "author", + "label": "Author", + "type": "reference", + "validation": { "targetCollection": "authors", "multiple": false } +} +``` + +That is the shorter path for a link only one collection views. Declare the relation when both +collections should view it, or to set its labels and limits. + +A relation's two collections are fixed once it exists: a seed naming different ones fails rather than +leaving the links it holds pointing into a collection that is no longer an end of it. Labels and +limits are updated when the seed is applied with `onConflict: "update"`. ## Taxonomies @@ -523,6 +592,9 @@ Reference other content entries using the `$ref:` prefix: The `$ref:` prefix resolves seed IDs to database IDs during seeding. +For a reference field, declare the links from the parent end of its relation. Both ends view one set +of links, so a field on the child collection would restate links the parent already carries. + ## Media References Include images from URLs: diff --git a/fixtures/perf-site/seed/seed.json b/fixtures/perf-site/seed/seed.json index d1a9ca2e8d..e2b73381ac 100644 --- a/fixtures/perf-site/seed/seed.json +++ b/fixtures/perf-site/seed/seed.json @@ -40,6 +40,15 @@ "slug": "excerpt", "label": "Excerpt", "type": "text" + }, + { + "slug": "related_posts", + "label": "Related posts", + "type": "reference", + "validation": { + "relation": "posts_related_posts", + "relationSide": "parent" + } } ] }, @@ -65,6 +74,17 @@ ] } ], + "relations": [ + { + "slug": "posts_related_posts", + "parentCollection": "posts", + "childCollection": "posts", + "parentLabel": "Referenced by", + "parentLabelSingular": "Referenced by", + "childLabel": "Related posts", + "childLabelSingular": "Related post" + } + ], "taxonomies": [ { "name": "category", @@ -684,6 +704,7 @@ "data": { "title": "Designing with Constraints", "excerpt": "Limitations aren't obstacles to creativity. They're the structure that makes creativity possible.", + "related_posts": ["$ref:post-1", "$ref:post-2", "$ref:post-3"], "featured_image": { "$media": { "url": "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=1200&h=800&fit=crop", @@ -838,6 +859,7 @@ "data": { "title": "Notes on Simplicity", "excerpt": "Simplicity isn't the absence of complexity. It's the result of understanding a problem well enough to solve it cleanly.", + "related_posts": ["$ref:post-4", "$ref:post-6"], "featured_image": { "$media": { "url": "https://images.unsplash.com/photo-1559051668-e1fa58f25786?w=1200&h=800&fit=crop", diff --git a/fixtures/perf-site/src/pages/related-baseline.astro b/fixtures/perf-site/src/pages/related-baseline.astro new file mode 100644 index 0000000000..d1cfa84517 --- /dev/null +++ b/fixtures/perf-site/src/pages/related-baseline.astro @@ -0,0 +1,17 @@ +--- +// The same entry as /related, read without the `references` option. A caller +// that asks for no references must issue nothing extra — cold start included — +// so this route's query count is the floor the paired one is measured against. +import { getEmDashEntry } from "emdash"; +import Base from "../layouts/Base.astro"; + +const { entry: post, cacheHint } = await getEmDashEntry( + "posts", + "designing-with-constraints", +); +Astro.cache.set(cacheHint); +--- + +<Base title="Related posts (baseline)" description="The same entry without its reference field"> + <h1>{post?.data.title}</h1> +</Base> diff --git a/fixtures/perf-site/src/pages/related.astro b/fixtures/perf-site/src/pages/related.astro new file mode 100644 index 0000000000..30808a53d3 --- /dev/null +++ b/fixtures/perf-site/src/pages/related.astro @@ -0,0 +1,30 @@ +--- +// An entry rendered with one reference field hydrated. Pair with +// /related-baseline, which is the same page without the `references` option: +// the gap between the two snapshots is what opting in costs — one link query +// for the field, plus one entry query for the collection it points at. +import { getEmDashEntry } from "emdash"; +import Base from "../layouts/Base.astro"; + +const { entry: post, cacheHint } = await getEmDashEntry( + "posts", + "designing-with-constraints", + { references: { related_posts: true } }, +); +Astro.cache.set(cacheHint); + +const related = post?.references?.related_posts?.entries ?? []; +--- + +<Base title="Related posts" description="An entry with its reference field resolved"> + <h1>{post?.data.title}</h1> + <ul> + { + related.map((entry) => ( + <li> + <a href={`/posts/${entry.id}`}>{entry.data.title}</a> + </li> + )) + } + </ul> +</Base> diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 519094ad46..bbbd7ed97e 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -7,6 +7,7 @@ import { InputArea, Label, LinkButton, + Loader, Select, Sidebar, Switch, @@ -20,7 +21,12 @@ import { X, ArrowsInSimple, ArrowsOutSimple, + CaretUp, + CaretDown, + Plus, + Trash, } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; import type { Editor } from "@tiptap/react"; import * as React from "react"; @@ -32,7 +38,12 @@ import type { UserListItem, TranslationSummary, } from "../lib/api"; -import { getPreviewUrl, getDraftStatus } from "../lib/api"; +import { + fetchReferenceChildren, + fetchReferenceParents, + getPreviewUrl, + getDraftStatus, +} from "../lib/api"; import { getContentPublishingState } from "../lib/content-publishing-state.js"; import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; import { getEntryTitle } from "../lib/entryTitle.js"; @@ -44,6 +55,7 @@ import { getLocaleDir } from "../locales/config.js"; import { useLocale } from "../locales/useLocale.js"; import { ArrowPrev } from "./ArrowIcons.js"; import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js"; +import { ContentPickerModal, type PickedContentEntry } from "./ContentPickerModal.js"; import { ContentSettingsPanel, DiscardDraftDialog, @@ -120,6 +132,85 @@ export interface FieldDescriptor { validation?: Record<string, unknown>; } +/** + * A single staged reference row in the editor. `title` comes from the picker + * for freshly added rows and from the server's resolved refs for hydrated rows; + * it falls back to slug/id for display when the entry has no title/name. + */ +export type ReferenceEntryRow = { + id: string; + slug: string | null; + title?: string; + locale?: string | null; + /** + * The referenced entry's translation group. `id` is whichever locale variant + * the server resolved for this editor's locale, so the group is what the + * picker matches against to recognize an entry that is already linked. + */ + translationGroup?: string | null; +}; + +type ReferenceGroupState = { + /** The last-saved id order — the diff baseline for dirty tracking. */ + baseline: ReferenceEntryRow[]; + /** The user's current staged selection. */ + current: ReferenceEntryRow[]; + /** Set while more pages of the hydrated set remain to be loaded. */ + nextCursor?: string; + loading: boolean; + /** Set when a page load failed. Stops auto-paging so a failing request never + * retries in a tight loop; cleared when the state is reseeded for a new entry. */ + error?: boolean; +}; + +/** Seed reference state from a hydrated item (first page per reference field). */ +function seedReferenceState(item?: ContentItem | null): Record<string, ReferenceGroupState> { + const out: Record<string, ReferenceGroupState> = {}; + const refs = item?.references; + if (!refs) return out; + for (const [group, page] of Object.entries(refs)) { + const rows: ReferenceEntryRow[] = page.children.map((c) => ({ + id: c.id, + slug: c.slug, + title: c.title ?? undefined, + locale: c.locale, + translationGroup: c.translationGroup, + })); + out[group] = { baseline: rows, current: rows, nextCursor: page.nextCursor, loading: false }; + } + return out; +} + +/** Order-sensitive id comparison of two reference-row lists. */ +function sameReferenceIds(a: ReferenceEntryRow[], b: ReferenceEntryRow[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i]?.id !== b[i]?.id) return false; + } + return true; +} + +/** + * Build the `references` save payload from staged state, keyed by field slug. + * Only fields whose id list has changed are included: the server replaces the + * links of every field it receives, so sending an untouched (and possibly + * not-yet-fully-loaded) field would risk overwriting it with a partial list. + * Untouched fields are omitted and left as-is on the server. + */ +function buildReferencesPayload( + state: Record<string, ReferenceGroupState>, +): Record<string, string[]> | undefined { + const out: Record<string, string[]> = {}; + let any = false; + for (const [group, s] of Object.entries(state)) { + if (!sameReferenceIds(s.baseline, s.current)) { + out[group] = s.current.map((r) => r.id); + any = true; + } + } + return any ? out : undefined; +} + /** Simplified user info for current user context */ export interface CurrentUserInfo { id: string; @@ -147,12 +238,14 @@ export interface ContentEditorProps { data: Record<string, unknown>; slug?: string; bylines?: BylineCreditInput[]; + references?: Record<string, string[]>; }) => void; /** Callback for autosave (debounced, skips revision creation) */ onAutosave?: (payload: { data: Record<string, unknown>; slug?: string; bylines?: BylineCreditInput[]; + references?: Record<string, string[]>; }) => void; /** Whether autosave is in progress */ isAutosaving?: boolean; @@ -339,6 +432,24 @@ export function ContentEditor({ // would wipe them. const [bylinesTouched, setBylinesTouched] = React.useState(false); + // Staged reference-field selections, keyed by field slug. + // Seeded from the hydrated first page; the picker fills titles for + // newly added rows. Edges save inside the content payload — never via edge + // POSTs. + const [referenceState, setReferenceState] = React.useState<Record<string, ReferenceGroupState>>( + () => seedReferenceState(item), + ); + // Mirror in a ref so save/autosave/load-more callbacks read fresh state + // without re-subscribing. + const referenceStateRef = React.useRef(referenceState); + referenceStateRef.current = referenceState; + // Snapshot of the reference groups sent in the in-flight autosave, applied + // as the new baseline when the autosave resolves (mirrors the data path's + // pendingAutosaveStateRef, since autosave patches the cache without a refetch). + const pendingAutosaveReferencesRef = React.useRef<Record<string, ReferenceEntryRow[]> | null>( + null, + ); + // Track portableText editor for document outline. Only the "content" // field wires its editor into this slot (see onEditorReady below). const [portableTextEditor, setPortableTextEditor] = React.useState<Editor | null>(null); @@ -413,6 +524,8 @@ export function ContentEditor({ }), ); pendingAutosaveStateRef.current = null; + pendingAutosaveReferencesRef.current = null; + setReferenceState(seedReferenceState(item)); setRejectedAutosaveState(null); setBylinesTouched(false); } @@ -443,9 +556,25 @@ export function ContentEditor({ }), ); pendingAutosaveStateRef.current = null; + // Re-seed references only when the item carries hydrated references. + // Autosave patches the content cache with a server item that has no + // `references` key (hydration is opt-in on the editor GET route only) — + // re-seeding from that would wipe the staged rows. The autosave baseline + // reset instead runs off `lastAutosaveAt` below. + if (item.references) { + setReferenceState(seedReferenceState(item)); + pendingAutosaveReferencesRef.current = null; + } setRejectedAutosaveState(null); } - }, [item?.updatedAt, itemDataString, itemBylinesString, item?.slug, item?.status]); + }, [ + item?.updatedAt, + itemDataString, + itemBylinesString, + item?.slug, + item?.status, + item?.references, + ]); const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines; const unsupportedPortableTextMarks = React.useMemo(() => { @@ -485,7 +614,13 @@ export function ContentEditor({ }), [formData, slug, activeBylines], ); - const isDirty = isNew || currentData !== lastSavedData; + // References live outside `serializeEditorState` — they carry their own + // baseline/current diff (order-sensitive id lists). + const referencesDirty = React.useMemo( + () => Object.values(referenceState).some((s) => !sameReferenceIds(s.baseline, s.current)), + [referenceState], + ); + const isDirty = isNew || currentData !== lastSavedData || referencesDirty; const saveFeedbackActive = isSaveFeedbackActive ?? isSaving; const autosaveFeedbackActive = isAutosaveFeedbackActive ?? isAutosaving; // Read at call time, not captured: a control that has not re-rendered since the @@ -496,6 +631,97 @@ export function ContentEditor({ const isContentSaveBlocked = isContentOperationPending || hasUnsupportedPortableTextMarks || readOnly; + // Replace a reference field's staged current selection (add/remove/reorder). + // Upserts the field so one with no hydrated rows can take its first pick. + const handleReferenceCurrentChange = React.useCallback( + (fieldSlug: string, rows: ReferenceEntryRow[]) => { + setReferenceState((prev) => { + const existing = prev[fieldSlug]; + return { + ...prev, + [fieldSlug]: existing + ? { ...existing, current: rows } + : { baseline: [], current: rows, loading: false }, + }; + }); + }, + [], + ); + + // Page the rest of a field's hydrated set. The full set must be loaded before + // reorder/remove so a save never emits a partial (truncating) list. + // + // State is keyed by field slug, the key the entry API takes a selection under, + // while the paging routes address the relation — so the relation and the side + // the field views come from the field descriptor. + const handleLoadMoreReferences = React.useCallback( + async (group: string) => { + if (!item?.id) return; + const st = referenceStateRef.current[group]; + if (!st || !st.nextCursor || st.loading) return; + const validation = fields[group]?.validation; + const relation = typeof validation?.relation === "string" ? validation.relation : undefined; + if (!relation) return; + const onChildSide = validation?.relationSide === "child"; + const cursor = st.nextCursor; + setReferenceState((prev) => { + const cur = prev[group]; + return cur ? { ...prev, [group]: { ...cur, loading: true } } : prev; + }); + try { + const res = onChildSide + ? await fetchReferenceParents(collection, item.id, relation, { cursor }).then((page) => ({ + children: page.parents, + nextCursor: page.nextCursor, + })) + : await fetchReferenceChildren(collection, item.id, relation, { cursor }); + const rows: ReferenceEntryRow[] = res.children.map((c) => ({ + id: c.id, + slug: c.slug, + title: c.title ?? undefined, + locale: c.locale, + translationGroup: c.translationGroup, + })); + setReferenceState((prev) => { + const cur = prev[group]; + if (!cur) return prev; + // Loading appends to the baseline. If the user hasn't diverged yet + // (current === baseline), mirror the append into current too so the + // newly loaded rows appear without registering as an edit. + const unedited = sameReferenceIds(cur.baseline, cur.current); + const seen = new Set(cur.baseline.map((r) => r.id)); + const nextBaseline = [...cur.baseline, ...rows.filter((r) => !seen.has(r.id))]; + return { + ...prev, + [group]: { + baseline: nextBaseline, + current: unedited ? nextBaseline : cur.current, + nextCursor: res.nextCursor, + loading: false, + }, + }; + }); + } catch { + setReferenceState((prev) => { + const cur = prev[group]; + // Flag the failure so the auto-page effect stops retrying — clearing + // only `loading` would leave `nextCursor` set and spin the request. + return cur ? { ...prev, [group]: { ...cur, loading: false, error: true } } : prev; + }); + } + }, + [collection, item?.id, fields], + ); + + // Clearing the flag is the whole retry: the auto-page effect gates on it and + // re-fires against the unchanged `nextCursor`. + const handleRetryReferences = React.useCallback((group: string) => { + setReferenceState((prev) => { + const cur = prev[group]; + return cur ? { ...prev, [group]: { ...cur, error: false } } : prev; + }); + }, []); + // Autosave with debounce // Track pending autosave to cancel on manual save const autosaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); @@ -505,12 +731,31 @@ export function ContentEditor({ slugRef.current = slug; React.useEffect(() => { - if (!autosaveCompletionToken || !pendingAutosaveStateRef.current) { + if (!autosaveCompletionToken) { return; } - setLastSavedData(pendingAutosaveStateRef.current); - pendingAutosaveStateRef.current = null; + if (pendingAutosaveStateRef.current) { + setLastSavedData(pendingAutosaveStateRef.current); + pendingAutosaveStateRef.current = null; + } + + // Mark the reference groups that autosave just persisted as saved by + // advancing their baseline to the sent snapshot. Editing further before + // the autosave resolved leaves `current` ahead of this baseline, so the + // group stays dirty and re-autosaves. + if (pendingAutosaveReferencesRef.current) { + const snapshot = pendingAutosaveReferencesRef.current; + pendingAutosaveReferencesRef.current = null; + setReferenceState((prev) => { + const next = { ...prev }; + for (const [group, rows] of Object.entries(snapshot)) { + const cur = next[group]; + if (cur) next[group] = { ...cur, baseline: rows }; + } + return next; + }); + } }, [autosaveCompletionToken]); React.useEffect(() => { @@ -545,11 +790,14 @@ export function ContentEditor({ data: Record<string, unknown>; slug?: string; bylines?: BylineCreditInput[]; + references?: Record<string, string[]>; } = { data: formDataRef.current, slug: slugRef.current || undefined, }; if (isNew || bylinesTouched) payload.bylines = activeBylines; + const references = buildReferencesPayload(referenceStateRef.current); + if (references) payload.references = references; return payload; }, [activeBylines, bylinesTouched, isNew]); const cancelPendingAutosave = React.useCallback(() => { @@ -596,6 +844,14 @@ export function ContentEditor({ autosaveTimeoutRef.current = setTimeout(() => { if (hasInvalidUrls(formDataRef.current)) return; const payload = createSavePayload(); + if (payload.references) { + // Remember what we sent so the baseline can advance on resolve. + const snapshot: Record<string, ReferenceEntryRow[]> = {}; + for (const group of Object.keys(payload.references)) { + snapshot[group] = referenceStateRef.current[group]?.current ?? []; + } + pendingAutosaveReferencesRef.current = snapshot; + } pendingAutosaveStateRef.current = serializeEditorState({ data: payload.data, slug: payload.slug || "", @@ -621,6 +877,7 @@ export function ContentEditor({ bylinesTouched, createSavePayload, hasInvalidUrls, + referenceState, hasUnsupportedPortableTextMarks, isPublishing, readOnly, @@ -1120,6 +1377,13 @@ export function ContentEditor({ } manifest={manifest} readOnly={readOnly} + referenceState={referenceState} + onReferenceChange={handleReferenceCurrentChange} + onLoadMoreReferences={handleLoadMoreReferences} + onRetryReferences={handleRetryReferences} + // Existing entries carry their locale on `item`; new entries only + // have the URL-derived `entryLocale`. Mirror ContentSettingsPanel. + entryLocale={item?.locale ?? entryLocale} /> ); return fieldEl; @@ -1470,6 +1734,16 @@ interface FieldRendererProps { onBlockSidebarClose?: () => void; /** Admin manifest for resolving sandboxed field widget elements */ manifest?: import("../lib/api/client.js").AdminManifest | null; + /** Staged reference selections for every reference field, by field slug. */ + referenceState?: Record<string, ReferenceGroupState>; + /** Replace a reference field's staged current selection. */ + onReferenceChange?: (fieldSlug: string, rows: ReferenceEntryRow[]) => void; + /** Page the rest of a relation's hydrated set. */ + onLoadMoreReferences?: (group: string) => void; + /** Clear a reference field's load error so paging resumes from the same cursor. */ + onRetryReferences?: (fieldSlug: string) => void; + /** Locale of the editing entry; threaded to reference pickers. */ + entryLocale?: string | null; /** Render the value without accepting edits. */ readOnly?: boolean; } @@ -1488,6 +1762,11 @@ function FieldRenderer({ onBlockSidebarOpen, onBlockSidebarClose, manifest, + referenceState, + onReferenceChange, + onLoadMoreReferences, + onRetryReferences, + entryLocale, readOnly = false, }: FieldRendererProps) { const { t } = useLingui(); @@ -1773,6 +2052,46 @@ function FieldRenderer({ ); } + case "reference": { + const relationGroup = + typeof field.validation?.relation === "string" ? field.validation.relation : undefined; + const targetCollection = + typeof field.validation?.targetCollection === "string" + ? field.validation.targetCollection + : undefined; + const multiple = field.validation?.multiple !== false; + // A reference field created before relations existed keeps its own column + // holding one entry id, so it stays the text input it has always been + // until an admin gives it a target collection. + if (!relationGroup || !targetCollection) { + return ( + <Input + label={label} + id={id} + value={typeof value === "string" ? value : ""} + onChange={(e) => handleChange(e.target.value)} + required={field.required} + dir="auto" + description={t`Holds an entry ID. Set a target collection under Content Types to pick entries instead.`} + /> + ); + } + return ( + <ReferenceFieldRenderer + label={label} + labelClass={labelClass} + targetCollection={targetCollection} + multiple={multiple} + reorderable={field.validation?.relationSide !== "child"} + state={referenceState?.[name]} + onChange={(rows) => onReferenceChange?.(name, rows)} + onLoadMore={() => onLoadMoreReferences?.(name)} + onRetry={() => onRetryReferences?.(name)} + entryLocale={entryLocale} + /> + ); + } + case "json": { const jsonString = typeof value === "string" ? value : value != null ? JSON.stringify(value, null, 2) : ""; @@ -1815,6 +2134,230 @@ function FieldRenderer({ } } +/** Display label for a staged reference row: title, then slug, then id. */ +function referenceRowLabel(row: ReferenceEntryRow): string { + return row.title || row.slug || row.id; +} + +/** Identity of a staged row for selection/dedupe: the entry, not the variant. */ +function referenceRowKey(row: ReferenceEntryRow): string { + return row.translationGroup ?? row.id; +} + +/** + * Reference field editor. Renders the staged selections with remove/reorder + * controls and a picker to add more. All mutations flow through `onChange` + * into the parent's `referenceState`; nothing is persisted until the content + * entry saves (edges ride in the `references` payload key). + */ +function ReferenceFieldRenderer({ + label, + labelClass, + targetCollection, + multiple, + reorderable, + state, + onChange, + onLoadMore, + onRetry, + entryLocale, +}: { + label: string; + labelClass?: string; + targetCollection: string; + multiple: boolean; + /** + * Whether the selection has an order to change. A field on the child end of + * its relation has none: `sort_order` positions children within one parent, + * and nothing positions a child's parents. + */ + reorderable: boolean; + state?: ReferenceGroupState; + onChange: (rows: ReferenceEntryRow[]) => void; + onLoadMore: () => void; + onRetry: () => void; + /** Locale of the editing entry; scopes the picker to one variant per target. */ + entryLocale?: string | null; +}) { + const { t } = useLingui(); + const [pickerOpen, setPickerOpen] = React.useState(false); + + const rows = state?.current ?? []; + const nextCursor = state?.nextCursor; + const loading = state?.loading ?? false; + const loadError = state?.error ?? false; + // Reorder/remove are gated until the full hydrated set is loaded, so a save + // can never emit a truncated list that would delete the unloaded tail. + const fullyLoaded = !nextCursor && !loading; + + // Auto-page the remaining hydrated set so the field is edit-ready. Chains: + // each load advances `nextCursor`, re-firing until the set is exhausted. A + // failed page sets `error`, which halts the chain so a throwing request never + // retries in a tight loop; reseeding for a new entry clears it. + React.useEffect(() => { + if (nextCursor && !loading && !loadError) onLoadMore(); + }, [nextCursor, loading, loadError, onLoadMore]); + + // Keyed by translation group to match the picker's collapsed rows: a hydrated + // row's `id` is the variant resolved for this entry's locale, which need not + // be the variant the picker shows for the same entry. + const selectedIds = React.useMemo(() => new Set(rows.map((r) => referenceRowKey(r))), [rows]); + + const move = (index: number, delta: number) => { + const target = index + delta; + if (target < 0 || target >= rows.length) return; + const next = [...rows]; + const [moved] = next.splice(index, 1); + if (moved) next.splice(target, 0, moved); + onChange(next); + }; + + const remove = (index: number) => { + onChange(rows.filter((_, i) => i !== index)); + }; + + const handleConfirm = (picked: PickedContentEntry[]) => { + const additions: ReferenceEntryRow[] = picked.map((p) => ({ + id: p.id, + slug: p.slug, + title: p.title, + locale: p.locale, + translationGroup: p.translationGroup, + })); + if (multiple) { + const existing = new Set(rows.map((r) => referenceRowKey(r))); + onChange([...rows, ...additions.filter((a) => !existing.has(referenceRowKey(a)))]); + } else { + // Single-value: the picked entry replaces the current selection. + onChange(additions.slice(0, 1)); + } + }; + + return ( + <div> + <span className={cn("text-sm font-medium leading-none text-kumo-default", labelClass)}> + {label} + </span> + <div className="mt-2 space-y-2"> + {rows.length === 0 ? ( + <p className="text-sm text-kumo-subtle">{t`No references selected.`}</p> + ) : ( + <ul className="space-y-2"> + {rows.map((row, index) => { + return ( + <li + key={row.id} + className="flex items-center gap-2 rounded-md border bg-kumo-base px-3 py-2" + > + <Link + to="/content/$collection/$id" + params={{ collection: targetCollection, id: row.id }} + search={{ locale: row.locale ?? undefined }} + className="group min-w-0 flex-1" + > + <div className="truncate text-sm font-medium group-hover:underline"> + {referenceRowLabel(row)} + </div> + {row.slug && ( + <div className="flex items-center gap-2 text-xs text-kumo-subtle"> + <span className="truncate">{row.slug}</span> + </div> + )} + </Link> + <RouterLinkButton + to="/content/$collection/$id" + params={{ collection: targetCollection, id: row.id }} + search={{ locale: row.locale ?? undefined }} + target="_blank" + variant="ghost" + shape="square" + size="sm" + icon={<ArrowSquareOut className="h-4 w-4" />} + aria-label={t`Open ${referenceRowLabel(row)} in a new tab`} + /> + {multiple && reorderable && ( + <div className="flex items-center gap-1"> + <Button + type="button" + variant="ghost" + shape="square" + size="sm" + disabled={index === 0 || !fullyLoaded} + onClick={() => move(index, -1)} + aria-label={t`Move ${referenceRowLabel(row)} up`} + > + <CaretUp className="h-4 w-4" /> + </Button> + <Button + type="button" + variant="ghost" + shape="square" + size="sm" + disabled={index === rows.length - 1 || !fullyLoaded} + onClick={() => move(index, 1)} + aria-label={t`Move ${referenceRowLabel(row)} down`} + > + <CaretDown className="h-4 w-4" /> + </Button> + </div> + )} + <Button + type="button" + variant="ghost" + shape="square" + size="sm" + disabled={!fullyLoaded} + onClick={() => remove(index)} + aria-label={t`Remove ${referenceRowLabel(row)}`} + > + <Trash className="h-4 w-4 text-kumo-danger" /> + </Button> + </li> + ); + })} + </ul> + )} + + {loadError ? ( + <div className="flex flex-wrap items-center gap-2 text-sm"> + <span className="text-kumo-danger">{t`Couldn't load all references.`}</span> + <Button type="button" variant="outline" size="sm" onClick={onRetry}> + {t`Retry`} + </Button> + </div> + ) : ( + !fullyLoaded && ( + <div className="flex items-center gap-2 text-sm text-kumo-subtle"> + <Loader size="sm" /> {t`Loading references...`} + </div> + ) + )} + + <Button + type="button" + variant="outline" + size="sm" + icon={<Plus />} + disabled={!fullyLoaded} + onClick={() => setPickerOpen(true)} + > + {multiple ? t`Add reference` : rows.length > 0 ? t`Replace reference` : t`Add reference`} + </Button> + </div> + + <ContentPickerModal + open={pickerOpen} + onOpenChange={setPickerOpen} + collection={targetCollection} + multiple={multiple} + selectedIds={selectedIds} + onConfirm={handleConfirm} + locale={entryLocale ?? undefined} + /> + </div> + ); +} + const URL_PROTOCOL_PATTERN = /^https?:\/\//; function isValidUrl(val: string): boolean { diff --git a/packages/admin/src/components/ContentPickerModal.tsx b/packages/admin/src/components/ContentPickerModal.tsx index 2e560bc4fa..5e40dd4b08 100644 --- a/packages/admin/src/components/ContentPickerModal.tsx +++ b/packages/admin/src/components/ContentPickerModal.tsx @@ -1,14 +1,26 @@ /** * Content Picker Modal * - * A modal for browsing and selecting content items to add to menus. - * Uses cursor pagination to allow browsing beyond the initial page. + * A modal for browsing and selecting content entries. Serves two callers: + * + * - **Menus** browse across collections (a collection dropdown is shown) and + * pick a single entry. + * - **Reference fields** lock to a single target collection (dropdown hidden), + * and either pick one entry or stage several (`multiple`), disabling entries + * already linked (`selectedIds`). + * + * Search is served by the content list's `q` filter, which uses the + * collection's FTS5 index when available (LIKE fallback otherwise) — the same + * search the admin content list uses. Results are cursor-paginated through + * `useInfiniteQuery` so pages accumulate in the query cache; nothing is + * mirrored into local state, so reopening the modal shows the cached results + * immediately rather than an empty list. */ -import { Button, Dialog, Input, Loader, Select } from "@cloudflare/kumo"; +import { Button, Checkbox, Dialog, Input, Loader, Select } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { fetchCollections, fetchContentList, fetchManifest, getDraftStatus } from "../lib/api"; @@ -18,103 +30,195 @@ import { useDebouncedValue } from "../lib/hooks"; import { cn } from "../lib/utils"; import { ContentStatusLabel, type ContentStatusState } from "./ContentStatusBadge.js"; +/** A chosen content entry, carrying its collection and a display title. */ +export interface PickedContentEntry { + collection: string; + id: string; + slug: string | null; + title: string; + /** Locale of the picked variant, so links/badges keep locale context before hydration. */ + locale?: string; + /** Translation group of the picked variant — the locale-stable entry identity. */ + translationGroup?: string | null; +} + interface ContentPickerModalProps { open: boolean; onOpenChange: (open: boolean) => void; - onSelect: (item: { collection: string; id: string; title: string }) => void; + /** + * Lock the picker to a single collection and hide the dropdown (reference + * fields). When omitted, a collection dropdown is shown (menus). + */ + collection?: string; + /** Allow staging several entries before confirming. Defaults to single-select. */ + multiple?: boolean; + /** + * Entries already linked in the target field — rendered checked and disabled. + * Keyed the same way rows are: by translation group when `locale` is set + * (reference fields), by row id otherwise (menus). + */ + selectedIds?: ReadonlySet<string>; + /** Emit the chosen entries. Single-select emits a one-element array. */ + onConfirm: (rows: PickedContentEntry[]) => void; + /** Optional dialog title override. */ + title?: string; + /** + * The editing entry's locale (reference fields). When set, translations of the + * same entry collapse to one row, preferring this locale and falling back to + * another when the entry has no variant here — mirroring how the reference + * list resolves edges (`resolveEntries`/`pickVariant`). Edges are keyed by + * translation group, so a cross-locale target is still a valid pick, and + * `selectedIds` is matched by group for the same reason. + */ + locale?: string; } -export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPickerModalProps) { +const EMPTY_SELECTED: ReadonlySet<string> = new Set<string>(); + +export function ContentPickerModal({ + open, + onOpenChange, + collection, + multiple = false, + selectedIds = EMPTY_SELECTED, + onConfirm, + title, + locale, +}: ContentPickerModalProps) { const { t } = useLingui(); + const locked = !!collection; const [searchQuery, setSearchQuery] = React.useState(""); const debouncedSearch = useDebouncedValue(searchQuery, 300); - const [selectedCollection, setSelectedCollection] = React.useState<string>(""); - const [allItems, setAllItems] = React.useState<ContentItem[]>([]); - const [nextCursor, setNextCursor] = React.useState<string | undefined>(); - const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [dropdownCollection, setDropdownCollection] = React.useState<string>(""); + // Staged picks (multiple mode) — keyed by id so we retain title/slug. + const [picked, setPicked] = React.useState<Record<string, PickedContentEntry>>({}); const { data: collections = [] } = useQuery({ queryKey: ["collections"], queryFn: fetchCollections, - enabled: open, + enabled: open && !locked, }); + // Default the dropdown to the first collection once collections load. + React.useEffect(() => { + if (!locked && collections.length > 0 && !dropdownCollection) { + setDropdownCollection(collections[0]!.slug); + } + }, [locked, collections, dropdownCollection]); + + const activeCollection = collection ?? dropdownCollection; + // Reuse the cached manifest (same query key as the rest of the admin) to - // resolve the selected collection's titleField for entry titles. + // resolve the active collection's titleField for entry titles. const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, enabled: open, }); - const titleField = manifest?.collections[selectedCollection]?.titleField; + const titleField = manifest?.collections[activeCollection]?.titleField; - // Default to first collection when collections load + // Reset transient UI state when the modal opens. Result pages come from the + // query cache (below), so there is nothing to re-fetch or re-sync here. React.useEffect(() => { - if (collections.length > 0 && !selectedCollection) { - setSelectedCollection(collections[0]!.slug); + if (open) { + setSearchQuery(""); + setPicked({}); + if (!locked) setDropdownCollection(""); } - }, [collections, selectedCollection]); + }, [open, locked]); - const { data: contentResult, isLoading: contentLoading } = useQuery({ - queryKey: ["content-picker", selectedCollection, { limit: 50 }], - queryFn: () => fetchContentList(selectedCollection, { limit: 50 }), - enabled: open && !!selectedCollection, + const trimmedSearch = debouncedSearch.trim(); + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ["content-picker", activeCollection, trimmedSearch], + queryFn: ({ pageParam }) => + fetchContentList(activeCollection, { + limit: 50, + cursor: pageParam, + search: trimmedSearch || undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: open && !!activeCollection, }); - // Sync initial page into accumulated items - React.useEffect(() => { - if (contentResult) { - setAllItems(contentResult.items); - setNextCursor(contentResult.nextCursor); + const items = React.useMemo(() => { + const flat = data?.pages.flatMap((page) => page.items) ?? []; + if (!locale) return flat; + // Reference fields link by translation group, so translations of the same + // entry are the same target. Collapse them to one row, preferring the + // editor locale and falling back to the lowest locale code (deterministic), + // mirroring `pickVariant` in the reference-list resolver. + const byGroup = new Map<string, ContentItem>(); + const order: string[] = []; + for (const item of flat) { + const key = item.translationGroup ?? item.id; + const existing = byGroup.get(key); + if (!existing) { + byGroup.set(key, item); + order.push(key); + } else if ( + existing.locale !== locale && + (item.locale === locale || item.locale < existing.locale) + ) { + byGroup.set(key, item); + } } - }, [contentResult]); + return order.map((key) => byGroup.get(key)!); + }, [data, locale]); - const handleLoadMore = async () => { - if (!nextCursor || isLoadingMore) return; - setIsLoadingMore(true); - try { - const result = await fetchContentList(selectedCollection, { - limit: 50, - cursor: nextCursor, - }); - setAllItems((prev) => [...prev, ...result.items]); - setNextCursor(result.nextCursor); - } finally { - setIsLoadingMore(false); - } - }; + // A collapsed row stands for a translation group, not a row, so an entry + // already linked through a sibling locale (its edge resolves to whichever + // variant matches the editor's locale) must still read as linked here. + const selectionKey = (item: ContentItem) => + locale ? (item.translationGroup ?? item.id) : item.id; - const filteredItems = React.useMemo(() => { - if (!debouncedSearch) return allItems; - const query = debouncedSearch.toLowerCase(); - return allItems.filter((item) => getEntryTitle(item, titleField).toLowerCase().includes(query)); - }, [allItems, debouncedSearch, titleField]); + const togglePicked = (item: ContentItem) => { + setPicked((prev) => { + const next = { ...prev }; + if (next[item.id]) { + delete next[item.id]; + } else { + next[item.id] = { + collection: activeCollection, + id: item.id, + slug: item.slug, + title: getEntryTitle(item, titleField), + locale: item.locale, + translationGroup: item.translationGroup, + }; + } + return next; + }); + }; - // Reset state when modal opens or collection changes - React.useEffect(() => { - if (open) { - setSearchQuery(""); - setSelectedCollection(""); - setAllItems([]); - setNextCursor(undefined); - } - }, [open]); + const handleSingleChoose = (item: ContentItem) => { + onConfirm([ + { + collection: activeCollection, + id: item.id, + slug: item.slug, + title: getEntryTitle(item, titleField), + locale: item.locale, + translationGroup: item.translationGroup, + }, + ]); + onOpenChange(false); + }; - const handleSelect = (item: ContentItem) => { - onSelect({ - collection: selectedCollection, - id: item.id, - title: getEntryTitle(item, titleField), - }); + const handleConfirmMultiple = () => { + onConfirm(Object.values(picked)); onOpenChange(false); }; + const pickedCount = Object.keys(picked).length; + const dialogTitle = title ?? (multiple ? t`Add references` : t`Select content`); + return ( <Dialog.Root open={open} onOpenChange={onOpenChange}> <Dialog className="p-6 max-w-2xl h-[80vh] flex flex-col" size="lg"> <div className="flex items-start justify-between gap-4 mb-4"> <Dialog.Title className="text-lg font-semibold leading-none tracking-tight"> - {t`Select Content`} + {dialogTitle} </Dialog.Title> <Dialog.Close aria-label={t`Close`} @@ -133,7 +237,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick /> </div> - {/* Search and collection filter */} + {/* Search and (unlocked) collection filter */} <div className="flex items-center gap-4 py-4 border-b"> <div className="relative flex-1"> <MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" /> @@ -145,27 +249,28 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick autoFocus /> </div> - <Select - value={selectedCollection} - onValueChange={(v) => { - setSelectedCollection(v ?? ""); - setAllItems([]); - setNextCursor(undefined); - }} - items={Object.fromEntries(collections.map((col) => [col.slug, col.label]))} - aria-label={t`Collection`} - /> + {!locked && ( + <Select + value={dropdownCollection} + onValueChange={(v) => { + setDropdownCollection(v ?? ""); + setPicked({}); + }} + items={Object.fromEntries(collections.map((col) => [col.slug, col.label]))} + aria-label={t`Collection`} + /> + )} </div> {/* Content list */} <div className="flex-1 overflow-y-auto py-4"> - {contentLoading ? ( + {isLoading ? ( <div className="flex items-center justify-center h-32"> <div className="text-kumo-subtle">{t`Loading content...`}</div> </div> - ) : filteredItems.length === 0 ? ( + ) : items.length === 0 ? ( <div className="flex flex-col items-center justify-center h-32 text-center"> - {searchQuery ? ( + {trimmedSearch ? ( <> <MagnifyingGlass className="h-8 w-8 text-kumo-subtle mb-2" /> <p className="text-kumo-subtle">{t`No content found`}</p> @@ -180,47 +285,78 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick </div> ) : ( <div className="space-y-1"> - {filteredItems.map((item) => { + {items.map((item) => { const status = getDraftStatus(item); + const alreadyLinked = selectedIds.has(selectionKey(item)); + const isPicked = alreadyLinked || !!picked[item.id]; const statusState: ContentStatusState = status === "published" ? "published" : status === "published_with_changes" ? "pendingChanges" : "draft"; + const meta = ( + <div className="text-sm text-kumo-subtle flex items-center gap-2"> + <ContentStatusLabel state={statusState} /> + {item.slug && ( + <> + <span className="text-kumo-subtle/50">/</span> + <span>{item.slug}</span> + </> + )} + </div> + ); + + if (multiple) { + return ( + <label + key={item.id} + className={cn( + "flex items-start gap-3 rounded-md px-3 py-2 transition-colors", + alreadyLinked ? "opacity-60" : "cursor-pointer hover:bg-kumo-tint/50", + )} + > + <Checkbox + checked={isPicked} + disabled={alreadyLinked} + onCheckedChange={() => togglePicked(item)} + aria-label={getEntryTitle(item, titleField)} + /> + <div className="min-w-0"> + <div className="font-medium">{getEntryTitle(item, titleField)}</div> + {meta} + </div> + </label> + ); + } + return ( <button key={item.id} type="button" - onClick={() => handleSelect(item)} + disabled={alreadyLinked} + onClick={() => handleSingleChoose(item)} className={cn( "w-full text-start rounded-md px-3 py-2 transition-colors", - "hover:bg-kumo-tint/50", - "focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2", + alreadyLinked + ? "opacity-60" + : "hover:bg-kumo-tint/50 focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2", )} > <div className="font-medium">{getEntryTitle(item, titleField)}</div> - <div className="text-sm text-kumo-subtle flex items-center gap-2"> - <ContentStatusLabel state={statusState} /> - {item.slug && ( - <> - <span className="text-kumo-subtle/50">/</span> - <span>{item.slug}</span> - </> - )} - </div> + {meta} </button> ); })} - {nextCursor && !searchQuery && ( + {hasNextPage && ( <div className="pt-2 text-center"> <Button variant="outline" size="sm" - onClick={handleLoadMore} - disabled={isLoadingMore} + onClick={() => void fetchNextPage()} + disabled={isFetchingNextPage} > - {isLoadingMore ? ( + {isFetchingNextPage ? ( <> <Loader size="sm" /> {t`Loading...`} </> @@ -239,6 +375,11 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick <Button variant="outline" onClick={() => onOpenChange(false)}> {t`Cancel`} </Button> + {multiple && ( + <Button onClick={handleConfirmMultiple} disabled={pickedCount === 0}> + {t`Add selected`} + </Button> + )} </div> </Dialog> </Dialog.Root> diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 7a5046f6c6..8c896cc803 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -65,6 +65,7 @@ import { ImageDetailPanel } from "./editor/ImageDetailPanel"; import type { ImageAttributes } from "./editor/ImageDetailPanel"; import type { BlockSidebarPanel } from "./PortableTextEditor"; import { PublicationDateDialog } from "./PublishingDateTimeEditor.js"; +import { ReferencesSidebar } from "./ReferencesSidebar.js"; import { RevisionHistory } from "./RevisionHistory"; import { SaveButton } from "./SaveButton"; import { SeoPanel } from "./SeoPanel"; @@ -1111,6 +1112,12 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ </SortableContentSettingsSection> )} + {item && !isNew && ( + <SortableContentSettingsSection id="references" label={t`Referenced by`}> + <ReferencesSidebar className="p-4" collection={collection} entryId={item.id} /> + </SortableContentSettingsSection> + )} + {hasSeo && !isNew && onSeoChange && ( <SortableContentSettingsSection id="seo" label={t`SEO`}> <div className="p-4"> diff --git a/packages/admin/src/components/ContentTypeEditor.tsx b/packages/admin/src/components/ContentTypeEditor.tsx index f10f0de218..9fd843ef67 100644 --- a/packages/admin/src/components/ContentTypeEditor.tsx +++ b/packages/admin/src/components/ContentTypeEditor.tsx @@ -20,9 +20,11 @@ import type { MessageDescriptor } from "@lingui/core"; import { msg, plural } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { Plus, DotsSixVertical, Pencil, Trash, Database, FileText } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import * as React from "react"; +import { fetchCollections, fetchRelations } from "../lib/api"; import type { SchemaCollectionWithFields, SchemaField, @@ -30,11 +32,14 @@ import type { CreateCollectionInput, UpdateCollectionInput, } from "../lib/api"; +import type { CreateRelationInput } from "../lib/api/relations.js"; import { cn } from "../lib/utils"; import { ArrowPrev } from "./ArrowIcons.js"; import { ConfirmDialog } from "./ConfirmDialog"; import { EditorHeader } from "./EditorHeader"; import { FieldEditor } from "./FieldEditor"; +import { RelationImpact } from "./RelationImpact.js"; +import { RelationsPanel } from "./RelationsPanel.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; @@ -49,8 +54,12 @@ export interface ContentTypeEditorProps { onSave: (input: CreateCollectionInput | UpdateCollectionInput) => void; onAddField?: (input: CreateFieldInput) => void; onUpdateField?: (fieldSlug: string, input: CreateFieldInput) => void; - onDeleteField?: (fieldSlug: string) => void; + /** `deleteRelation` also removes the relationship a reference field views, + * its links, and the field on the other end. */ + onDeleteField?: (fieldSlug: string, options?: { deleteRelation?: boolean }) => void; onReorderFields?: (fieldSlugs: string[]) => void; + /** Resolves once the relation exists; rejects with the server's message. */ + onCreateRelation?: (input: CreateRelationInput) => Promise<unknown>; } interface SupportOptionDef { @@ -150,6 +159,7 @@ export function ContentTypeEditor({ onUpdateField, onDeleteField, onReorderFields, + onCreateRelation, }: ContentTypeEditorProps) { const { t } = useLingui(); const _navigate = useNavigate(); @@ -190,6 +200,9 @@ export function ContentTypeEditor({ const [editingField, setEditingField] = React.useState<SchemaField | undefined>(); const [fieldSaving, setFieldSaving] = React.useState(false); const [deleteFieldTarget, setDeleteFieldTarget] = React.useState<SchemaField | null>(null); + // Checked by default: deleting a reference field almost always means the + // relationship it views is finished too. + const [deleteFieldRelation, setDeleteFieldRelation] = React.useState(true); const urlPatternValid = !urlPattern || urlPattern.includes("{slug}"); @@ -305,6 +318,11 @@ export function ContentTypeEditor({ } }; + const requestDeleteField = (field: SchemaField) => { + setDeleteFieldRelation(true); + setDeleteFieldTarget(field); + }; + const handleEditField = (field: SchemaField) => { setEditingField(field); setFieldEditorOpen(true); @@ -318,6 +336,17 @@ export function ContentTypeEditor({ const isFromCode = collection?.source === "code"; const fields = collection?.fields ?? []; + const { data: relations = [], isLoading: relationsLoading } = useQuery({ + queryKey: ["relations"], + queryFn: () => fetchRelations(), + }); + const { data: allCollections = [] } = useQuery({ + queryKey: ["schema", "collections"], + queryFn: fetchCollections, + }); + const targetRelationSlug = deleteFieldTarget?.validation?.relation; + const targetRelation = relations.find((rel) => rel.slug === targetRelationSlug); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), @@ -606,9 +635,9 @@ export function ContentTypeEditor({ </form> </div> - {/* Fields section - only show for existing collections */} + {/* Fields and relations - only shown for existing collections */} {!isNew && ( - <div className="lg:col-span-2"> + <div className="lg:col-span-2 space-y-6"> <div className="rounded-lg border bg-kumo-base"> <div className="flex items-center justify-between p-4 border-b"> <div> @@ -672,7 +701,7 @@ export function ContentTypeEditor({ field={field} isFromCode={isFromCode} onEdit={() => handleEditField(field)} - onDelete={() => setDeleteFieldTarget(field)} + onDelete={() => requestDeleteField(field)} /> ))} </div> @@ -681,6 +710,16 @@ export function ContentTypeEditor({ </> )} </div> + + {collection && ( + <RelationsPanel + collectionSlug={collection.slug} + relations={relations} + collections={allCollections} + isLoading={relationsLoading} + onCreateRelation={onCreateRelation} + /> + )} </div> )} </div> @@ -692,6 +731,7 @@ export function ContentTypeEditor({ field={editingField} onSave={handleFieldSave} isSaving={fieldSaving} + collectionSlug={collection?.slug} /> <ConfirmDialog @@ -709,11 +749,36 @@ export function ContentTypeEditor({ error={null} onConfirm={() => { if (deleteFieldTarget) { - onDeleteField?.(deleteFieldTarget.slug); + onDeleteField?.( + deleteFieldTarget.slug, + targetRelation ? { deleteRelation: deleteFieldRelation } : undefined, + ); setDeleteFieldTarget(null); } }} - /> + > + {targetRelation && ( + <div className="mt-4 space-y-3"> + <Checkbox + checked={deleteFieldRelation} + onCheckedChange={(checked) => setDeleteFieldRelation(checked === true)} + label={t`Also delete the relationship this field uses`} + /> + {deleteFieldRelation && collection && ( + <div className="rounded-md border border-kumo-danger/50 bg-kumo-danger-tint p-3"> + <p className="text-sm font-medium">{t`This also removes:`}</p> + <RelationImpact + relations={[targetRelation]} + excludeField={{ + collectionSlug: collection.slug, + fieldSlug: deleteFieldTarget?.slug ?? "", + }} + /> + </div> + )} + </div> + )} + </ConfirmDialog> </div> ); } diff --git a/packages/admin/src/components/ContentTypeList.tsx b/packages/admin/src/components/ContentTypeList.tsx index 6845703eea..cd8544a729 100644 --- a/packages/admin/src/components/ContentTypeList.tsx +++ b/packages/admin/src/components/ContentTypeList.tsx @@ -26,13 +26,17 @@ import { Warning, Check, DotsSixVertical, + LinkSimple, } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; +import { fetchRelations } from "../lib/api"; import type { SchemaCollection, OrphanedTable } from "../lib/api"; import { cn } from "../lib/utils"; import { ConfirmDialog } from "./ConfirmDialog"; +import { RelationImpact } from "./RelationImpact.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; /** @@ -74,6 +78,17 @@ export function ContentTypeList({ const [deleteTarget, setDeleteTarget] = React.useState<SchemaCollection | null>(null); const hasOrphans = orphanedTables && orphanedTables.length > 0; + const { data: relations = [] } = useQuery({ + queryKey: ["relations"], + queryFn: () => fetchRelations(), + }); + const affectedRelations = deleteTarget + ? relations.filter( + (rel) => + rel.parentCollection === deleteTarget.slug || rel.childCollection === deleteTarget.slug, + ) + : []; + // Optimistic order: the drop lands immediately, the server order takes // over once the mutation invalidates the query. const [order, setOrder] = React.useState<string[] | null>(null); @@ -118,9 +133,14 @@ export function ContentTypeList({ {t`Define the structure of your content`} </p> </div> - <RouterLinkButton to="/content-types/new" icon={<Plus />}> - {t`New Content Type`} - </RouterLinkButton> + <div className="flex items-center gap-2"> + <RouterLinkButton to="/content-types/relations" variant="outline" icon={<LinkSimple />}> + {t`Relations`} + </RouterLinkButton> + <RouterLinkButton to="/content-types/new" icon={<Plus />}> + {t`New Content Type`} + </RouterLinkButton> + </div> </div> {/* Orphaned Tables Warning */} @@ -243,7 +263,16 @@ export function ContentTypeList({ setDeleteTarget(null); } }} - /> + > + {affectedRelations.length > 0 && ( + <div className="mt-4 rounded-md border border-kumo-danger/50 bg-kumo-danger-tint p-3"> + <p className="text-sm font-medium"> + {t`Every relationship this content type takes part in goes too:`} + </p> + <RelationImpact relations={affectedRelations} /> + </div> + )} + </ConfirmDialog> </div> ); } diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 60aa53fec3..1d8babb72b 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -1,5 +1,14 @@ -import { Button, Dialog, Input, InputArea, Select, Switch } from "@cloudflare/kumo"; -import { useLingui } from "@lingui/react/macro"; +import { + Button, + Dialog, + Input, + InputArea, + Link as KumoLink, + Select, + Switch, + Tooltip, +} from "@cloudflare/kumo"; +import { Trans, useLingui } from "@lingui/react/macro"; import { TextT, TextAlignLeft, @@ -19,10 +28,14 @@ import { Plus, Trash, X, + Info, } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; import * as React from "react"; +import { fetchCollections, fetchRelations } from "../lib/api"; import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api"; +import type { RelationSide, RelationWithUsage } from "../lib/api/relations.js"; import { cn } from "../lib/utils"; import { AllowedTypesEditor } from "./AllowedTypesEditor"; @@ -47,10 +60,23 @@ const INDEXABLE_FIELD_TYPES = new Set<FieldType>([ "boolean", "datetime", "select", - "reference", "slug", ]); +/** + * Which ends of `relation` a field on `collection` could still bind to. + * + * A self-referential relation offers both; any other relation offers the one + * end that matches. An end a field already picks from is not offered again. + */ +function freeSidesFor(relation: RelationWithUsage, collection: string): RelationSide[] { + const taken = new Set(relation.boundFields.map((f) => f.side)); + const sides: RelationSide[] = []; + if (relation.parentCollection === collection && !taken.has("parent")) sides.push("parent"); + if (relation.childCollection === collection && !taken.has("child")) sides.push("child"); + return sides; +} + function isSearchableFieldType(type: FieldType | null): type is FieldType { return type !== null && SEARCHABLE_FIELD_TYPES.has(type); } @@ -69,6 +95,9 @@ export interface FieldEditorProps { field?: SchemaField; onSave: (input: CreateFieldInput) => void; isSaving?: boolean; + /** The collection the field belongs to. Reference fields need it to work out + * which relations they can bind to, and from which end. */ + collectionSlug?: string; } interface FieldTypeConfig { @@ -104,7 +133,12 @@ interface FieldFormState { minItems: string; maxItems: string; allowedMimeTypes: string[]; + targetCollection: string; + allowMultiple: boolean; darkVariant: boolean; + /** Relation slug to bind to, or `""` for "create a new one". */ + relation: string; + relationSide: RelationSide; } function getInitialFormState(field?: SchemaField): FieldFormState { @@ -130,7 +164,18 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: (field.validation as Record<string, unknown>)?.minItems?.toString() ?? "", maxItems: (field.validation as Record<string, unknown>)?.maxItems?.toString() ?? "", allowedMimeTypes: field.validation?.allowedMimeTypes ?? [], + // A reference field created before relations existed named its target in + // `options.collection`. Showing it here is what lets the editor confirm + // it and turn the field into a picker. + targetCollection: + field.validation?.targetCollection ?? + (typeof field.options?.collection === "string" ? field.options.collection : ""), + // An unbound legacy field carries no `multiple`; the API and migration 077 + // both read a missing one as single, so the switch must not say otherwise. + allowMultiple: field.validation?.multiple ?? false, darkVariant: field.options?.darkVariant === true, + relation: field.validation?.relation ?? "", + relationSide: field.validation?.relationSide ?? "parent", }; } return { @@ -152,29 +197,86 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: "", maxItems: "", allowedMimeTypes: [], + targetCollection: "", + allowMultiple: false, darkVariant: false, + relation: "", + relationSide: "parent", }; } /** * Field editor dialog for creating/editing fields */ -export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: FieldEditorProps) { +export function FieldEditor({ + open, + onOpenChange, + field, + onSave, + isSaving, + collectionSlug, +}: FieldEditorProps) { const { t } = useLingui(); const [formState, setFormState] = React.useState(() => getInitialFormState(field)); + const [refError, setRefError] = React.useState(false); + + const { data: collections = [] } = useQuery({ + queryKey: ["collections"], + queryFn: fetchCollections, + }); + + const { data: allRelations = [] } = useQuery({ + queryKey: ["relations"], + queryFn: () => fetchRelations(), + // The dialog links out to the relation editor in a new tab, so a + // relationship created there has to show up on the way back. + refetchOnWindowFocus: "always", + }); // Reset state when dialog opens React.useEffect(() => { if (open) { setFormState(getInitialFormState(field)); + setRefError(false); } }, [open, field]); const { step, selectedType, slug, label, required, unique, searchable, indexed } = formState; const { minLength, maxLength, min, max, pattern, options } = formState; + const { targetCollection, allowMultiple, relation, relationSide } = formState; const setField = <K extends keyof FieldFormState>(key: K, value: FieldFormState[K]) => setFormState((prev) => ({ ...prev, [key]: value })); + // Only a reference field already bound to a relation has an immutable target; + // one that predates relations is still waiting for its first. + const isBoundReference = typeof field?.validation?.relation === "string"; + + // Relations this collection can still bind a field to, with the ends that + // are free. A relation whose every matching end already has a field is left + // out: two pickers over one link set have no defined merge. + const bindableRelations = React.useMemo( + () => + collectionSlug + ? allRelations + .map((rel) => ({ relation: rel, sides: freeSidesFor(rel, collectionSlug) })) + .filter((candidate) => candidate.sides.length > 0) + : [], + [allRelations, collectionSlug], + ); + + const selectedRelation = bindableRelations.find((c) => c.relation.slug === relation); + // The side is only a genuine choice when both ends of the relation are this + // collection and both are free — a self-referential relation such as related + // posts. Anywhere else the matching end decides it. + const sideIsAChoice = (selectedRelation?.sides.length ?? 0) > 1; + const derivedSide = selectedRelation?.sides[0] ?? "parent"; + const effectiveSide = sideIsAChoice ? relationSide : derivedSide; + const boundTarget = selectedRelation + ? effectiveSide === "parent" + ? selectedRelation.relation.childCollection + : selectedRelation.relation.parentCollection + : ""; + // Build field types inside the component so t`` works const FIELD_TYPES: FieldTypeConfig[] = [ { @@ -297,6 +399,11 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie const handleSave = () => { if (!selectedType || !slug || !label) return; + if (selectedType === "reference" && !relation && !targetCollection) { + setRefError(true); + return; + } + const validation: CreateFieldInput["validation"] = {}; // Build validation based on field type @@ -343,6 +450,18 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie validation.allowedMimeTypes = formState.allowedMimeTypes; } + if (selectedType === "reference") { + if (relation) { + // The relation owns the target and the limits; sending a target + // collection too would let the two disagree. + validation.relation = relation; + validation.relationSide = effectiveSide; + } else { + validation.targetCollection = targetCollection; + validation.multiple = allowMultiple; + } + } + // Only include searchable for text-based fields const isSearchableType = isSearchableFieldType(selectedType); const isIndexableType = isIndexableFieldType(selectedType); @@ -556,6 +675,128 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie /> )} + {selectedType === "reference" && ( + <div className="flex flex-col gap-4"> + <h4 className="font-medium text-sm">{t`Reference`}</h4> + + {isBoundReference ? ( + <> + <Select + label={t`Relationship`} + value={field?.validation?.relation ?? ""} + onValueChange={() => undefined} + items={{ + [field?.validation?.relation ?? ""]: field?.validation?.relation ?? "", + }} + disabled + /> + <Select + label={t`Referenced collection`} + value={targetCollection} + onValueChange={() => undefined} + items={collections.map((c) => ({ label: c.label, value: c.slug }))} + disabled + /> + <SideNote side={field?.validation?.relationSide ?? "parent"} /> + <p className="text-xs text-kumo-subtle"> + {t`The relationship and the referenced collection cannot be changed after creation. How many entries this field accepts is set on the relationship.`} + </p> + </> + ) : ( + <> + <Select + label={t`Relationship`} + value={relation} + onValueChange={(v) => { + setField("relation", v ?? ""); + setRefError(false); + }} + items={[ + { label: t`Quick create a relationship`, value: "" }, + ...bindableRelations.map(({ relation: rel }) => ({ + label: rel.slug, + value: rel.slug, + })), + ]} + /> + + {relation ? ( + <> + <Select + label={t`Referenced collection`} + value={boundTarget} + onValueChange={() => undefined} + items={collections.map((c) => ({ label: c.label, value: c.slug }))} + disabled + /> + {sideIsAChoice ? ( + <div className="flex items-end gap-1.5"> + <Select + label={t`This field picks`} + value={effectiveSide} + onValueChange={(v) => setField("relationSide", v ?? "parent")} + items={{ + parent: t`Entries this one links to`, + child: t`Entries that link to this one`, + }} + /> + <SideTooltip /> + </div> + ) : ( + <div className="flex items-center gap-1.5"> + <SideNote side={effectiveSide} /> + <SideTooltip /> + </div> + )} + <p className="text-xs text-kumo-subtle"> + {t`The relationship decides the referenced collection and how many entries this field accepts.`} + </p> + </> + ) : ( + <> + <Select + label={t`Referenced collection`} + value={targetCollection} + onValueChange={(v) => { + setField("targetCollection", v ?? ""); + setRefError(false); + }} + items={collections.map((c) => ({ label: c.label, value: c.slug }))} + placeholder={t`Select a collection`} + error={refError ? t`Referenced collection is required` : undefined} + /> + {field && ( + <p className="text-xs text-kumo-subtle"> + {t`Saving a collection here turns this field into an entry picker. Its stored entry IDs move to the relationship, and the field can no longer be searched or filtered on.`} + </p> + )} + <Switch + checked={allowMultiple} + onCheckedChange={(checked) => setField("allowMultiple", checked)} + label={<span className="text-sm">{t`Allow multiple references`}</span>} + /> + <p className="text-xs text-kumo-subtle"> + <Trans> + Quick create names the relationship after this field and this + collection, takes how many entries it holds from the switch above, and + puts no limit on how many entries link back the other way.{" "} + <KumoLink + href="/_emdash/admin/content-types/relations/new" + target="_blank" + > + Create the relationship yourself + </KumoLink>{" "} + to set its slug, the name each side goes by, and both limits, then pick + it above. + </Trans> + </p> + </> + )} + </> + )} + </div> + )} + {selectedType === "repeater" && ( <div className="space-y-4"> <div className="flex items-center justify-between"> @@ -717,3 +958,44 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie </Dialog.Root> ); } + +/** States which end of a relation a field picks from, for a side the user did + * not choose. */ +function SideNote({ side }: { side: RelationSide }) { + const { t } = useLingui(); + return ( + <p className="text-sm"> + {side === "parent" + ? t`This field picks entries this one links to.` + : t`This field lists entries that link to this one.`} + </p> + ); +} + +/** Explains what the side costs: only the linking end orders its selection, + * because a link's position is scoped to the entry that made it. */ +function SideTooltip() { + const { t } = useLingui(); + return ( + <Tooltip + content={ + <span className="block max-w-64 text-pretty"> + {t`Picking entries this one links to lets editors drag them into an order. The other direction lists whatever points at the entry and cannot be reordered.`} + </span> + } + delay={0} + closeDelay={0} + render={ + <Button + type="button" + variant="ghost" + shape="square" + size="xs" + icon={<Info aria-hidden="true" />} + className="text-kumo-subtle hover:text-kumo-default ms-1" + aria-label={t`What the direction changes`} + /> + } + /> + ); +} diff --git a/packages/admin/src/components/MenuEditor.tsx b/packages/admin/src/components/MenuEditor.tsx index 848ca807b4..eda8c30c29 100644 --- a/packages/admin/src/components/MenuEditor.tsx +++ b/packages/admin/src/components/MenuEditor.tsx @@ -450,7 +450,10 @@ export function MenuEditor() { <ContentPickerModal open={isContentPickerOpen} onOpenChange={setIsContentPickerOpen} - onSelect={handleAddContent} + onConfirm={(rows) => { + const item = rows[0]; + if (item) handleAddContent(item); + }} /> {i18n && i18n.locales.length > 1 && menu ? ( diff --git a/packages/admin/src/components/ReferencesSidebar.tsx b/packages/admin/src/components/ReferencesSidebar.tsx new file mode 100644 index 0000000000..4b3a17e466 --- /dev/null +++ b/packages/admin/src/components/ReferencesSidebar.tsx @@ -0,0 +1,241 @@ +/** + * References Sidebar for Content Editor + * + * Read-only "Referenced by" panel shown in the content editor sidebar for + * existing entries. For each relation whose child side is the entry's + * collection, it lists the parent entries that reference the entry being + * edited (the reverse direction of the parent-side reference field renderer). + * + * The panel always renders its heading so it remains a stable, sortable + * content-settings section. Entries without backlinks show an empty state. + */ + +import { Button, Loader, Text } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { ArrowSquareOut } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import * as React from "react"; + +import { fetchCollections } from "../lib/api"; +import { + type EntryRef, + type RelationDef, + fetchReferenceParents, + fetchRelations, +} from "../lib/api/relations.js"; +import { cn } from "../lib/utils.js"; +import { RouterLinkButton } from "./RouterLinkButton.js"; + +interface ReferencesSidebarProps { + collection: string; + entryId: string; + /** Applied to the root element. */ + className?: string; +} + +interface ParentsState { + items: EntryRef[]; + nextCursor?: string; + loading: boolean; +} + +const PAGE_SIZE = 50; + +export function ReferencesSidebar({ collection, entryId, className }: ReferencesSidebarProps) { + const { t } = useLingui(); + const relationsQuery = useQuery({ + queryKey: ["relations"], + queryFn: () => fetchRelations(), + // A 403 means the viewer genuinely lacks `schema:read`; retrying just adds + // latency before we hide the panel, which is the desired outcome. + retry: false, + }); + + // Group headings use the parent collection's plural label. The relation only + // carries `parentLabel` (the singular field label), so map slugs to their + // plural display name; the query is shared with the rest of the admin. + const collectionsQuery = useQuery({ + queryKey: ["collections"], + queryFn: fetchCollections, + }); + const pluralLabelBySlug = React.useMemo(() => { + const map = new Map<string, string>(); + for (const c of collectionsQuery.data ?? []) map.set(c.slug, c.label); + return map; + }, [collectionsQuery.data]); + + // Every relation whose child side is this collection: those are the ones + // something can point at this entry through. + const applicableRelations = React.useMemo( + () => (relationsQuery.data ?? []).filter((r) => r.childCollection === collection), + [relationsQuery.data, collection], + ); + + // Per-relation parents pagination, keyed by relation id. First pages are + // loaded on demand (effect below) once the relations list resolves; manual + // "Load more" advances the cursor for an individual relation. + const [parentsByRel, setParentsByRel] = React.useState<Record<string, ParentsState>>({}); + + // Dump stale state and load the first page for each applicable relation. + // Re-runs when the entry, collection, or applicable relation set changes. + React.useEffect(() => { + let cancelled = false; + // New entry/relation slice — start from a clean slate so cursor keys from + // a previously displayed entry never bleed through. + setParentsByRel({}); + void (async () => { + for (const rel of applicableRelations) { + try { + const res = await fetchReferenceParents(collection, entryId, rel.id, { + limit: PAGE_SIZE, + }); + if (cancelled) return; + setParentsByRel((prev) => ({ + ...prev, + [rel.id]: { items: res.parents, nextCursor: res.nextCursor, loading: false }, + })); + } catch { + if (cancelled) return; + // A single relation failing (not found, draft-read denied, …) + // shouldn't crash the panel — record it as empty so the heading + // still hides once every relation has resolved. + setParentsByRel((prev) => ({ + ...prev, + [rel.id]: { items: [], nextCursor: undefined, loading: false }, + })); + } + } + })(); + return () => { + cancelled = true; + }; + }, [applicableRelations, collection, entryId]); + + const loadMore = React.useCallback( + async (rel: RelationDef) => { + // Read current cursor synchronously from state to avoid a stale closure + // when multiple "Load more" clicks queue up. + let cursor: string | undefined; + setParentsByRel((prev) => { + const cur = prev[rel.id]; + if (!cur || !cur.nextCursor || cur.loading) return prev; + cursor = cur.nextCursor; + return { ...prev, [rel.id]: { ...cur, loading: true } }; + }); + if (!cursor) return; + try { + const res = await fetchReferenceParents(collection, entryId, rel.id, { + cursor, + limit: PAGE_SIZE, + }); + setParentsByRel((prev) => { + const cur = prev[rel.id]; + if (!cur) return prev; + const seen = new Set(cur.items.map((p) => p.id)); + const merged = [...cur.items, ...res.parents.filter((p) => !seen.has(p.id))]; + return { + ...prev, + [rel.id]: { items: merged, nextCursor: res.nextCursor, loading: false }, + }; + }); + } catch { + setParentsByRel((prev) => { + const cur = prev[rel.id]; + return cur ? { ...prev, [rel.id]: { ...cur, loading: false } } : prev; + }); + } + }, + [collection, entryId], + ); + + const isPopulated = (rel: RelationDef): boolean => (parentsByRel[rel.id]?.items.length ?? 0) > 0; + const populatedRels = applicableRelations.filter(isPopulated); + const isLoadingParents = applicableRelations.some((rel) => !parentsByRel[rel.id]); + + return ( + <div className={cn("space-y-4", className)}> + <Text bold as="h3"> + {t`Referenced by`} + </Text> + {relationsQuery.isLoading || isLoadingParents ? ( + <Loader size="sm" /> + ) : relationsQuery.error ? ( + <p className="text-sm text-kumo-subtle">{t`References unavailable.`}</p> + ) : populatedRels.length === 0 ? ( + <p className="text-sm text-kumo-subtle">{t`No references yet.`}</p> + ) : ( + <div className="space-y-4"> + {populatedRels.map((rel) => { + const state = parentsByRel[rel.id]; + // `state` is guarded by `isPopulated` above, but the TS narrowing + // across the .filter callback doesn't carry through. + if (!state) return null; + const heading = pluralLabelBySlug.get(rel.parentCollection) ?? rel.parentLabel; + return ( + <div key={rel.id} className="space-y-2"> + <h4 className="text-sm font-medium text-kumo-subtle">{heading}</h4> + <ul className="space-y-2"> + {state.items.map((parent) => { + const label = parent.title || parent.slug || parent.id; + return ( + <li + key={parent.id} + className="flex items-center gap-2 rounded-md border bg-kumo-base px-3 py-2" + > + <Link + to="/content/$collection/$id" + params={{ collection: parent.collection, id: parent.id }} + search={{ locale: parent.locale ?? undefined }} + className="group min-w-0 flex-1" + > + <div className="truncate text-sm font-medium group-hover:underline"> + {label} + </div> + {parent.slug && ( + <div className="flex items-center gap-2 text-xs text-kumo-subtle"> + <span className="truncate">{parent.slug}</span> + </div> + )} + </Link> + <RouterLinkButton + to="/content/$collection/$id" + params={{ collection: parent.collection, id: parent.id }} + search={{ locale: parent.locale ?? undefined }} + target="_blank" + variant="ghost" + shape="square" + size="sm" + icon={<ArrowSquareOut className="h-4 w-4" />} + aria-label={t`Open ${label} in a new tab`} + /> + </li> + ); + })} + </ul> + {state.nextCursor && ( + <div className="pt-1"> + <Button + variant="outline" + size="sm" + onClick={() => void loadMore(rel)} + disabled={state.loading} + > + {state.loading ? ( + <> + <Loader size="sm" /> {t`Loading...`} + </> + ) : ( + t`Load more` + )} + </Button> + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ); +} diff --git a/packages/admin/src/components/RelationCreateDialog.tsx b/packages/admin/src/components/RelationCreateDialog.tsx new file mode 100644 index 0000000000..a73824b557 --- /dev/null +++ b/packages/admin/src/components/RelationCreateDialog.tsx @@ -0,0 +1,115 @@ +/** + * Create a relation without leaving the content type you are editing. + * + * The full-page editor at `/content-types/relations/new` remains the way in + * from the relations list; this is the same form in a dialog, with the content + * type you came from prefilled as the linking end. + */ + +import { Button, Dialog } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { X } from "@phosphor-icons/react"; +import * as React from "react"; + +import type { SchemaCollection } from "../lib/api"; +import type { CreateRelationInput } from "../lib/api/relations.js"; +import { DialogError, getMutationError } from "./DialogError"; +import { RelationFormFields, useRelationForm } from "./RelationForm.js"; + +export interface RelationCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + collections: SchemaCollection[]; + /** Prefills the linking end. */ + defaultParentCollection?: string; + /** Resolves once the relation exists; rejects with the server's message. */ + onCreate: (input: CreateRelationInput) => Promise<unknown>; +} + +export function RelationCreateDialog({ + open, + onOpenChange, + collections, + defaultParentCollection, + onCreate, +}: RelationCreateDialogProps) { + const { t } = useLingui(); + const form = useRelationForm({ isNew: true, defaultParentCollection }); + const [isSaving, setIsSaving] = React.useState(false); + const [error, setError] = React.useState<string | null>(null); + const { reset } = form; + + React.useEffect(() => { + if (open) { + reset(); + setError(null); + } + }, [open, reset]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!form.canSave || isSaving) return; + setIsSaving(true); + setError(null); + try { + await onCreate(form.toInput() as CreateRelationInput); + onOpenChange(false); + } catch (err) { + setError(getMutationError(err)); + } finally { + setIsSaving(false); + } + }; + + return ( + <Dialog.Root open={open} onOpenChange={onOpenChange}> + <Dialog + size="lg" + className="flex max-h-[min(88dvh,46rem)] flex-col overflow-hidden p-0" + style={{ width: "min(94vw, 40rem)" }} + > + <div className="flex shrink-0 items-start justify-between gap-4 border-b border-kumo-line px-6 py-5"> + <div className="min-w-0"> + <Dialog.Title className="text-lg font-semibold leading-tight tracking-tight"> + {t`New Relation`} + </Dialog.Title> + <Dialog.Description className="mt-1 text-sm leading-5 text-kumo-subtle"> + {t`A relation defines how two content types link. Reference fields on either side then pick entries through it.`} + </Dialog.Description> + </div> + <Dialog.Close + render={(props) => ( + <Button {...props} variant="ghost" shape="square" aria-label={t`Close`}> + <X className="h-4 w-4" /> + </Button> + )} + /> + </div> + + <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col"> + <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-5"> + <RelationFormFields form={form} collections={collections} isNew layout="stack" /> + </div> + + <div className="shrink-0 border-t border-kumo-line px-6 py-4"> + <DialogError message={error} className="mb-3" /> + + <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSaving} + > + {t`Cancel`} + </Button> + <Button type="submit" disabled={!form.canSave || isSaving}> + {isSaving ? t`Creating...` : t`Create Relation`} + </Button> + </div> + </div> + </form> + </Dialog> + </Dialog.Root> + ); +} diff --git a/packages/admin/src/components/RelationDangerZone.tsx b/packages/admin/src/components/RelationDangerZone.tsx new file mode 100644 index 0000000000..22b9a1378b --- /dev/null +++ b/packages/admin/src/components/RelationDangerZone.tsx @@ -0,0 +1,59 @@ +/** + * Deleting a relationship, from the relationship's own page. + * + * It does not refuse when fields are bound to it: it names them and removes + * them, the same cascade the field-delete checkbox and a content-type delete + * run. A relationship a field still points at is not a state anything else in + * the admin knows how to show. + */ + +import { Button } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import * as React from "react"; + +import type { RelationWithUsage } from "../lib/api/relations.js"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { RelationImpact } from "./RelationImpact.js"; + +export interface RelationDangerZoneProps { + relation: RelationWithUsage; + onDelete: () => void; + isDeleting?: boolean; + error?: unknown; +} + +export function RelationDangerZone({ + relation, + onDelete, + isDeleting, + error, +}: RelationDangerZoneProps) { + const { t } = useLingui(); + const [confirming, setConfirming] = React.useState(false); + + return ( + <div className="rounded-lg border border-kumo-danger/50 bg-kumo-base p-4"> + <h2 className="font-semibold">{t`Delete relationship`}</h2> + <p className="mt-1 text-sm text-kumo-subtle"> + {t`Removes this relationship, every link stored under it, and the reference fields that use it.`} + </p> + <Button variant="destructive" className="mt-3" onClick={() => setConfirming(true)}> + {t`Delete relationship`} + </Button> + + <ConfirmDialog + open={confirming} + onClose={() => setConfirming(false)} + title={t`Delete "${relation.slug}"?`} + description={t`This removes:`} + confirmLabel={t`Delete`} + pendingLabel={t`Deleting...`} + isPending={!!isDeleting} + error={error} + onConfirm={onDelete} + > + <RelationImpact relations={[relation]} nameRelations={false} /> + </ConfirmDialog> + </div> + ); +} diff --git a/packages/admin/src/components/RelationEditor.tsx b/packages/admin/src/components/RelationEditor.tsx new file mode 100644 index 0000000000..687964aecc --- /dev/null +++ b/packages/admin/src/components/RelationEditor.tsx @@ -0,0 +1,100 @@ +/** + * Relation editor page — create a link definition, or rename the roles of one. + * + * The form itself lives in `RelationForm`, which the create dialog on a + * content type shares. + */ + +import { useLingui } from "@lingui/react/macro"; +import * as React from "react"; + +import type { SchemaCollection } from "../lib/api"; +import type { + CreateRelationInput, + RelationWithUsage, + UpdateRelationInput, +} from "../lib/api/relations.js"; +import { ArrowPrev } from "./ArrowIcons.js"; +import { EditorHeader } from "./EditorHeader"; +import { RelationFormFields, useRelationForm } from "./RelationForm.js"; +import { RouterLinkButton } from "./RouterLinkButton.js"; +import { SaveButton } from "./SaveButton"; + +export interface RelationEditorProps { + relation?: RelationWithUsage; + collections: SchemaCollection[]; + isNew?: boolean; + isSaving?: boolean; + error?: string; + onSave: (input: CreateRelationInput | UpdateRelationInput) => void; + /** Rendered under the roles, for the delete action. */ + footer?: React.ReactNode; +} + +export function RelationEditor({ + relation, + collections, + isNew, + isSaving, + error, + onSave, + footer, +}: RelationEditorProps) { + const { t } = useLingui(); + const form = useRelationForm({ relation, isNew }); + + const labelFor = (collectionSlug: string) => + collections.find((c) => c.slug === collectionSlug)?.label ?? collectionSlug; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!form.canSave) return; + onSave(form.toInput()); + }; + + return ( + <div className="space-y-6"> + <EditorHeader + leading={ + <RouterLinkButton + to="/content-types/relations" + aria-label={t`Back to Relations`} + variant="ghost" + shape="square" + icon={<ArrowPrev />} + /> + } + actions={ + <SaveButton + type="submit" + form="relation-editor-form" + isDirty={form.isDirty} + isSaving={!!isSaving} + disabled={!form.canSave || !form.isDirty} + /> + } + > + <h1 className="truncate text-2xl font-semibold"> + {isNew ? t`New Relation` : relation?.slug} + </h1> + {!isNew && relation && ( + <p className="text-kumo-subtle text-sm"> + {t`${labelFor(relation.parentCollection)} link to ${labelFor(relation.childCollection)}`} + </p> + )} + </EditorHeader> + + {error && ( + <div className="rounded-md border border-kumo-danger/50 bg-kumo-danger-tint p-4 text-sm"> + {error} + </div> + )} + + <form id="relation-editor-form" onSubmit={handleSubmit}> + <RelationFormFields form={form} collections={collections} isNew={isNew} layout="grid" /> + </form> + + {footer} + </div> + ); +} diff --git a/packages/admin/src/components/RelationForm.tsx b/packages/admin/src/components/RelationForm.tsx new file mode 100644 index 0000000000..c6efb004d7 --- /dev/null +++ b/packages/admin/src/components/RelationForm.tsx @@ -0,0 +1,377 @@ +/** + * The relation form — the fields shared by the full-page relation editor and + * the create dialog on a content type. + * + * The two collections and the slug are fixed once the relation exists: a + * reference field stores the slug, and every link is keyed by the relation's + * id, so changing either end would leave the stored links pointing at content + * of the wrong type. + * + * Roles are single-valued, like a collection's label. A multi-locale admin + * shows them untranslated. + */ + +import { Input, Select } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import * as React from "react"; + +import type { SchemaCollection } from "../lib/api"; +import type { + CreateRelationInput, + RelationWithUsage, + UpdateRelationInput, +} from "../lib/api/relations.js"; + +const SLUG_INVALID_CHARS_PATTERN = /[^a-z0-9]+/g; +const SLUG_LEADING_TRAILING_PATTERN = /^_|_$/g; + +/** How a limit is expressed in the form. `limit` reveals a number input. */ +type LimitMode = "one" | "many" | "limit"; + +function limitMode(value: number | null): LimitMode { + if (value === null) return "many"; + return value === 1 ? "one" : "limit"; +} + +function limitValue(mode: LimitMode, custom: string): number | null { + if (mode === "many") return null; + if (mode === "one") return 1; + const parsed = parseInt(custom, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function customLimit(value: number | null | undefined): string { + return value && value !== 1 ? String(value) : ""; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(SLUG_INVALID_CHARS_PATTERN, "_") + .replace(SLUG_LEADING_TRAILING_PATTERN, ""); +} + +export interface RelationFormState { + slug: string; + slugEdited: boolean; + parentCollection: string; + childCollection: string; + parentLabel: string; + parentLabelSingular: string; + childLabel: string; + childLabelSingular: string; + childrenMode: LimitMode; + childrenLimit: string; + parentsMode: LimitMode; + parentsLimit: string; +} + +export interface UseRelationFormOptions { + relation?: RelationWithUsage; + isNew?: boolean; + /** Prefills the linking end when a relation is created from a content type. */ + defaultParentCollection?: string; +} + +export interface RelationForm { + state: RelationFormState; + set: <K extends keyof RelationFormState>(key: K, value: RelationFormState[K]) => void; + /** Sets one end, and renames an unedited new relation after both ends. */ + setEnd: (end: "parent" | "child", value: string) => void; + /** Back to the values the form opened with. */ + reset: () => void; + canSave: boolean; + isDirty: boolean; + toInput: () => CreateRelationInput | UpdateRelationInput; +} + +function initialState({ + relation, + defaultParentCollection, +}: UseRelationFormOptions): RelationFormState { + return { + slug: relation?.slug ?? "", + slugEdited: false, + parentCollection: relation?.parentCollection ?? defaultParentCollection ?? "", + childCollection: relation?.childCollection ?? "", + parentLabel: relation?.parentLabel ?? "", + parentLabelSingular: relation?.parentLabelSingular ?? "", + childLabel: relation?.childLabel ?? "", + childLabelSingular: relation?.childLabelSingular ?? "", + childrenMode: limitMode(relation?.maxChildrenPerParent ?? null), + childrenLimit: customLimit(relation?.maxChildrenPerParent), + parentsMode: limitMode(relation?.maxParentsPerChild ?? null), + parentsLimit: customLimit(relation?.maxParentsPerChild), + }; +} + +export function useRelationForm(options: UseRelationFormOptions): RelationForm { + const { relation, isNew } = options; + const [state, setState] = React.useState<RelationFormState>(() => initialState(options)); + + const set = React.useCallback( + <K extends keyof RelationFormState>(key: K, value: RelationFormState[K]) => { + setState((current) => ({ ...current, [key]: value })); + }, + [], + ); + + // A relation's slug defaults to the two collections it joins, which is what + // the field-created relations are named after too. + const setEnd = React.useCallback( + (end: "parent" | "child", value: string) => { + setState((current) => { + const next = { + ...current, + ...(end === "parent" ? { parentCollection: value } : { childCollection: value }), + }; + if (isNew && !next.slugEdited && next.parentCollection && next.childCollection) { + next.slug = slugify(`${next.parentCollection}_${next.childCollection}`); + } + return next; + }); + }, + [isNew], + ); + + const optionsRef = React.useRef(options); + optionsRef.current = options; + const reset = React.useCallback(() => setState(initialState(optionsRef.current)), []); + + const canSave = isNew + ? Boolean( + state.slug && + state.parentCollection && + state.childCollection && + state.parentLabel && + state.childLabel, + ) + : Boolean(state.parentLabel && state.childLabel); + + const isDirty = + isNew || + !relation || + state.parentLabel !== relation.parentLabel || + state.parentLabelSingular !== (relation.parentLabelSingular ?? "") || + state.childLabel !== relation.childLabel || + state.childLabelSingular !== (relation.childLabelSingular ?? "") || + limitValue(state.childrenMode, state.childrenLimit) !== relation.maxChildrenPerParent || + limitValue(state.parentsMode, state.parentsLimit) !== relation.maxParentsPerChild; + + const toInput = React.useCallback((): CreateRelationInput | UpdateRelationInput => { + const roles = { + parentLabel: state.parentLabel, + parentLabelSingular: state.parentLabelSingular || null, + childLabel: state.childLabel, + childLabelSingular: state.childLabelSingular || null, + maxChildrenPerParent: limitValue(state.childrenMode, state.childrenLimit), + maxParentsPerChild: limitValue(state.parentsMode, state.parentsLimit), + }; + return isNew + ? { + slug: state.slug, + parentCollection: state.parentCollection, + childCollection: state.childCollection, + ...roles, + } + : roles; + }, [state, isNew]); + + return { state, set, setEnd, reset, canSave, isDirty, toInput }; +} + +export interface RelationFormFieldsProps { + form: RelationForm; + collections: SchemaCollection[]; + isNew?: boolean; + /** `grid` is the two-column page editor; `stack` fits inside a dialog. */ + layout?: "grid" | "stack"; +} + +export function RelationFormFields({ + form, + collections, + isNew, + layout = "grid", +}: RelationFormFieldsProps) { + const { t } = useLingui(); + const { state, set, setEnd } = form; + + const collectionItems = collections.map((c) => ({ label: c.label, value: c.slug })); + const labelFor = (collectionSlug: string) => + collections.find((c) => c.slug === collectionSlug)?.label ?? collectionSlug; + const singularFor = (collectionSlug: string) => + collections.find((c) => c.slug === collectionSlug)?.labelSingular ?? labelFor(collectionSlug); + + // Named here rather than inside the label template: a `t` call nested in + // another `t` template is not something the macro can extract. + const linkingSideName = state.parentLabelSingular || t`entry on the linking side`; + const linkedSideName = state.childLabelSingular || t`entry on the linked side`; + + const isGrid = layout === "grid"; + const Heading = isGrid ? "h2" : "h3"; + const headingClass = isGrid + ? "font-semibold" + : "text-xs font-medium uppercase tracking-wider text-kumo-subtle"; + const panelClass = isGrid ? "rounded-lg border bg-kumo-base p-4 space-y-4" : "space-y-4"; + + return ( + <div className={isGrid ? "grid grid-cols-1 lg:grid-cols-2 gap-6" : "space-y-6"}> + <div className={panelClass}> + <Heading className={headingClass}>{t`Content types`}</Heading> + + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> + <Select + label={t`Links from`} + className="w-full" + value={state.parentCollection} + onValueChange={(v) => setEnd("parent", v ?? "")} + items={collectionItems} + placeholder={t`Select a content type`} + disabled={!isNew} + /> + <Select + label={t`Links to`} + className="w-full" + value={state.childCollection} + onValueChange={(v) => setEnd("child", v ?? "")} + items={collectionItems} + placeholder={t`Select a content type`} + disabled={!isNew} + /> + </div> + <div> + <Input + label={t`Slug`} + value={state.slug} + onChange={(e) => { + set("slugEdited", true); + set("slug", e.target.value); + }} + placeholder="posts_authors" + disabled={!isNew} + /> + <p className="text-xs text-kumo-subtle mt-2"> + {isNew + ? t`Lowercase letters, numbers and underscores. Reference fields store this.` + : t`The content types and slug cannot be changed after a relation is created.`} + </p> + </div> + </div> + + <div className={panelClass}> + <Heading className={headingClass}>{t`Roles`}</Heading> + <p className="text-sm text-kumo-subtle"> + {t`What each side is called. These name the picker and the "Referenced by" panel.`} + </p> + + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> + <Input + label={t`Linking side (plural)`} + value={state.parentLabel} + onChange={(e) => set("parentLabel", e.target.value)} + placeholder={state.parentCollection ? labelFor(state.parentCollection) : t`Posts`} + /> + <Input + label={t`Linking side (singular)`} + value={state.parentLabelSingular} + onChange={(e) => set("parentLabelSingular", e.target.value)} + placeholder={state.parentCollection ? singularFor(state.parentCollection) : t`Post`} + /> + <Input + label={t`Linked side (plural)`} + value={state.childLabel} + onChange={(e) => set("childLabel", e.target.value)} + placeholder={state.childCollection ? labelFor(state.childCollection) : t`Authors`} + /> + <Input + label={t`Linked side (singular)`} + value={state.childLabelSingular} + onChange={(e) => set("childLabelSingular", e.target.value)} + placeholder={state.childCollection ? singularFor(state.childCollection) : t`Author`} + /> + </div> + </div> + + <div className={isGrid ? `${panelClass} lg:col-span-2` : panelClass}> + <Heading className={headingClass}>{t`How many`}</Heading> + <p className="text-sm text-kumo-subtle"> + {t`Both reference fields bound to this relation share these limits, so the two sides cannot disagree about the same links.`} + </p> + + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:grid-rows-[auto_auto] sm:gap-y-2"> + <LimitControl + label={t`Each ${linkingSideName} links to`} + oneLabel={t`One`} + manyLabel={t`Any number`} + mode={state.childrenMode} + onModeChange={(mode) => set("childrenMode", mode)} + limit={state.childrenLimit} + onLimitChange={(value) => set("childrenLimit", value)} + /> + <LimitControl + label={t`Each ${linkedSideName} is linked from`} + oneLabel={t`One`} + manyLabel={t`Any number`} + mode={state.parentsMode} + onModeChange={(mode) => set("parentsMode", mode)} + limit={state.parentsLimit} + onLimitChange={(value) => set("parentsLimit", value)} + /> + </div> + </div> + </div> + ); +} + +interface LimitControlProps { + label: string; + oneLabel: string; + manyLabel: string; + mode: LimitMode; + onModeChange: (mode: LimitMode) => void; + limit: string; + onLimitChange: (limit: string) => void; +} + +function LimitControl({ + label, + oneLabel, + manyLabel, + mode, + onModeChange, + limit, + onLimitChange, +}: LimitControlProps) { + const { t } = useLingui(); + const labelId = React.useId(); + + // The label sits in the parent grid's own row so that a label wrapping onto + // a second line still leaves the two selects on one line. + return ( + <div className="grid gap-2 sm:row-span-2 sm:grid-rows-subgrid"> + <span id={labelId} className="text-base font-medium text-kumo-default"> + {label} + </span> + <div className="space-y-2"> + <Select + aria-labelledby={labelId} + className="w-full" + value={mode} + onValueChange={(v) => onModeChange(v ?? "many")} + items={{ one: oneLabel, many: manyLabel, limit: t`At most…` }} + /> + {mode === "limit" && ( + <Input + type="number" + min={2} + label={t`Maximum`} + value={limit} + onChange={(e) => onLimitChange(e.target.value)} + placeholder="5" + /> + )} + </div> + </div> + ); +} diff --git a/packages/admin/src/components/RelationImpact.tsx b/packages/admin/src/components/RelationImpact.tsx new file mode 100644 index 0000000000..e433a64302 --- /dev/null +++ b/packages/admin/src/components/RelationImpact.tsx @@ -0,0 +1,69 @@ +/** + * What deleting a relationship takes with it. + * + * Every path that deletes a relationship — deleting a reference field with the + * checkbox left on, deleting the relationship itself, deleting a content type + * one end sits on — removes the same three things, so all three dialogs spell + * them out the same way. + * + * Naming the side of each field matters most for the linked-end one: deleting + * an inverse field on Authors otherwise silently removes the primary field on + * Posts, which is not what the person clicking Delete has in mind. + */ + +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; + +import type { RelationWithUsage } from "../lib/api/relations.js"; + +export interface RelationImpactProps { + relations: RelationWithUsage[]; + /** Field already being deleted, left out of the list so it is not named twice. */ + excludeField?: { collectionSlug: string; fieldSlug: string }; + /** Drop to `false` where the dialog is already about one named relationship. */ + nameRelations?: boolean; +} + +export function RelationImpact({ + relations, + excludeField, + nameRelations = true, +}: RelationImpactProps) { + const { t } = useLingui(); + + if (relations.length === 0) return null; + + return ( + <ul className="mt-3 space-y-3 text-sm"> + {relations.map((relation) => { + const fields = relation.boundFields.filter( + (bound) => + !excludeField || + bound.collectionSlug !== excludeField.collectionSlug || + bound.fieldSlug !== excludeField.fieldSlug, + ); + return ( + <li key={relation.id}> + {nameRelations && ( + <code className="bg-kumo-tint px-1.5 py-0.5 rounded me-2">{relation.slug}</code> + )} + <span className="text-kumo-subtle"> + {plural(relation.linkCount, { one: "# link", other: "# links" })} + </span> + {fields.length > 0 && ( + <ul className="mt-1 space-y-1 text-kumo-subtle"> + {fields.map((bound) => ( + <li key={`${bound.collectionSlug}.${bound.fieldSlug}`}> + {bound.side === "parent" + ? t`the ${bound.fieldSlug} field on ${bound.collectionSlug}, which picks entries it links to` + : t`the ${bound.fieldSlug} field on ${bound.collectionSlug}, which lists entries that link to it`} + </li> + ))} + </ul> + )} + </li> + ); + })} + </ul> + ); +} diff --git a/packages/admin/src/components/RelationList.tsx b/packages/admin/src/components/RelationList.tsx new file mode 100644 index 0000000000..01873c606c --- /dev/null +++ b/packages/admin/src/components/RelationList.tsx @@ -0,0 +1,163 @@ +/** + * Relation list view — every link definition on the site. + * + * A relation is schema, like a collection: it names two collections and the + * roles each plays. Reference fields bind to one, from one end. A relation + * with no bound field is still listed: unbinding the last field leaves one + * behind, and this page is the only way to reach it again. + */ + +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { ArrowRight, Pencil, Plus } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; + +import type { RelationWithUsage } from "../lib/api/relations.js"; +import { ArrowPrev } from "./ArrowIcons.js"; +import { RouterLinkButton } from "./RouterLinkButton.js"; + +export interface RelationListProps { + relations: RelationWithUsage[]; + isLoading?: boolean; + error?: string; +} + +export function RelationList({ relations, isLoading, error }: RelationListProps) { + const { t } = useLingui(); + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between gap-4"> + <div className="flex items-center gap-4 min-w-0"> + <RouterLinkButton + to="/content-types" + aria-label={t`Back to Content Types`} + variant="ghost" + shape="square" + icon={<ArrowPrev />} + /> + <div className="min-w-0"> + <h1 className="text-2xl font-semibold leading-tight">{t`Relations`}</h1> + <p className="mt-1 text-sm leading-5 text-pretty text-kumo-subtle"> + {t`Define how content types link to each other`} + </p> + </div> + </div> + <RouterLinkButton to="/content-types/relations/new" icon={<Plus />}> + {t`New Relation`} + </RouterLinkButton> + </div> + + {error && ( + <div className="rounded-md border border-kumo-danger/50 bg-kumo-danger-tint p-4 text-sm"> + {error} + </div> + )} + + <div className="rounded-md border bg-kumo-base overflow-x-auto"> + <table className="w-full"> + <thead> + <tr className="border-b bg-kumo-tint/50"> + <th scope="col" className="px-4 py-3 text-start text-sm font-medium"> + {t`Slug`} + </th> + <th scope="col" className="px-4 py-3 text-start text-sm font-medium"> + {t`Connects`} + </th> + <th scope="col" className="px-4 py-3 text-start text-sm font-medium"> + {t`Fields`} + </th> + <th scope="col" className="px-4 py-3 text-start text-sm font-medium"> + {t`Links`} + </th> + <th scope="col" className="px-4 py-3 text-end text-sm font-medium"> + {t`Actions`} + </th> + </tr> + </thead> + <tbody className="divide-y divide-kumo-line"> + {isLoading ? ( + <tr> + <td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle"> + {t`Loading relations...`} + </td> + </tr> + ) : relations.length === 0 ? ( + <tr> + <td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle"> + {t`No relations yet.`}{" "} + <Link to="/content-types/relations/new" className="text-kumo-link underline"> + {t`Create your first relation`} + </Link> + </td> + </tr> + ) : ( + relations.map((relation) => <RelationRow key={relation.id} relation={relation} />) + )} + </tbody> + </table> + </div> + </div> + ); +} + +function RelationRow({ relation }: { relation: RelationWithUsage }) { + const { t } = useLingui(); + + return ( + <tr className="hover:bg-kumo-tint/25"> + <td className="px-4 py-3"> + <Link + to="/content-types/relations/$slug" + params={{ slug: relation.slug }} + className="font-medium hover:text-kumo-link" + > + <code className="text-sm">{relation.slug}</code> + </Link> + </td> + <td className="px-4 py-3"> + <div className="flex items-center gap-2 text-sm"> + <span>{relation.parentCollection}</span> + <ArrowRight + className="h-3.5 w-3.5 text-kumo-subtle rtl:-scale-x-100" + aria-hidden="true" + /> + <span>{relation.childCollection}</span> + </div> + </td> + <td className="px-4 py-3"> + {relation.boundFields.length === 0 ? ( + <span className="text-sm text-kumo-subtle">{t`No fields`}</span> + ) : ( + <ul className="space-y-1"> + {relation.boundFields.map((bound) => ( + <li key={`${bound.collectionSlug}.${bound.fieldSlug}`} className="text-sm"> + <code className="bg-kumo-tint px-1.5 py-0.5 rounded"> + {bound.collectionSlug}.{bound.fieldSlug} + </code> + <span className="ms-2 text-xs text-kumo-subtle"> + {bound.side === "parent" + ? t`picks ${relation.childLabel}` + : t`picks ${relation.parentLabel}`} + </span> + </li> + ))} + </ul> + )} + </td> + <td className="px-4 py-3 text-sm text-kumo-subtle"> + {plural(relation.linkCount, { one: "# link", other: "# links" })} + </td> + <td className="px-4 py-3 text-end"> + <RouterLinkButton + to="/content-types/relations/$slug" + params={{ slug: relation.slug }} + aria-label={t`Edit ${relation.slug}`} + variant="ghost" + shape="square" + icon={<Pencil />} + /> + </td> + </tr> + ); +} diff --git a/packages/admin/src/components/RelationsPanel.tsx b/packages/admin/src/components/RelationsPanel.tsx new file mode 100644 index 0000000000..d70e3318af --- /dev/null +++ b/packages/admin/src/components/RelationsPanel.tsx @@ -0,0 +1,167 @@ +/** + * Relations a content type takes part in, listed under its fields. + * + * A relation is schema that lives outside the collection — the relations list + * owns the full view — but the collection editor is where someone asks "what + * does this link to?", so the ones it is an end of are answered here. + */ + +import { Button } from "@cloudflare/kumo"; +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { ArrowRight, LinkSimple, Pencil, Plus } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; +import * as React from "react"; + +import type { SchemaCollection } from "../lib/api"; +import type { CreateRelationInput, RelationWithUsage } from "../lib/api/relations.js"; +import { RelationCreateDialog } from "./RelationCreateDialog"; +import { RouterLinkButton } from "./RouterLinkButton.js"; + +export interface RelationsPanelProps { + /** The content type being edited — the end these relations are read from. */ + collectionSlug: string; + /** Every relation on the site; the panel keeps the ones this end is in. */ + relations: RelationWithUsage[]; + collections: SchemaCollection[]; + isLoading?: boolean; + onCreateRelation?: (input: CreateRelationInput) => Promise<unknown>; +} + +export function RelationsPanel({ + collectionSlug, + relations, + collections, + isLoading, + onCreateRelation, +}: RelationsPanelProps) { + const { t } = useLingui(); + const [createOpen, setCreateOpen] = React.useState(false); + + const participating = relations.filter( + (relation) => + relation.parentCollection === collectionSlug || relation.childCollection === collectionSlug, + ); + + return ( + <div className="rounded-lg border bg-kumo-base"> + <div className="flex items-center justify-between gap-4 p-4 border-b"> + <div> + <h2 className="font-semibold">{t`Relations`}</h2> + <p className="text-sm text-kumo-subtle"> + {isLoading + ? t`Loading relations...` + : plural(participating.length, { + one: "# relation this content type takes part in", + other: "# relations this content type takes part in", + })} + </p> + </div> + {onCreateRelation && ( + <Button icon={<Plus />} onClick={() => setCreateOpen(true)}> + {t`New Relation`} + </Button> + )} + </div> + + {participating.length === 0 ? ( + <div className="p-8 text-center text-kumo-subtle"> + <LinkSimple className="mx-auto h-12 w-12 mb-4 opacity-50" /> + <p className="font-medium">{t`No relations yet`}</p> + <p className="text-sm"> + {t`Create one to let entries here link to entries in another content type.`} + </p> + {onCreateRelation && ( + <Button className="mt-4" icon={<Plus />} onClick={() => setCreateOpen(true)}> + {t`Create First Relation`} + </Button> + )} + </div> + ) : ( + <div className="divide-y divide-kumo-line"> + {participating.map((relation) => ( + <RelationRow key={relation.id} relation={relation} collectionSlug={collectionSlug} /> + ))} + </div> + )} + + {onCreateRelation && ( + <RelationCreateDialog + open={createOpen} + onOpenChange={setCreateOpen} + collections={collections} + defaultParentCollection={collectionSlug} + onCreate={onCreateRelation} + /> + )} + </div> + ); +} + +function RelationRow({ + relation, + collectionSlug, +}: { + relation: RelationWithUsage; + collectionSlug: string; +}) { + const { t } = useLingui(); + + const isParent = relation.parentCollection === collectionSlug; + const isChild = relation.childCollection === collectionSlug; + const ownFields = relation.boundFields.filter((bound) => bound.collectionSlug === collectionSlug); + + return ( + <div className="flex items-center gap-4 px-4 py-3 hover:bg-kumo-tint/25"> + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <Link + to="/content-types/relations/$slug" + params={{ slug: relation.slug }} + className="font-medium hover:text-kumo-link" + > + <code className="text-sm">{relation.slug}</code> + </Link> + <span className="flex items-center gap-1.5 text-sm text-kumo-subtle"> + {relation.parentCollection} + <ArrowRight className="h-3 w-3 rtl:-scale-x-100" aria-hidden="true" /> + {relation.childCollection} + </span> + </div> + + <p className="mt-1 text-sm text-kumo-subtle"> + {isParent && <span>{t`Links to ${relation.childLabel}`}</span>} + {isParent && isChild && <span aria-hidden="true"> · </span>} + {isChild && <span>{t`Linked from ${relation.parentLabel}`}</span>} + </p> + + {ownFields.length === 0 ? ( + <p className="mt-1 text-xs text-kumo-subtle">{t`No field on this content type uses it yet`}</p> + ) : ( + <ul className="mt-1 flex flex-wrap gap-1.5"> + {ownFields.map((bound) => ( + <li key={bound.fieldSlug}> + <code className="rounded bg-kumo-tint px-1.5 py-0.5 text-xs"> + {bound.fieldSlug} + </code> + </li> + ))} + </ul> + )} + </div> + + <span className="whitespace-nowrap text-sm text-kumo-subtle"> + {plural(relation.linkCount, { one: "# link", other: "# links" })} + </span> + + <RouterLinkButton + to="/content-types/relations/$slug" + params={{ slug: relation.slug }} + aria-label={t`Edit ${relation.slug}`} + variant="ghost" + shape="square" + icon={<Pencil />} + /> + </div> + ); +} diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 92fe705943..5c0f7cceeb 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -13,6 +13,7 @@ import { throwResponseError, type FindManyResult, } from "./client.js"; +import type { EntryRef } from "./relations.js"; /** * Derive draft status from a content item's revision pointers @@ -59,6 +60,13 @@ export interface ContentItem { liveRevisionId: string | null; draftRevisionId: string | null; seo?: ContentSeo; + /** + * First page of reference-field edges, keyed by field slug. + * Only present when the server opts into hydration (the editor GET route). + * Each field's entries are stored solely in `_emdash_content_references`; + * the admin sends the desired id lists back in the `references` save key. + */ + references?: Record<string, { children: EntryRef[]; nextCursor?: string }>; /** * Opaque optimistic-concurrency token returned by the content API on * reads. Echo it back on writes so the server can reject a save that is @@ -75,6 +83,8 @@ export interface CreateContentInput { bylines?: BylineCreditInput[]; locale?: string; translationOf?: string; + /** Reference-field edges to write atomically, keyed by field slug. */ + references?: Record<string, string[]>; } export interface TranslationSummary { @@ -120,6 +130,8 @@ export interface UpdateContentInput { /** Skip revision creation (used by autosave) */ skipRevision?: boolean; seo?: ContentSeoInput; + /** Reference-field edges to replace atomically, keyed by field slug. */ + references?: Record<string, string[]>; /** * Optimistic-concurrency token from the last read. When present, the * server rejects the write with 409 if the entry changed since that read, @@ -277,6 +289,7 @@ export async function createContent( bylines: input.bylines, locale: input.locale, translationOf: input.translationOf, + references: input.references, }), }); const data = await parseApiResponse<{ item: ContentItem; _rev?: string }>( diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index 067bdca811..58130c3e5f 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -418,6 +418,25 @@ export { // Current user export { type CurrentUser, useCurrentUser } from "./current-user.js"; +// Relations (reference fields) +export { + type BoundField, + type CreateRelationInput, + type EntryRef, + type ReferencePageOptions, + type RelationDef, + type RelationSide, + type RelationWithUsage, + type UpdateRelationInput, + createRelation, + deleteRelation, + fetchReferenceChildren, + fetchReferenceParents, + fetchRelation, + fetchRelations, + updateRelation, +} from "./relations.js"; + // Entry edit locks export { type EntryLockHolder, diff --git a/packages/admin/src/lib/api/relations.ts b/packages/admin/src/lib/api/relations.ts new file mode 100644 index 0000000000..767a740edc --- /dev/null +++ b/packages/admin/src/lib/api/relations.ts @@ -0,0 +1,195 @@ +/** + * Relations API (reference field definitions and links). + */ + +import { i18n } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; + +import { API_BASE, apiFetch, parseApiResponse } from "./client.js"; + +/** Which end of a relation a reference field picks from. */ +export type RelationSide = "parent" | "child"; + +export interface RelationDef { + id: string; + slug: string; + parentCollection: string; + childCollection: string; + parentLabel: string; + parentLabelSingular: string | null; + childLabel: string; + childLabelSingular: string | null; + /** How many children one parent may hold. `null` means unlimited. */ + maxChildrenPerParent: number | null; + /** How many parents one child may hold. `null` means unlimited. */ + maxParentsPerChild: number | null; +} + +/** A reference field that views a relation, and which end it views it from. */ +export interface BoundField { + collectionSlug: string; + fieldSlug: string; + side: RelationSide; +} + +/** + * A relation plus what deleting it would take with it. Every delete dialog + * enumerates these before the user confirms. + */ +export interface RelationWithUsage extends RelationDef { + boundFields: BoundField[]; + linkCount: number; +} + +export interface CreateRelationInput { + slug: string; + parentCollection: string; + childCollection: string; + parentLabel: string; + parentLabelSingular?: string | null; + childLabel: string; + childLabelSingular?: string | null; + maxChildrenPerParent?: number | null; + maxParentsPerChild?: number | null; +} + +/** The two collections are immutable once a relation exists; only labels and + * limits can be updated. */ +export interface UpdateRelationInput { + parentLabel?: string; + parentLabelSingular?: string | null; + childLabel?: string; + childLabelSingular?: string | null; + maxChildrenPerParent?: number | null; + maxParentsPerChild?: number | null; +} + +export interface EntryRef { + id: string; + slug: string | null; + collection: string; + /** Display label from the entry's title/name field; null when neither is set. */ + title: string | null; + locale: string | null; + /** The translation group `id` resolved from — locale-stable entry identity. */ + translationGroup: string | null; + sortOrder?: number; +} + +export interface ReferencePageOptions { + cursor?: string; + limit?: number; +} + +/** + * Fetch relation definitions, optionally only those `collection` takes part in. + */ +export async function fetchRelations( + opts: { collection?: string } = {}, +): Promise<RelationWithUsage[]> { + const qs = opts.collection ? `?collection=${encodeURIComponent(opts.collection)}` : ""; + const response = await apiFetch(`${API_BASE}/relations${qs}`); + const data = await parseApiResponse<{ relations: RelationWithUsage[] }>( + response, + "Failed to fetch relations", + ); + return data.relations; +} + +/** + * Fetch one relation by id. + */ +export async function fetchRelation(id: string): Promise<RelationWithUsage> { + const response = await apiFetch(`${API_BASE}/relations/${encodeURIComponent(id)}`); + const data = await parseApiResponse<{ relation: RelationWithUsage }>( + response, + "Failed to fetch relation", + ); + return data.relation; +} + +export async function createRelation(input: CreateRelationInput): Promise<RelationDef> { + const response = await apiFetch(`${API_BASE}/relations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const data = await parseApiResponse<{ relation: RelationDef }>( + response, + "Failed to create relation", + ); + return data.relation; +} + +export async function updateRelation(id: string, input: UpdateRelationInput): Promise<RelationDef> { + const response = await apiFetch(`${API_BASE}/relations/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const data = await parseApiResponse<{ relation: RelationDef }>( + response, + "Failed to update relation", + ); + return data.relation; +} + +/** + * Delete a relation, its links, and every reference field bound to it. + */ +export async function deleteRelation(id: string): Promise<string[]> { + const response = await apiFetch(`${API_BASE}/relations/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + const data = await parseApiResponse<{ deleted: true; deletedFields: string[] }>( + response, + i18n._(msg`Failed to delete relation`), + ); + return data.deletedFields; +} + +function buildPageQuery(opts: ReferencePageOptions = {}): string { + const params = new URLSearchParams(); + if (opts.cursor) params.set("cursor", opts.cursor); + if (opts.limit) params.set("limit", String(opts.limit)); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + +/** + * Fetch the children of an entry for a given relation (parent side). + */ +export async function fetchReferenceChildren( + collection: string, + id: string, + relation: string, + opts: ReferencePageOptions = {}, +): Promise<{ children: EntryRef[]; nextCursor?: string }> { + const qs = buildPageQuery(opts); + const response = await apiFetch( + `${API_BASE}/content/${collection}/${id}/references/${relation}/children${qs}`, + ); + return parseApiResponse<{ children: EntryRef[]; nextCursor?: string }>( + response, + "Failed to fetch reference children", + ); +} + +/** + * Fetch the parents of an entry for a given relation (child side). + */ +export async function fetchReferenceParents( + collection: string, + id: string, + relation: string, + opts: ReferencePageOptions = {}, +): Promise<{ parents: EntryRef[]; nextCursor?: string }> { + const qs = buildPageQuery(opts); + const response = await apiFetch( + `${API_BASE}/content/${collection}/${id}/references/${relation}/parents${qs}`, + ); + return parseApiResponse<{ parents: EntryRef[]; nextCursor?: string }>( + response, + "Failed to fetch reference parents", + ); +} diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index 800778b849..deaeac555f 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -77,6 +77,10 @@ export interface SchemaField { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; + targetCollection?: string; + multiple?: boolean; + relation?: string; + relationSide?: "parent" | "child"; }; widget?: string; options?: Record<string, unknown>; @@ -140,6 +144,10 @@ export interface CreateFieldInput { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; + targetCollection?: string; + multiple?: boolean; + relation?: string; + relationSide?: "parent" | "child"; } | null; widget?: string; options?: Record<string, unknown>; @@ -160,6 +168,9 @@ export interface UpdateFieldInput { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; + targetCollection?: string; + multiple?: boolean; + relation?: string; } | null; widget?: string; options?: Record<string, unknown>; @@ -296,9 +307,14 @@ export async function updateField( /** * Delete a field */ -export async function deleteField(collectionSlug: string, fieldSlug: string): Promise<void> { +export async function deleteField( + collectionSlug: string, + fieldSlug: string, + options: { deleteRelation?: boolean } = {}, +): Promise<void> { + const qs = options.deleteRelation ? "?deleteRelation=true" : ""; const response = await apiFetch( - `${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}`, + `${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}${qs}`, { method: "DELETE" }, ); if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete field`)); diff --git a/packages/admin/src/lib/content-settings-layout.ts b/packages/admin/src/lib/content-settings-layout.ts index 58be005b29..e382afaef8 100644 --- a/packages/admin/src/lib/content-settings-layout.ts +++ b/packages/admin/src/lib/content-settings-layout.ts @@ -6,6 +6,7 @@ export const DEFAULT_CONTENT_SETTINGS_SECTION_ORDER = [ "bylines", "translations", "taxonomies", + "references", "seo", "outline", "revisions", diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 07bee287c1..5bf620ffee 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -54,6 +54,9 @@ import { PluginSettings } from "./components/PluginSettings"; import { Redirects } from "./components/Redirects"; import { RegistryBrowse } from "./components/RegistryBrowse"; import { RegistryPluginDetail } from "./components/RegistryPluginDetail"; +import { RelationDangerZone } from "./components/RelationDangerZone"; +import { RelationEditor } from "./components/RelationEditor"; +import { RelationList } from "./components/RelationList"; import { SandboxedPluginPage } from "./components/SandboxedPluginPage"; import { SectionEditor } from "./components/SectionEditor"; import { Sections } from "./components/Sections"; @@ -89,7 +92,13 @@ import { fetchMediaList, updateMedia, uploadMedia, + createRelation, + deleteRelation, fetchCollections, + fetchRelations, + updateRelation, + type CreateRelationInput, + type UpdateRelationInput, fetchCollection, createCollection, updateCollection, @@ -163,6 +172,7 @@ interface ContentUpdateChanges { bylines?: BylineCreditInput[]; skipRevision?: boolean; seo?: ContentSeoInput; + references?: Record<string, string[]>; /** Optimistic-concurrency token from the latest response. */ _rev?: string; } @@ -177,7 +187,7 @@ interface ContentUpdateMutationInput { interface AutosaveMutationInput { targetId: string; targetLocale?: string; - changes: Pick<ContentUpdateChanges, "data" | "slug" | "bylines" | "_rev">; + changes: Pick<ContentUpdateChanges, "data" | "slug" | "bylines" | "references" | "_rev">; } function isSaveConflict(error: unknown): boolean { @@ -723,6 +733,7 @@ function ContentNewPage() { data: Record<string, unknown>; slug?: string; bylines?: BylineCreditInput[]; + references?: Record<string, string[]>; }) => createContent(collection, { ...data, locale: pickerLocale }), onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); @@ -777,7 +788,12 @@ function ContentNewPage() { // ContentSettingsPanel, so fresh arrows on every mutation-state flip // would defeat the memo. mutate/mutateAsync are referentially stable. const handleSave = React.useCallback( - (payload: { data: Record<string, unknown>; slug?: string; bylines?: BylineCreditInput[] }) => { + (payload: { + data: Record<string, unknown>; + slug?: string; + bylines?: BylineCreditInput[]; + references?: Record<string, string[]>; + }) => { createMutation.mutate(payload); }, [createMutation.mutate], @@ -1354,7 +1370,12 @@ function ContentEditPage() { // (twice per autosave cycle) would defeat the memo. mutate/mutateAsync // are referentially stable. const handleSave = React.useCallback( - (payload: { data: Record<string, unknown>; slug?: string; bylines?: BylineCreditInput[] }) => { + (payload: { + data: Record<string, unknown>; + slug?: string; + bylines?: BylineCreditInput[]; + references?: Record<string, string[]>; + }) => { void serializeEditorSave(() => updateMutation.mutateAsync({ targetId: id, @@ -1368,7 +1389,12 @@ function ContentEditPage() { ); const handleAutosave = React.useCallback( - (payload: { data: Record<string, unknown>; slug?: string; bylines?: BylineCreditInput[] }) => { + (payload: { + data: Record<string, unknown>; + slug?: string; + bylines?: BylineCreditInput[]; + references?: Record<string, string[]>; + }) => { void serializeEditorSave(() => autosaveMutation.mutateAsync({ targetId: id, @@ -2417,6 +2443,9 @@ function ContentTypesListPage() { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + // Every relationship the collection was an end of went with it, along + // with the reference fields on the other collections. + void queryClient.invalidateQueries({ queryKey: ["relations"] }); }, }); @@ -2489,6 +2518,138 @@ function ContentTypesNewPage() { ); } +// Relations: link definitions between two content types. Under /content-types +// because a relation is schema, like a collection. +const relationsListRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: "/content-types/relations", + component: RelationsListPage, +}); + +function RelationsListPage() { + const { data: relations, isLoading, error } = useRelationsQuery(); + + return ( + <RelationList + relations={relations ?? []} + isLoading={isLoading} + error={error ? error.message : undefined} + /> + ); +} + +function useRelationsQuery() { + return useQuery({ + queryKey: ["relations"], + queryFn: () => fetchRelations(), + }); +} + +const relationsNewRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: "/content-types/relations/new", + component: RelationsNewPage, +}); + +function RelationsNewPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data: collections = [] } = useQuery({ + queryKey: ["schema", "collections"], + queryFn: fetchCollections, + }); + + const createMutation = useMutation({ + mutationFn: (input: CreateRelationInput) => createRelation(input), + onSuccess: (relation) => { + void queryClient.invalidateQueries({ queryKey: ["relations"] }); + void navigate({ + to: "/content-types/relations/$slug", + params: { slug: relation.slug }, + }); + }, + }); + + return ( + <RelationEditor + isNew + collections={collections} + isSaving={createMutation.isPending} + error={createMutation.error ? createMutation.error.message : undefined} + onSave={(input) => createMutation.mutate(input as CreateRelationInput)} + /> + ); +} + +const relationsEditRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: "/content-types/relations/$slug", + component: RelationsEditPage, +}); + +function RelationsEditPage() { + const { slug } = useParams({ from: "/_admin/content-types/relations/$slug" }); + const queryClient = useQueryClient(); + const { t } = useLingui(); + + // The list carries the same `RelationWithUsage` shape the editor needs, and + // the relation endpoints address a relation by id while the URL names it by + // slug — so one list read answers both. + const { data: relations, isLoading, error } = useRelationsQuery(); + const relation = relations?.find((r) => r.slug === slug); + + const updateMutation = useMutation({ + mutationFn: (input: UpdateRelationInput) => { + if (!relation) throw new Error("Relation not loaded"); + return updateRelation(relation.id, input); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["relations"] }); + }, + }); + + const { data: collections = [] } = useQuery({ + queryKey: ["schema", "collections"], + queryFn: fetchCollections, + }); + + const navigate = useNavigate(); + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteRelation(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["relations"] }); + // The fields bound to the relation went with it, on both collections. + void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); + void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + void navigate({ to: "/content-types/relations" }); + }, + }); + + if (error) return <ErrorScreen error={error.message} />; + if (isLoading) return <LoadingScreen />; + if (!relation) return <ErrorScreen error={t`Relation not found`} />; + + return ( + <RelationEditor + key={relation.id} + relation={relation} + collections={collections} + isSaving={updateMutation.isPending} + error={updateMutation.error ? updateMutation.error.message : undefined} + onSave={(input) => updateMutation.mutate(input)} + footer={ + <RelationDangerZone + relation={relation} + onDelete={() => deleteMutation.mutate(relation.id)} + isDeleting={deleteMutation.isPending} + error={deleteMutation.error} + /> + } + /> + ); +} + const contentTypesEditRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/content-types/$slug", @@ -2556,6 +2717,9 @@ function ContentTypesEditPage() { queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + // A reference field creates a relationship server-side, so the list the + // next dialog computes its free sides from is stale without this. + void queryClient.invalidateQueries({ queryKey: ["relations"] }); }, }); @@ -2567,16 +2731,36 @@ function ContentTypesEditPage() { queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + // Binding a field creates a relationship, and a relabel renames a role + // on one. + void queryClient.invalidateQueries({ queryKey: ["relations"] }); }, }); const deleteFieldMutation = useMutation({ - mutationFn: (fieldSlug: string) => deleteField(slug, fieldSlug), + mutationFn: ({ + fieldSlug, + alsoDeleteRelation, + }: { + fieldSlug: string; + alsoDeleteRelation?: boolean; + }) => deleteField(slug, fieldSlug, { deleteRelation: alsoDeleteRelation }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["schema", "collections", slug], }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + // Deleting the relationship takes the field on its other end with it, + // so every collection's field list and the relations list can change. + void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); + void queryClient.invalidateQueries({ queryKey: ["relations"] }); + }, + }); + + const createRelationMutation = useMutation({ + mutationFn: (input: CreateRelationInput) => createRelation(input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["relations"] }); }, }); @@ -2605,8 +2789,11 @@ function ContentTypesEditPage() { onSave={(input) => updateMutation.mutate(input)} onAddField={(input) => addFieldMutation.mutateAsync(input)} onUpdateField={(fieldSlug, input) => updateFieldMutation.mutateAsync({ fieldSlug, input })} - onDeleteField={(fieldSlug) => deleteFieldMutation.mutate(fieldSlug)} + onDeleteField={(fieldSlug, options) => + deleteFieldMutation.mutate({ fieldSlug, alsoDeleteRelation: options?.deleteRelation }) + } onReorderFields={(fieldSlugs) => reorderFieldsMutation.mutate(fieldSlugs)} + onCreateRelation={(input) => createRelationMutation.mutateAsync(input)} /> ); } @@ -2663,6 +2850,9 @@ const adminRoutes = adminLayoutRoute.addChildren([ contentEditRoute, contentTypesListRoute, contentTypesNewRoute, + relationsListRoute, + relationsNewRoute, + relationsEditRoute, contentTypesEditRoute, mediaRoute, commentsRoute, diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index 94d6b317d9..a28bb8cca7 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -8,7 +8,7 @@ import { type FieldDescriptor, type ContentEditorProps, } from "../../src/components/ContentEditor"; -import { fetchBylines } from "../../src/lib/api"; +import { fetchBylines, fetchReferenceChildren } from "../../src/lib/api"; import type { BylineSummary, ContentItem } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; @@ -111,6 +111,7 @@ vi.mock("../../src/lib/api", async () => { ...actual, getPreviewUrl: vi.fn().mockResolvedValue({ url: "https://example.com/preview" }), fetchBylines: vi.fn(async () => ({ items: [], nextCursor: null })), + fetchReferenceChildren: vi.fn(async () => ({ children: [] })), }; }); @@ -2362,6 +2363,134 @@ describe("ContentEditor", () => { }); }); + describe("reference field paging", () => { + const RELATION = "rel-group-1"; + + const referenceFields: Record<string, FieldDescriptor> = { + title: { kind: "string", label: "Title" }, + related: { + kind: "reference", + label: "Related", + validation: { relation: RELATION, targetCollection: "posts", multiple: true }, + }, + }; + + /** + * An entry whose hydrated first page leaves a second page to auto-load. + * Keyed by field slug, as the server hydrates it; the paging request + * addresses the relation the field names. + */ + function itemWithPendingPage(): ContentItem { + return makeItem({ + data: { title: "Hello" }, + references: { + related: { + children: [ + { id: "c-1", slug: "one", title: "One", locale: "en", translationGroup: "g-1" }, + ], + nextCursor: "cursor-1", + }, + }, + }); + } + + async function renderWithFailedPage() { + vi.mocked(fetchReferenceChildren).mockRejectedValue(new Error("network")); + const screen = await renderEditor({ + isNew: false, + item: itemWithPendingPage(), + fields: referenceFields, + }); + await expect.element(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + return screen; + } + + it("offers a retry instead of spinning forever when a page fails", async () => { + const screen = await renderWithFailedPage(); + + await expect.element(screen.getByText("Couldn't load all references.")).toBeInTheDocument(); + expect(screen.getByText("Loading references...").query()).toBeNull(); + }); + + it("retries the same cursor and recovers the field", async () => { + const screen = await renderWithFailedPage(); + const before = vi.mocked(fetchReferenceChildren).mock.calls.length; + + vi.mocked(fetchReferenceChildren).mockResolvedValue({ + children: [ + { + id: "c-2", + slug: "second-entry", + title: "Two", + locale: "en", + translationGroup: "g-2", + } as never, + ], + }); + + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + + await expect.element(screen.getByText("Two")).toBeInTheDocument(); + const calls = vi.mocked(fetchReferenceChildren).mock.calls; + expect(calls.length).toBeGreaterThan(before); + // The failed page must be re-requested, not skipped past, and addressed + // by the relation the field names. + expect(calls[before]?.[2]).toBe(RELATION); + expect(calls[before]?.[3]).toEqual({ cursor: "cursor-1" }); + expect(screen.getByText("Couldn't load all references.").query()).toBeNull(); + }); + + it("re-enables editing once the retried page lands", async () => { + const screen = await renderWithFailedPage(); + await expect.element(screen.getByRole("button", { name: "Add reference" })).toBeDisabled(); + + vi.mocked(fetchReferenceChildren).mockResolvedValue({ children: [] }); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + + await expect.element(screen.getByRole("button", { name: "Add reference" })).toBeEnabled(); + }); + }); + + describe("reference field that predates relations", () => { + // No relation means the field still owns a column holding one entry id, so + // it keeps the text input it had before reference pickers existed. + const legacyFields: Record<string, FieldDescriptor> = { + title: { kind: "string", label: "Title" }, + author: { kind: "reference", label: "Author", options: { collection: "authors" } }, + }; + + it("edits its stored entry id in a text input", async () => { + const onSave = vi.fn(); + const screen = await renderEditor({ + isNew: false, + item: makeItem({ data: { title: "Hello", author: "author-entry-id" } }), + fields: legacyFields, + onSave, + }); + + const input = screen.getByLabelText("Author"); + await expect.element(input).toHaveValue("author-entry-id"); + + await userEvent.fill(input, "another-entry-id"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalled(); + expect(onSave.mock.calls[0]?.[0]?.data).toMatchObject({ author: "another-entry-id" }); + }); + + it("points at the schema editor instead of claiming it is misconfigured", async () => { + const screen = await renderEditor({ + isNew: false, + item: makeItem({ data: { title: "Hello", author: "author-entry-id" } }), + fields: legacyFields, + }); + + await expect + .element(screen.getByText(/Set a target collection under Content Types/)) + .toBeInTheDocument(); + }); + }); + describe("edit lock read-only mode", () => { it("does not accept edits while another editor holds the entry", async () => { const screen = await renderEditor({ diff --git a/packages/admin/tests/components/ContentPickerModal.test.tsx b/packages/admin/tests/components/ContentPickerModal.test.tsx new file mode 100644 index 0000000000..b2b39139c3 --- /dev/null +++ b/packages/admin/tests/components/ContentPickerModal.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import type { ContentItem, FindManyResult } from "../../src/lib/api"; +import { render } from "../utils/render.tsx"; + +const mockFetchContentList = vi.fn<() => Promise<FindManyResult<ContentItem>>>(); + +vi.mock("../../src/lib/api", async () => { + const actual = await vi.importActual("../../src/lib/api"); + return { + ...actual, + fetchCollections: vi.fn(async () => []), + fetchContentList: (...args: unknown[]) => mockFetchContentList(...(args as [])), + }; +}); + +const { ContentPickerModal } = await import("../../src/components/ContentPickerModal"); + +function makeItem(overrides: Partial<ContentItem> = {}): ContentItem { + return { + id: "post-en", + type: "posts", + slug: "jane-doe", + status: "published", + locale: "en", + translationGroup: "grp-1", + data: { title: "Jane Doe" }, + authorId: null, + primaryBylineId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + publishedAt: "2026-01-01T00:00:00Z", + scheduledAt: null, + liveRevisionId: null, + draftRevisionId: null, + ...overrides, + }; +} + +describe("ContentPickerModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("disables an entry already linked through another locale variant", async () => { + // The editor is at `fr` and the reference resolved to the `fr` row, but the + // picker's result page only carries the `en` variant of that same entry + // (search matched the English title; the rest is behind the cursor). + mockFetchContentList.mockResolvedValue({ + items: [makeItem()], + nextCursor: "cursor-1", + }); + + const screen = await render( + <ContentPickerModal + open + onOpenChange={() => {}} + collection="posts" + multiple + locale="fr" + selectedIds={new Set(["grp-1"])} + onConfirm={() => {}} + />, + ); + + await expect.element(screen.getByText("Jane Doe")).toBeInTheDocument(); + await expect.element(screen.getByRole("checkbox", { name: "Jane Doe" })).toBeDisabled(); + await expect.element(screen.getByRole("checkbox", { name: "Jane Doe" })).toBeChecked(); + }); + + it("matches by row id when no locale is given (menu picker)", async () => { + mockFetchContentList.mockResolvedValue({ items: [makeItem()] }); + + const screen = await render( + <ContentPickerModal + open + onOpenChange={() => {}} + collection="posts" + multiple + selectedIds={new Set(["post-en"])} + onConfirm={() => {}} + />, + ); + + await expect.element(screen.getByRole("checkbox", { name: "Jane Doe" })).toBeDisabled(); + }); +}); diff --git a/packages/admin/tests/components/ContentSettingsPanel.test.tsx b/packages/admin/tests/components/ContentSettingsPanel.test.tsx index 7d0d51c551..6ff3e61957 100644 --- a/packages/admin/tests/components/ContentSettingsPanel.test.tsx +++ b/packages/admin/tests/components/ContentSettingsPanel.test.tsx @@ -49,6 +49,10 @@ vi.mock("../../src/components/SeoPanel", () => ({ SeoPanel: () => <div data-testid="seo-panel">SEO fields</div>, })); +vi.mock("../../src/components/ReferencesSidebar", () => ({ + ReferencesSidebar: () => <h3>Referenced by</h3>, +})); + vi.mock("@tanstack/react-router", async () => { const actual = await vi.importActual("@tanstack/react-router"); return { @@ -209,7 +213,7 @@ describe("ContentSettingsPanel", () => { vi.restoreAllMocks(); }); - it("renders all eight sections when every capability is enabled", async () => { + it("renders all nine sections when every capability is enabled", async () => { const screen = await render(<ContentSettingsPanel {...makePanelProps()} />); await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument(); @@ -217,10 +221,23 @@ describe("ContentSettingsPanel", () => { await expect.element(screen.getByRole("heading", { name: "Bylines" })).toBeInTheDocument(); await expect.element(screen.getByRole("heading", { name: "Translations" })).toBeInTheDocument(); await expect.element(screen.getByTestId("taxonomy-sidebar")).toBeInTheDocument(); + await expect + .element(screen.getByRole("heading", { name: "Referenced by" })) + .toBeInTheDocument(); await expect.element(screen.getByRole("heading", { name: "SEO" })).toBeInTheDocument(); await expect.element(screen.getByTestId("doc-outline")).toBeInTheDocument(); await expect.element(screen.getByTestId("revision-history")).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Move to Trash" })).toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Drag to reorder Referenced by" })) + .toBeInTheDocument(); + + const referencesSection = screen + .getByRole("heading", { name: "Referenced by" }) + .element() + .closest("section"); + const seoSection = screen.getByRole("heading", { name: "SEO" }).element().closest("section"); + expect(referencesSection?.nextElementSibling).toBe(seoSection); }); it("moves byline ordering guidance into help beside the heading", async () => { diff --git a/packages/admin/tests/components/ContentTypeEditor.test.tsx b/packages/admin/tests/components/ContentTypeEditor.test.tsx index 74a07fafd0..774492aad2 100644 --- a/packages/admin/tests/components/ContentTypeEditor.test.tsx +++ b/packages/admin/tests/components/ContentTypeEditor.test.tsx @@ -5,9 +5,20 @@ import { ContentTypeEditor, type ContentTypeEditorProps, } from "../../src/components/ContentTypeEditor"; -import type { SchemaCollectionWithFields, SchemaField } from "../../src/lib/api"; +import { fetchCollections, fetchRelations } from "../../src/lib/api"; +import type { SchemaCollection, SchemaCollectionWithFields, SchemaField } from "../../src/lib/api"; +import type { RelationWithUsage } from "../../src/lib/api/relations.js"; import { render } from "../utils/render"; +vi.mock("../../src/lib/api", async () => { + const actual = await vi.importActual<typeof import("../../src/lib/api")>("../../src/lib/api"); + return { + ...actual, + fetchRelations: vi.fn(async () => []), + fetchCollections: vi.fn(async () => []), + }; +}); + // Regexes hoisted to module scope to avoid recompilation per call const EDIT_TITLE_RE = /Edit Title field/i; const EDIT_BODY_RE = /Edit Body field/i; @@ -314,7 +325,7 @@ describe("ContentTypeEditor", () => { // Direct DOM click to bypass Base UI inert overlay screen.getByRole("button", { name: "Delete" }).element().click(); - expect(onDeleteField).toHaveBeenCalledWith("title"); + expect(onDeleteField).toHaveBeenCalledWith("title", undefined); }); it("does not call onDeleteField when delete dialog is cancelled", async () => { @@ -339,6 +350,109 @@ describe("ContentTypeEditor", () => { expect(onDeleteField).not.toHaveBeenCalled(); }); + // ---- Deleting a reference field offers to delete its relationship ---- + + describe("deleting a reference field", () => { + const referenceField = makeField({ + id: "field-ref", + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "posts_authors", relationSide: "parent" }, + }); + + const boundRelation: RelationWithUsage = { + id: "rel-1", + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + maxParentsPerChild: null, + boundFields: [ + { collectionSlug: "posts", fieldSlug: "author", side: "parent" }, + { collectionSlug: "authors", fieldSlug: "posts", side: "child" }, + ], + linkCount: 7, + }; + + async function openDeleteDialog(onDeleteField = vi.fn()) { + vi.mocked(fetchRelations).mockResolvedValue([boundRelation]); + const collection = makeCollection({ fields: [referenceField] }); + const screen = await render( + <ContentTypeEditor {...defaultProps({ onDeleteField })} collection={collection} />, + ); + await screen.getByRole("button", { name: /Delete Author field/i }).click(); + await expect.element(screen.getByText("Delete Field?")).toBeInTheDocument(); + return { screen, onDeleteField }; + } + + it("names the field on the other content type and the links that go with it", async () => { + const { screen } = await openDeleteDialog(); + + await expect + .element( + screen.getByText(/the posts field on authors, which lists entries that link to it/), + ) + .toBeInTheDocument(); + // Scoped to the dialog: the relations panel behind it counts the same + // links. + await expect.element(screen.getByRole("dialog").getByText("7 links")).toBeInTheDocument(); + }); + + // The field being deleted is already named in the dialog title; repeating + // it in the list of what else goes reads as a second field. + it("leaves the field being deleted out of the list", async () => { + const { screen } = await openDeleteDialog(); + + expect( + screen.getByText(/the author field on posts, which picks entries it links to/).query(), + ).toBeNull(); + }); + + it("deletes the relationship by default", async () => { + const { screen, onDeleteField } = await openDeleteDialog(); + + screen.getByRole("button", { name: "Delete" }).element().click(); + + expect(onDeleteField).toHaveBeenCalledWith("author", { deleteRelation: true }); + }); + + // Unchecking leaves a relationship with no bound fields, which the + // relations page still lists so it stays deletable. + it("keeps the relationship when the checkbox is cleared", async () => { + const { screen, onDeleteField } = await openDeleteDialog(); + + screen + .getByRole("checkbox", { name: "Also delete the relationship this field uses" }) + .element() + .click(); + screen.getByRole("button", { name: "Delete" }).element().click(); + + expect(onDeleteField).toHaveBeenCalledWith("author", { deleteRelation: false }); + }); + + it("offers nothing extra for a field that uses no relationship", async () => { + vi.mocked(fetchRelations).mockResolvedValue([boundRelation]); + const collection = makeCollection({ fields: [makeField()] }); + const screen = await render( + <ContentTypeEditor {...defaultProps()} collection={collection} />, + ); + + await screen.getByRole("button", { name: DELETE_FIELD_BUTTON_PATTERN }).click(); + await expect.element(screen.getByText("Delete Field?")).toBeInTheDocument(); + + expect( + screen + .getByRole("checkbox", { name: "Also delete the relationship this field uses" }) + .query(), + ).toBeNull(); + }); + }); + // ---- Code-source collections show disabled inputs and info banner ---- it("shows info banner and disables inputs for code-source collections", async () => { @@ -606,4 +720,157 @@ describe("ContentTypeEditor", () => { // Should show "6 system + 2 custom fields" await expect.element(screen.getByText(SYSTEM_FIELDS_REGEX)).toBeInTheDocument(); }); + + // ---- Relations panel ---- + + describe("relations panel", () => { + function makeRelation(overrides: Partial<RelationWithUsage> = {}): RelationWithUsage { + return { + id: "rel-1", + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + maxParentsPerChild: null, + boundFields: [{ collectionSlug: "posts", fieldSlug: "author", side: "parent" }], + linkCount: 3, + ...overrides, + }; + } + + const collections = [ + { slug: "posts", label: "Posts", labelSingular: "Post" }, + { slug: "authors", label: "Authors", labelSingular: "Author" }, + ] as SchemaCollection[]; + + async function renderPanel( + relations: RelationWithUsage[], + props: Partial<ContentTypeEditorProps> = {}, + ) { + vi.mocked(fetchRelations).mockResolvedValue(relations); + vi.mocked(fetchCollections).mockResolvedValue(collections); + return render(<ContentTypeEditor {...defaultProps(props)} collection={makeCollection()} />); + } + + /** Kumo's Select is a combobox button over a listbox; the dialog's inert + * overlay blocks Playwright's actionability checks, so drive it through + * the DOM as the other dialog tests do. */ + async function choose( + screen: Awaited<ReturnType<typeof renderPanel>>, + label: string, + option: string, + ) { + const trigger = screen.getByRole("combobox", { name: label, exact: true }); + await expect.element(trigger).toBeInTheDocument(); + trigger.element().click(); + await vi.waitFor(() => { + screen.getByRole("option", { name: option, exact: true }).element().click(); + }); + } + + it("lists only the relations this content type is an end of", async () => { + const screen = await renderPanel([ + makeRelation(), + makeRelation({ + id: "rel-2", + slug: "pages_media", + parentCollection: "pages", + childCollection: "media", + boundFields: [], + }), + ]); + + await expect.element(screen.getByText("posts_authors")).toBeInTheDocument(); + await expect.element(screen.getByText("pages_media")).not.toBeInTheDocument(); + }); + + it("names the side this content type plays", async () => { + const screen = await renderPanel([ + makeRelation(), + makeRelation({ + id: "rel-2", + slug: "tags_posts", + parentCollection: "tags", + childCollection: "posts", + parentLabel: "Tags", + childLabel: "Posts", + boundFields: [], + }), + ]); + + await expect.element(screen.getByText("Links to Authors")).toBeInTheDocument(); + await expect.element(screen.getByText("Linked from Tags")).toBeInTheDocument(); + }); + + it("says when no field on this content type uses a relation", async () => { + const screen = await renderPanel([makeRelation({ boundFields: [] })]); + + await expect + .element(screen.getByText("No field on this content type uses it yet")) + .toBeInTheDocument(); + }); + + it("creates a relation with this content type as the linking end", async () => { + const onCreateRelation = vi.fn(async () => ({})); + const screen = await renderPanel([], { onCreateRelation }); + + await screen.getByRole("button", { name: "Create First Relation" }).click(); + + // Prefilled from the content type being edited, and the slug follows + // both ends once the other is picked. + await expect + .element(screen.getByRole("combobox", { name: "Links from", exact: true })) + .toHaveTextContent("Posts"); + await choose(screen, "Links to", "Authors"); + await expect + .element(screen.getByLabelText("Slug", { exact: true })) + .toHaveValue("posts_authors"); + + await screen.getByLabelText("Linking side (plural)").fill("Posts"); + await screen.getByLabelText("Linked side (plural)").fill("Authors"); + // The dialog's inert overlay blocks Playwright's actionability checks, so + // submit through the DOM as the other dialog tests do. + screen.getByRole("button", { name: "Create Relation" }).element().click(); + + await vi.waitFor(() => { + expect(onCreateRelation).toHaveBeenCalledWith({ + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: null, + childLabel: "Authors", + childLabelSingular: null, + maxChildrenPerParent: null, + maxParentsPerChild: null, + }); + }); + }); + + it("keeps the dialog open and shows the server's message when creating fails", async () => { + const onCreateRelation = vi.fn(async () => { + throw new Error("A relation with slug 'posts_authors' already exists"); + }); + const screen = await renderPanel([], { onCreateRelation }); + + await screen.getByRole("button", { name: "Create First Relation" }).click(); + await choose(screen, "Links to", "Authors"); + await screen.getByLabelText("Linking side (plural)").fill("Posts"); + await screen.getByLabelText("Linked side (plural)").fill("Authors"); + // The dialog's inert overlay blocks Playwright's actionability checks, so + // submit through the DOM as the other dialog tests do. + screen.getByRole("button", { name: "Create Relation" }).element().click(); + + await expect + .element(screen.getByText("A relation with slug 'posts_authors' already exists")) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Create Relation" })) + .toBeInTheDocument(); + }); + }); }); diff --git a/packages/admin/tests/components/ContentTypeList.test.tsx b/packages/admin/tests/components/ContentTypeList.test.tsx index 2af01a6d0c..d3d93aab10 100644 --- a/packages/admin/tests/components/ContentTypeList.test.tsx +++ b/packages/admin/tests/components/ContentTypeList.test.tsx @@ -2,9 +2,16 @@ import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { ContentTypeList, moveCollection } from "../../src/components/ContentTypeList"; +import { fetchRelations } from "../../src/lib/api"; import type { SchemaCollection, OrphanedTable } from "../../src/lib/api"; +import type { RelationWithUsage } from "../../src/lib/api/relations.js"; import { render } from "../utils/render.tsx"; +vi.mock("../../src/lib/api", async () => { + const actual = await vi.importActual<typeof import("../../src/lib/api")>("../../src/lib/api"); + return { ...actual, fetchRelations: vi.fn(async () => []) }; +}); + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -291,3 +298,55 @@ describe("ContentTypeList", () => { }); }); }); + +describe("ContentTypeList relationship warning", () => { + const relation: RelationWithUsage = { + id: "rel-1", + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + maxParentsPerChild: null, + boundFields: [ + { collectionSlug: "posts", fieldSlug: "author", side: "parent" }, + { collectionSlug: "authors", fieldSlug: "posts", side: "child" }, + ], + linkCount: 4, + }; + + beforeEach(() => { + vi.mocked(fetchRelations).mockResolvedValue([relation]); + }); + + // Deleting a content type cascades through every relationship it is an end + // of, which takes reference fields off *other* content types. + it("names the relationships and the fields on other content types that go with them", async () => { + const screen = await render( + <ContentTypeList collections={[makeCollection({ slug: "posts", label: "Posts" })]} />, + ); + + await screen.getByRole("button", { name: /Delete Posts/i }).click(); + await expect.element(screen.getByText("Delete Content Type?")).toBeInTheDocument(); + + await expect.element(screen.getByText("posts_authors")).toBeInTheDocument(); + await expect + .element(screen.getByText(/the posts field on authors, which lists entries that link to it/)) + .toBeInTheDocument(); + await expect.element(screen.getByText("4 links")).toBeInTheDocument(); + }); + + it("says nothing about relationships for a content type in none", async () => { + const screen = await render( + <ContentTypeList collections={[makeCollection({ slug: "pages", label: "Pages" })]} />, + ); + + await screen.getByRole("button", { name: /Delete Pages/i }).click(); + await expect.element(screen.getByText("Delete Content Type?")).toBeInTheDocument(); + + expect(screen.getByText("posts_authors").query()).toBeNull(); + }); +}); diff --git a/packages/admin/tests/components/FieldEditor.test.tsx b/packages/admin/tests/components/FieldEditor.test.tsx index 0813844333..dffb05f8a5 100644 --- a/packages/admin/tests/components/FieldEditor.test.tsx +++ b/packages/admin/tests/components/FieldEditor.test.tsx @@ -2,9 +2,16 @@ import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { FieldEditor } from "../../src/components/FieldEditor"; +import { fetchCollections, fetchRelations } from "../../src/lib/api"; import type { SchemaField } from "../../src/lib/api"; +import type { RelationWithUsage } from "../../src/lib/api/relations.js"; import { render } from "../utils/render.tsx"; +vi.mock("../../src/lib/api", async () => { + const actual = await vi.importActual<typeof import("../../src/lib/api")>("../../src/lib/api"); + return { ...actual, fetchCollections: vi.fn(), fetchRelations: vi.fn() }; +}); + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -67,6 +74,12 @@ describe("FieldEditor", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(fetchCollections).mockResolvedValue([ + { slug: "posts", label: "Posts", labelSingular: "Post" }, + { slug: "authors", label: "Authors", labelSingular: "Author" }, + { slug: "pages", label: "Pages", labelSingular: "Page" }, + ] as Awaited<ReturnType<typeof fetchCollections>>); + vi.mocked(fetchRelations).mockResolvedValue([]); }); describe("type selection step", () => { @@ -369,6 +382,275 @@ describe("FieldEditor", () => { expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ indexed: true })); }); + + it("hides and clears the flag for a storage-less reference field", async () => { + const onSave = vi.fn(); + const field = makeField({ + slug: "related", + label: "Related", + type: "reference", + indexed: true, + validation: { targetCollection: "posts", multiple: true }, + }); + const screen = await render(<FieldEditor {...defaultProps} field={field} onSave={onSave} />); + + expect(screen.getByText("Indexed").query()).toBeNull(); + await save(screen); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ indexed: false })); + }); + }); + + describe("reference field that predates relations", () => { + const legacyField = makeField({ + slug: "author", + label: "Author", + type: "reference", + required: false, + searchable: false, + options: { collection: "authors" }, + }); + + const boundField = makeField({ + slug: "author", + label: "Author", + type: "reference", + required: false, + searchable: false, + validation: { relation: "posts_author", targetCollection: "authors" }, + }); + + it("shows the collection its options named, so it can be confirmed", async () => { + const screen = await render(<FieldEditor {...defaultProps} field={legacyField} />); + + await expect + .element(screen.getByRole("combobox", { name: "Referenced collection" })) + .toBeEnabled(); + await expect + .element(screen.getByText(/Saving a collection here turns this field into an entry picker/)) + .toBeInTheDocument(); + }); + + it("keeps a bound field's collection immutable", async () => { + const screen = await render(<FieldEditor {...defaultProps} field={boundField} />); + + await expect + .element(screen.getByRole("combobox", { name: "Referenced collection" })) + .toBeDisabled(); + await expect + .element( + screen.getByText(/The relationship and the referenced collection cannot be changed/), + ) + .toBeInTheDocument(); + }); + + it("sends the collection on save so the server can bind the field", async () => { + const onSave = vi.fn(); + const screen = await render( + <FieldEditor {...defaultProps} field={legacyField} onSave={onSave} />, + ); + + const button = screen.getByRole("button", { name: "Update Field" }); + await expect.element(button).toBeEnabled(); + button.element().click(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + validation: expect.objectContaining({ targetCollection: "authors" }), + }), + ); + }); + }); + + describe("binding a reference field to an existing relationship", () => { + const relationField = makeField({ + slug: "author", + label: "Author", + type: "reference", + required: false, + searchable: false, + }); + + function relation(overrides: Partial<RelationWithUsage> = {}): RelationWithUsage { + return { + id: "rel-1", + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + maxParentsPerChild: null, + boundFields: [], + linkCount: 0, + ...overrides, + }; + } + + async function openWithRelations( + relations: RelationWithUsage[], + props: Partial<React.ComponentProps<typeof FieldEditor>> = {}, + ) { + vi.mocked(fetchRelations).mockResolvedValue(relations); + return render( + <FieldEditor {...defaultProps} field={relationField} collectionSlug="posts" {...props} />, + ); + } + + /** Kumo's Select is a combobox button over a listbox; the dialog's inert + * overlay blocks Playwright's actionability checks, so drive it through + * the DOM as the other dialog tests do. */ + async function choose( + screen: Awaited<ReturnType<typeof openWithRelations>>, + label: string, + option: string, + ) { + const trigger = screen.getByRole("combobox", { name: label }); + await expect.element(trigger).toBeInTheDocument(); + trigger.element().click(); + await vi.waitFor(() => { + screen.getByRole("option", { name: option, exact: true }).element().click(); + }); + } + + it("offers a relationship this collection can still bind to", async () => { + const screen = await openWithRelations([relation()]); + + await expect + .element(screen.getByRole("combobox", { name: "Relationship" })) + .toBeInTheDocument(); + }); + + // Quick create is a choice even with nothing to choose between, because it + // is what tells the user a relationship is being made for them and where + // to go to make one themselves. + it("offers quick create and the relation editor when no relationship is bindable", async () => { + const screen = await openWithRelations([]); + + await expect + .element(screen.getByRole("combobox", { name: "Relationship" })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("link", { name: "Create the relationship yourself" })) + .toHaveAttribute("href", "/_emdash/admin/content-types/relations/new"); + }); + + // Both ends of this relation already have a field, so a third picker over + // the same links has nowhere to go. + it("leaves out a relationship whose ends are already picked from", async () => { + const screen = await openWithRelations([ + relation({ + boundFields: [ + { collectionSlug: "posts", fieldSlug: "author", side: "parent" }, + { collectionSlug: "authors", fieldSlug: "posts", side: "child" }, + ], + }), + ]); + + const trigger = screen.getByRole("combobox", { name: "Relationship" }); + await expect.element(trigger).toBeInTheDocument(); + trigger.element().click(); + + await expect + .element(screen.getByRole("option", { name: "Quick create a relationship" })) + .toBeInTheDocument(); + expect(screen.getByRole("option", { name: "posts_authors" }).query()).toBeNull(); + }); + + it("derives the side and sends it with the relationship", async () => { + const onSave = vi.fn(); + const screen = await openWithRelations([relation()], { onSave }); + + await choose(screen, "Relationship", "posts_authors"); + await expect + .element(screen.getByText("This field picks entries this one links to.")) + .toBeInTheDocument(); + + screen.getByRole("button", { name: "Update Field" }).element().click(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + validation: expect.objectContaining({ + relation: "posts_authors", + relationSide: "parent", + }), + }), + ); + }); + + it("derives the child side when this collection is the linked end", async () => { + const onSave = vi.fn(); + const screen = await openWithRelations( + [relation({ parentCollection: "pages", childCollection: "posts" })], + { onSave }, + ); + + await choose(screen, "Relationship", "posts_authors"); + await expect + .element(screen.getByText("This field lists entries that link to this one.")) + .toBeInTheDocument(); + + screen.getByRole("button", { name: "Update Field" }).element().click(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + validation: expect.objectContaining({ relationSide: "child" }), + }), + ); + }); + + // Both ends are this collection, so neither is implied by the schema. + it("offers the side as a choice on a self-referential relationship", async () => { + const onSave = vi.fn(); + const screen = await openWithRelations( + [relation({ slug: "posts_related", parentCollection: "posts", childCollection: "posts" })], + { onSave }, + ); + + await choose(screen, "Relationship", "posts_related"); + await choose(screen, "This field picks", "Entries that link to this one"); + + screen.getByRole("button", { name: "Update Field" }).element().click(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + validation: expect.objectContaining({ + relation: "posts_related", + relationSide: "child", + }), + }), + ); + }); + + // The relationship owns the target and the limits, so sending a target + // collection alongside it would let the two disagree. + it("sends no target collection when a relationship is chosen", async () => { + const onSave = vi.fn(); + const screen = await openWithRelations([relation()], { onSave }); + + await choose(screen, "Relationship", "posts_authors"); + screen.getByRole("button", { name: "Update Field" }).element().click(); + + const validation = onSave.mock.calls[0]?.[0]?.validation as Record<string, unknown>; + expect(validation.targetCollection).toBeUndefined(); + expect(validation.multiple).toBeUndefined(); + }); + + it("still creates a relationship when none is chosen", async () => { + const onSave = vi.fn(); + const screen = await openWithRelations([relation()], { onSave }); + + await choose(screen, "Referenced collection", "Authors"); + screen.getByRole("button", { name: "Update Field" }).element().click(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + validation: expect.objectContaining({ targetCollection: "authors" }), + }), + ); + }); }); describe("config step (file field)", () => { diff --git a/packages/admin/tests/components/ReferencesSidebar.test.tsx b/packages/admin/tests/components/ReferencesSidebar.test.tsx new file mode 100644 index 0000000000..19211e0e7f --- /dev/null +++ b/packages/admin/tests/components/ReferencesSidebar.test.tsx @@ -0,0 +1,153 @@ +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ReferencesSidebar } from "../../src/components/ReferencesSidebar"; +import { + fetchReferenceParents, + fetchRelations, + type EntryRef, + type RelationWithUsage, +} from "../../src/lib/api/relations.js"; +import { render } from "../utils/render.tsx"; + +vi.mock("../../src/lib/api", async () => { + const actual = await vi.importActual("../../src/lib/api"); + return { + ...actual, + fetchCollections: vi.fn(async () => [ + { slug: "posts", label: "Posts" }, + { slug: "pages", label: "Pages" }, + ]), + }; +}); + +vi.mock("../../src/lib/api/relations.js", async () => { + const actual = await vi.importActual("../../src/lib/api/relations.js"); + return { + ...actual, + fetchRelations: vi.fn(), + fetchReferenceParents: vi.fn(), + }; +}); + +function relation(overrides: Partial<RelationWithUsage>): RelationWithUsage { + return { + id: "rel-1", + slug: "posts_author", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: null, + maxParentsPerChild: null, + boundFields: [], + linkCount: 0, + ...overrides, + }; +} + +function parentRef(overrides: Partial<EntryRef>): EntryRef { + return { + id: "post-1", + slug: "launch-notes", + collection: "posts", + title: "Launch notes", + locale: "en", + translationGroup: "group-1", + ...overrides, + }; +} + +async function renderSidebar() { + const rootRoute = createRootRoute({ component: Outlet }); + const panelRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => <ReferencesSidebar collection="authors" entryId="author-1" />, + }); + const contentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/content/$collection/$id", + validateSearch: (search: Record<string, unknown>) => ({ + locale: typeof search.locale === "string" ? search.locale : undefined, + }), + component: () => <div>Content destination</div>, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([panelRoute, contentRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render(<RouterProvider router={router} />); +} + +describe("ReferencesSidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows an empty state when the entry has no backlinks", async () => { + vi.mocked(fetchRelations).mockResolvedValue([]); + vi.mocked(fetchReferenceParents).mockResolvedValue({ parents: [] }); + + const screen = await renderSidebar(); + + await expect + .element(screen.getByRole("heading", { name: "Referenced by" })) + .toBeInTheDocument(); + await expect.element(screen.getByText("No references yet.")).toBeInTheDocument(); + }); + + it("lists the backlinks of every relation pointing at the collection", async () => { + vi.mocked(fetchRelations).mockResolvedValue([ + relation({ id: "rel-authored", slug: "posts_author", parentCollection: "posts" }), + relation({ id: "rel-reviewed", slug: "pages_reviewer", parentCollection: "pages" }), + relation({ + id: "rel-elsewhere", + slug: "posts_tags", + parentCollection: "posts", + childCollection: "tags", + }), + ]); + // Keyed by the identifier the panel sends: a relation resolves by id or + // slug, and anything else 404s into an empty panel. + const parentsByRelation: Record<string, EntryRef[]> = { + "rel-authored": [parentRef({ id: "post-1", title: "Launch notes" })], + "rel-reviewed": [ + parentRef({ id: "page-1", title: "About us", collection: "pages", slug: "about-us" }), + ], + }; + vi.mocked(fetchReferenceParents).mockImplementation(async (_collection, _id, rel) => ({ + parents: parentsByRelation[rel] ?? [], + })); + + const screen = await renderSidebar(); + + await expect.element(screen.getByRole("heading", { name: "Posts" })).toBeInTheDocument(); + await expect.element(screen.getByRole("heading", { name: "Pages" })).toBeInTheDocument(); + await expect.element(screen.getByText("Launch notes")).toBeInTheDocument(); + await expect.element(screen.getByText("About us")).toBeInTheDocument(); + }); + + it("ignores relations whose child side is another collection", async () => { + vi.mocked(fetchRelations).mockResolvedValue([ + relation({ id: "rel-elsewhere", parentCollection: "posts", childCollection: "tags" }), + ]); + vi.mocked(fetchReferenceParents).mockResolvedValue({ + parents: [parentRef({ title: "Should not appear" })], + }); + + const screen = await renderSidebar(); + + await expect.element(screen.getByText("No references yet.")).toBeInTheDocument(); + expect(fetchReferenceParents).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/admin/tests/routes/relations.test.tsx b/packages/admin/tests/routes/relations.test.tsx new file mode 100644 index 0000000000..e64f286cb0 --- /dev/null +++ b/packages/admin/tests/routes/relations.test.tsx @@ -0,0 +1,262 @@ +/** + * The relations admin surface: the list, the editor, and the routing that + * reaches them. + * + * `/content-types/relations` sits under the collection editor's own + * `/content-types/$slug`, so the routing assertions here are the ones that + * catch a relation page being served as a collection called "relations". + */ + +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { RelationDangerZone } from "../../src/components/RelationDangerZone"; +import { RelationEditor } from "../../src/components/RelationEditor"; +import { RelationList } from "../../src/components/RelationList"; +import type { SchemaCollection } from "../../src/lib/api"; +import type { RelationWithUsage } from "../../src/lib/api/relations.js"; +import { createAdminRouter } from "../../src/router"; +import { render } from "../utils/render.tsx"; +import { createTestQueryClient } from "../utils/test-helpers"; + +function relation(overrides: Partial<RelationWithUsage> = {}): RelationWithUsage { + return { + id: "rel-1", + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + maxParentsPerChild: null, + boundFields: [{ collectionSlug: "posts", fieldSlug: "author", side: "parent" }], + linkCount: 12, + ...overrides, + }; +} + +const collections = [ + { slug: "posts", label: "Posts", labelSingular: "Post" }, + { slug: "authors", label: "Authors", labelSingular: "Author" }, +] as SchemaCollection[]; + +/** Render `element` inside a router that owns the relation route shapes, so + * `<Link>` targets resolve the way they do in the admin. */ +async function renderWithRoutes(element: React.ReactNode) { + const rootRoute = createRootRoute({ component: Outlet }); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => element, + }); + const listRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/content-types/relations", + component: () => <div>Relations list</div>, + }); + const newRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/content-types/relations/new", + component: () => <div>New relation</div>, + }); + const editRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/content-types/relations/$slug", + component: () => <div>Edit relation</div>, + }); + const contentTypesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/content-types", + component: () => <div>Content types</div>, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + contentTypesRoute, + listRoute, + newRoute, + editRoute, + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const screen = await render(<RouterProvider router={router} />); + return { router, screen }; +} + +/** Kumo's Select is a combobox button over a listbox, not a native select. */ +async function selectOption( + screen: Awaited<ReturnType<typeof renderWithRoutes>>["screen"], + label: string, + option: string, +) { + await screen.getByLabelText(label, { exact: true }).click(); + await screen.getByRole("option", { name: option, exact: true }).click(); +} + +describe("RelationList", () => { + it("shows each relation's ends, bound fields and link count", async () => { + const { screen } = await renderWithRoutes(<RelationList relations={[relation()]} />); + + await expect.element(screen.getByText("posts_authors")).toBeInTheDocument(); + await expect.element(screen.getByText("posts.author")).toBeInTheDocument(); + await expect.element(screen.getByText("picks Authors")).toBeInTheDocument(); + await expect.element(screen.getByText("12 links")).toBeInTheDocument(); + }); + + it("says which end a child-side field picks from", async () => { + const { screen } = await renderWithRoutes( + <RelationList + relations={[ + relation({ + boundFields: [{ collectionSlug: "authors", fieldSlug: "posts", side: "child" }], + }), + ]} + />, + ); + + await expect.element(screen.getByText("picks Posts")).toBeInTheDocument(); + }); + + // Unbinding the last field leaves a relation behind; without a row here it + // is unreachable and undeletable. + it("lists a relation with no bound fields", async () => { + const { screen } = await renderWithRoutes( + <RelationList relations={[relation({ boundFields: [], linkCount: 0 })]} />, + ); + + await expect.element(screen.getByText("posts_authors")).toBeInTheDocument(); + await expect.element(screen.getByText("No fields")).toBeInTheDocument(); + }); + + it("navigates to a relation from its slug", async () => { + const { router, screen } = await renderWithRoutes(<RelationList relations={[relation()]} />); + + screen.getByRole("link", { name: "posts_authors", exact: true }).element().click(); + + await vi.waitFor(() => { + expect(router.state.location.pathname).toBe("/content-types/relations/posts_authors"); + }); + }); +}); + +describe("RelationEditor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("saves the roles and limits of an existing relation", async () => { + const onSave = vi.fn(); + const { screen } = await renderWithRoutes( + <RelationEditor relation={relation()} collections={collections} onSave={onSave} />, + ); + + const childLabel = screen.getByLabelText("Linked side (plural)"); + await childLabel.fill("Writers"); + await screen.getByRole("button", { name: /Save/ }).click(); + + await vi.waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0]?.[0]).toEqual({ + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Writers", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + maxParentsPerChild: null, + }); + }); + + // The two ends key every stored link; changing one would repoint them at + // content of the wrong type. + it("locks the ends and the slug of an existing relation", async () => { + const { screen } = await renderWithRoutes( + <RelationEditor relation={relation()} collections={collections} onSave={vi.fn()} />, + ); + + await expect.element(screen.getByLabelText("Slug")).toBeDisabled(); + await expect.element(screen.getByLabelText("Links from", { exact: true })).toBeDisabled(); + await expect.element(screen.getByLabelText("Links to", { exact: true })).toBeDisabled(); + }); + + it("sends the two ends and a slug when creating", async () => { + const onSave = vi.fn(); + const { screen } = await renderWithRoutes( + <RelationEditor isNew collections={collections} onSave={onSave} />, + ); + + await selectOption(screen, "Links from", "Posts"); + await selectOption(screen, "Links to", "Authors"); + await screen.getByLabelText("Linking side (plural)").fill("Posts"); + await screen.getByLabelText("Linked side (plural)").fill("Authors"); + await screen.getByRole("button", { name: /Save/ }).click(); + + await vi.waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0]?.[0]).toMatchObject({ + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + childLabel: "Authors", + }); + }); +}); + +describe("relation routes", () => { + // `/content-types/relations` sits inside `/content-types/$slug`'s space. If + // the dynamic route ever wins, the page becomes a collection editor for a + // collection named "relations" — a 404 the user cannot act on. + it("prefers the relations routes over the collection editor", () => { + const router = createAdminRouter(createTestQueryClient()); + + const matchIds = (pathname: string) => + router.matchRoutes({ pathname, search: {}, hash: "", href: pathname, state: {} }).at(-1) + ?.routeId; + + expect(matchIds("/content-types/relations")).toBe("/_admin/content-types/relations"); + expect(matchIds("/content-types/relations/new")).toBe("/_admin/content-types/relations/new"); + expect(matchIds("/content-types/relations/posts_authors")).toBe( + "/_admin/content-types/relations/$slug", + ); + expect(matchIds("/content-types/posts")).toBe("/_admin/content-types/$slug"); + }); +}); + +describe("RelationDangerZone", () => { + // The relation delete cascades to the fields on both ends, so the dialog + // says so before it runs rather than reporting it afterwards. + it("names what the delete takes before it runs", async () => { + const { screen } = await renderWithRoutes( + <RelationDangerZone relation={relation()} onDelete={vi.fn()} />, + ); + + await screen.getByRole("button", { name: "Delete relationship", exact: true }).click(); + + await expect.element(screen.getByText("This removes:")).toBeInTheDocument(); + await expect + .element(screen.getByText(/the author field on posts, which picks entries it links to/)) + .toBeInTheDocument(); + await expect.element(screen.getByText("12 links")).toBeInTheDocument(); + }); + + // Bound fields are not a refusal: the dialog names them and the delete + // removes them. + it("deletes a relationship that fields are still bound to", async () => { + const onDelete = vi.fn(); + const { screen } = await renderWithRoutes( + <RelationDangerZone relation={relation()} onDelete={onDelete} />, + ); + + await screen.getByRole("button", { name: "Delete relationship", exact: true }).click(); + screen.getByRole("button", { name: "Delete", exact: true }).element().click(); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 4d39eb7e50..c96e0a5ac9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -225,7 +225,8 @@ "test:workerd": "vitest run --config vitest.workerd.config.ts", "test:smoke": "vitest run --config vitest.smoke.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", - "test:repro": "vitest run --config vitest.repro.config.ts" + "test:repro": "vitest run --config vitest.repro.config.ts", + "test:types": "vitest run --config vitest.types.config.ts" }, "dependencies": { "@atcute/lexicons": "catalog:", diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index aa7ad83f87..52a4312622 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -17,6 +17,7 @@ import { } from "../../database/repositories/content.js"; import { EntryLockRepository } from "../../database/repositories/entry-locks.js"; import { RedirectRepository } from "../../database/repositories/redirect.js"; +import { RelationRepository } from "../../database/repositories/relation.js"; import { RevisionRepository } from "../../database/repositories/revision.js"; import { SeoRepository } from "../../database/repositories/seo.js"; import { TaxonomyRepository } from "../../database/repositories/taxonomy.js"; @@ -41,12 +42,31 @@ import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; import { getI18nConfig, isI18nEnabled, resolveConfiguredLocale } from "../../i18n/config.js"; import { invalidateRedirectCache } from "../../redirects/cache.js"; +import { isStoragelessFieldRow } from "../../schema/types.js"; import { FTSManager } from "../../search/fts-manager.js"; import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingColumnError, isMissingTableError } from "../../utils/db-errors.js"; import { decodeRev, encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; +import { + getReferenceTitleField, + resolveEntries, + resolveEntryGroups, + setReferenceSelection, +} from "./relations.js"; +import { + applyStagedReferences, + liveReferenceSelection, + readStagedReferences, + STAGED_REFERENCES_KEY, + type StagedReferences, + validateStagedReferences, +} from "./staged-references.js"; import { validateMediaFields } from "./validate-media-fields.js"; +import { + referenceFieldConstraints, + validateRequiredReferencesPresent, +} from "./validate-references.js"; /** * Narrow a caught error to one carrying a structured `apiError` discriminant. @@ -204,6 +224,126 @@ async function hydrateBylines( item.byline = null; } +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +/** + * Hydrate the first page of each reference field's selection onto a single + * content item, keyed by field slug — the same key the create and update bodies + * take a selection under. + * + * Opt-in only: callers must have already decided `includeDrafts` (draft + * visibility is enforced by the caller, not this helper) because a resolved + * entry can carry a draft/scheduled entry's id and slug. See + * `handleContentGet`'s `referenceOptions` param — the REST GET route is the + * only caller that currently opts in. + * + * For a caller that opted into drafts, a field whose selection is staged in the + * entry's draft revision is answered from that revision; every other field, and + * every field for a caller that did not opt in, is answered from the links, + * which hold the published selection. That is what keeps a picker change on a + * published entry invisible until it is published. + * + * A staged selection arrives whole rather than a page at a time: it is already + * in memory, and resolving it costs the one batched read the save that staged it + * already paid. + */ +async function hydrateReferences( + db: Kysely<Database>, + collection: string, + item: ContentItem, + includeDrafts: boolean, +): Promise<void> { + if (!item.translationGroup) return; + + const collectionRow = await db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", collection) + .executeTakeFirst(); + if (!collectionRow) return; + + const fields = await db + .selectFrom("_emdash_fields") + .select(["slug", "validation"]) + .where("collection_id", "=", collectionRow.id) + .where("type", "=", "reference") + .execute(); + + const references: NonNullable<ContentItem["references"]> = {}; + if (fields.length === 0) { + item.references = references; + return; + } + + const repo = new RelationRepository(db); + const content = new ContentRepository(db); + + const staged = + includeDrafts && item.draftRevisionId + ? readStagedReferences( + (await new RevisionRepository(db).findById(item.draftRevisionId))?.data, + ) + : undefined; + + for (const field of fields) { + let validation: Record<string, unknown> = {}; + if (field.validation) { + let parsed: unknown; + try { + parsed = JSON.parse(field.validation); + } catch { + continue; + } + if (isRecord(parsed)) validation = parsed; + } + const relation = typeof validation.relation === "string" ? validation.relation : undefined; + const targetCollection = + typeof validation.targetCollection === "string" ? validation.targetCollection : undefined; + // A field with no relation keeps its own column; its value is already in + // `data` and there are no links to resolve. + if (!relation || !targetCollection) continue; + + const stagedGroups = staged?.[field.slug]; + if (stagedGroups) { + references[field.slug] = { + children: await resolveEntryGroups( + content, + targetCollection, + stagedGroups, + item.locale, + includeDrafts, + await getReferenceTitleField(db, targetCollection), + ), + }; + continue; + } + + // A field on the child end of its relation selects parents, which carry no + // order of their own — `sort_order` positions children within one parent. + const onChildSide = validation.relationSide === "child"; + const edges = onChildSide + ? await repo.getParentsPage(relation, item.translationGroup) + : await repo.getChildrenPage(relation, item.translationGroup); + const children = await resolveEntries( + content, + targetCollection, + edges.items, + (e) => (onChildSide ? e.parentGroup : e.childGroup), + item.locale, + includeDrafts, + await getReferenceTitleField(db, targetCollection), + ); + references[field.slug] = { + children, + ...(edges.nextCursor ? { nextCursor: edges.nextCursor } : {}), + }; + } + + item.references = references; +} + /** * Batch-hydrate bylines for multiple items using two bulk queries instead of N+1. * @@ -390,6 +530,40 @@ async function resolveSearchColumns(db: Kysely<Database>, collection: string): P return [...columns]; } +/** + * Remove storage-less field keys (e.g. a reference field bound to a relation) + * from a content `data` payload before it reaches the column writer, which would + * otherwise throw "no such column". Defensive for direct API users; the admin + * sends references in the dedicated `references` key, not in `data`. + * + * A reference field with no relation still owns its column, so its value passes + * through untouched. + */ +async function stripStoragelessDataKeys( + db: Kysely<Database>, + collection: string, + data: Record<string, unknown>, +): Promise<Record<string, unknown>> { + const collectionRow = await db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", collection) + .executeTakeFirst(); + if (!collectionRow) return data; + const fields = await db + .selectFrom("_emdash_fields") + .select(["slug", "type", "validation"]) + .where("collection_id", "=", collectionRow.id) + .execute(); + const storageless = new Set(fields.filter(isStoragelessFieldRow).map((f) => f.slug)); + if (storageless.size === 0) return data; + const cleaned: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(data)) { + if (!storageless.has(k)) cleaned[k] = v; + } + return cleaned; +} + /** * Decide whether the content-list `q` filter can be served from the * collection's FTS5 index instead of a full-scan substring LIKE (#1517). @@ -714,6 +888,7 @@ export async function handleContentGet( collection: string, id: string, locale?: string, + referenceOptions?: { includeDrafts: boolean }, ): Promise<ApiResult<ContentResponse>> { try { const repo = new ContentRepository(db); @@ -737,6 +912,12 @@ export async function handleContentGet( const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); await hydrateBylines(db, collection, item); + // Opt-in: hydration is skipped entirely unless the caller passes + // `referenceOptions`, since it can leak draft child ids/slugs — see + // `hydrateReferences`'s doc comment. + if (referenceOptions) { + await hydrateReferences(db, collection, item, referenceOptions.includeDrafts); + } return { success: true, @@ -823,6 +1004,8 @@ export async function handleContentCreate( translationOf?: string; seo?: ContentSeoInput; taxonomies?: Record<string, string[]>; + /** Reference fields: field slug → ordered selected entry ids. */ + references?: Record<string, string[]>; createdAt?: string | null; publishedAt?: string | null; }, @@ -841,9 +1024,19 @@ export async function handleContentCreate( }; } + body.data = await stripStoragelessDataKeys(db, collection, body.data); + const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; + const requiredReferences = await validateRequiredReferencesPresent( + db, + collection, + body.references, + body.translationOf, + ); + if (!requiredReferences.success) return requiredReferences; + // Wrap content + SEO writes in a transaction for atomicity const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); @@ -929,6 +1122,27 @@ export async function handleContentCreate( await assignTaxonomies(trx, collection, created.id, effectiveLocale, body.taxonomies); } + // Attach reference links in the same transaction: a field slug or + // selected id that fails to resolve throws with a structured + // `apiError`, aborting the whole save so no half-written entry + // (with taxonomies/bylines/SEO already committed) is left behind. + if (body.references) { + for (const [fieldSlug, selectedIds] of Object.entries(body.references)) { + const set = await setReferenceSelection( + trx, + collection, + created.id, + fieldSlug, + selectedIds, + ); + if (!set.success) { + throw Object.assign(new Error(set.error.message), { + apiError: { code: set.error.code }, + }); + } + } + } + return created; }); @@ -937,6 +1151,14 @@ export async function handleContentCreate( data: { item, _rev: encodeRev(item) }, }; } catch (error) { + // Handle structured errors thrown from inside the transaction (e.g. a + // reference resolution failure from `setReferenceSelection`). + if (hasApiError(error)) { + return { + success: false, + error: { code: error.apiError.code, message: error.message }, + }; + } if (isMissingTableError(error)) { return { success: false, @@ -1009,6 +1231,8 @@ export async function handleContentUpdate( _rev?: string; seo?: ContentSeoInput; taxonomies?: Record<string, string[]>; + /** Reference fields: field slug → ordered selected entry ids. */ + references?: Record<string, string[]>; publishedAt?: string | null; }, ): Promise<ApiResult<ContentResponse>> { @@ -1027,6 +1251,7 @@ export async function handleContentUpdate( } if (body.data) { + body.data = await stripStoragelessDataKeys(db, collection, body.data); const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; } @@ -1154,6 +1379,26 @@ export async function handleContentUpdate( ); } + // Replace reference links in the same transaction. See the matching + // block in handleContentCreate: a resolution failure throws with a + // structured `apiError`, aborting the whole update. + if (body.references) { + for (const [fieldSlug, selectedIds] of Object.entries(body.references)) { + const set = await setReferenceSelection( + trx, + collection, + resolvedId, + fieldSlug, + selectedIds, + ); + if (!set.success) { + throw Object.assign(new Error(set.error.message), { + apiError: { code: set.error.code }, + }); + } + } + } + return updated; }); @@ -1235,8 +1480,32 @@ export async function handleContentDuplicate( const repo = new ContentRepository(trx); const bylineRepo = new BylineRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; + const original = await repo.findById(collection, resolvedId); const dup = await repo.duplicate(collection, resolvedId, authorId); + // Reference edges are storage-less (keyed by translation_group, not in + // `data`), so they don't ride along in the row copy — carry the original's + // outgoing references onto the duplicate explicitly. + if (original?.translationGroup && dup.translationGroup) { + const relations = new RelationRepository(trx); + await relations.copyParentEdges(original.translationGroup, dup.translationGroup); + + // Where a field on this collection binds the *child* end, its + // backlinks are the field's value, so a duplicate that dropped them + // would lose that field's whole selection. Backlinks no field views + // still point only at the original. + for (const field of (await referenceFieldConstraints(trx, collection)).values()) { + if (field.relationSide !== "child") continue; + const parents = await relations.getParents(field.relation, original.translationGroup); + if (parents.length === 0) continue; + await relations.setParents( + field.relation, + dup.translationGroup, + parents.map((parent) => parent.parentGroup), + ); + } + } + const existingBylines = await bylineRepo.getContentBylines(collection, resolvedId); if (existingBylines.length > 0) { await bylineRepo.setContentBylines( @@ -1389,6 +1658,7 @@ export async function handleContentPermanentDelete( // Wrap content delete + SEO/comment cleanup in a transaction const deleted = await withTransaction(db, async (trx) => { const trxRepo = new ContentRepository(trx); + const item = await trxRepo.findByIdIncludingTrashed(collection, resolvedId); const wasDeleted = await trxRepo.permanentDelete(collection, resolvedId); if (wasDeleted) { @@ -1402,6 +1672,18 @@ export async function handleContentPermanentDelete( const revisionRepo = new RevisionRepository(trx); await revisionRepo.deleteByEntry(collection, resolvedId); await new EntryLockRepository(trx).releaseEntry(collection, resolvedId); + // Reference edges are keyed by translation_group, so they belong to the + // group rather than to this row — drop them only once no sibling + // (trashed ones included, they can still be restored) is left to own them. + if (item?.translationGroup) { + const groupSurvives = await trxRepo.hasTranslationsIncludingTrashed( + collection, + item.translationGroup, + ); + if (!groupSurvives) { + await new RelationRepository(trx).clearReferencesForGroup(item.translationGroup); + } + } } return wasDeleted; @@ -1638,6 +1920,30 @@ export async function handleContentPublish( // are normally created. const existing = await repo.findById(collection, resolvedId); + // A selection staged in the draft becomes the live one here. Validate + // before the publishing statement rather than after: `withTransaction` + // degrades to sequential statements on D1, so a selection rejected + // afterwards would leave the entry published with the old links. + const stagedReferences = + publishConfig.supportsRevisions && existing?.draftRevisionId + ? readStagedReferences( + (await new RevisionRepository(trx).findById(existing.draftRevisionId))?.data, + ) + : undefined; + if (existing?.translationGroup) { + const valid = await validateStagedReferences( + trx, + collection, + stagedReferences ?? {}, + existing.translationGroup, + ); + if (!valid.success) { + throw Object.assign(new Error(valid.error.message), { + apiError: { code: valid.error.code }, + }); + } + } + const published = await repo.publish( collection, resolvedId, @@ -1649,6 +1955,10 @@ export async function handleContentPublish( expectedRevision, ); + if (stagedReferences && published.translationGroup) { + await applyStagedReferences(trx, collection, published.translationGroup, stagedReferences); + } + // Leave a 301 behind when publishing changed the slug of an entry that // was already published — its old URL was live and may be indexed or // linked. A first publish is excluded: a draft's URL was never public. @@ -1680,6 +1990,12 @@ export async function handleContentPublish( data: { item, _rev: encodeRev(item) }, }; } catch (error) { + if (hasApiError(error)) { + return { + success: false, + error: { code: error.apiError.code, message: error.message }, + }; + } if (error instanceof ContentMutationConflictError) { return { success: false, @@ -1910,13 +2226,31 @@ export async function handleContentCompare( const live = entry.liveRevisionId ? await revisionRepo.findById(entry.liveRevisionId) : null; const draft = entry.draftRevisionId ? await revisionRepo.findById(entry.draftRevisionId) : null; + // Reference selections have to be filled in from the links on both sides + // before they can be compared. The published selection is the links, not + // whatever `_references` the live revision happens to carry; and the draft + // stages only the fields its saves named, so the rest of its effective + // selection is the live one. + const liveReferences = entry.translationGroup + ? await liveReferenceSelection(db, collection, entry.translationGroup) + : {}; + const withReferences = ( + revisionData: Record<string, unknown> | undefined, + staged: StagedReferences, + ) => { + if (!revisionData) return undefined; + const selection = { ...liveReferences, ...staged }; + if (Object.keys(selection).length === 0) return revisionData; + return { ...revisionData, [STAGED_REFERENCES_KEY]: selection }; + }; + return { success: true, data: { hasChanges: entry.draftRevisionId !== null && entry.draftRevisionId !== entry.liveRevisionId, - live: live?.data ?? null, - draft: draft?.data ?? null, + live: withReferences(live?.data, {}) ?? null, + draft: withReferences(draft?.data, readStagedReferences(draft?.data) ?? {}) ?? null, }, }; } catch (error) { diff --git a/packages/core/src/api/handlers/manifest.ts b/packages/core/src/api/handlers/manifest.ts index 43a2a08383..d9a4078451 100644 --- a/packages/core/src/api/handlers/manifest.ts +++ b/packages/core/src/api/handlers/manifest.ts @@ -289,7 +289,10 @@ function dbFieldDescriptor(field: Field): ManifestFieldDescriptor { // Include validation only for field widgets that need it client-side. if ( - (field.type === "repeater" || field.type === "file" || field.type === "image") && + (field.type === "repeater" || + field.type === "file" || + field.type === "image" || + field.type === "reference") && field.validation ) { entry.validation = { ...field.validation }; diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index 3581ae1696..fddeafc0c9 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -6,13 +6,22 @@ import { type ContentReference, type CreateRelationInput, type Relation, + type UpdateRelationInput, } from "../../database/repositories/relation.js"; import { InvalidCursorError } from "../../database/repositories/types.js"; import type { ContentItem } from "../../database/repositories/types.js"; +import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; -import { resolveConfiguredLocale } from "../../i18n/config.js"; +import { invalidateCollectionCache } from "../../object-cache/index.js"; +import { requestCached } from "../../request-cache.js"; +import { invalidateSchemaCache } from "../../schema/index.js"; import { SchemaRegistry } from "../../schema/registry.js"; import type { ApiResult } from "../types.js"; +import { + constraintsForRelationSide, + referenceFieldConstraints, + validateReferenceSelection, +} from "./validate-references.js"; /** Map an edge-read failure: a bad pagination cursor is a 400 client error, * everything else is the generic 500-shaped reference-read error. */ @@ -26,6 +35,10 @@ function referencesGetError(error: unknown): ApiResult<never> { }; } +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + /** True for SQLite UNIQUE / Postgres unique_violation messages (matches the * fingerprint used in the content handlers). Narrow enough not to catch NOT * NULL / CHECK violations whose messages also say "constraint". */ @@ -42,60 +55,31 @@ export async function handleRelationCreate( const repo = new RelationRepository(db); // Invariant: a relation must point at collections that exist. There is no - // SQL FK (group-linking precludes it), so a ghost collection would yield a - // structurally-valid-but-permanently-useless relation. Skip when - // `translationOf` is set — structural fields are then inherited from an - // already-validated source, and the input collections are ignored. - if (!input.translationOf) { - if (!input.parentCollection || !input.childCollection) { + // SQL FK (the edge endpoints are content translation_groups, which + // precludes one), so a ghost collection would yield a + // structurally-valid-but-permanently-useless relation. + const registry = new SchemaRegistry(db); + for (const collection of [input.parentCollection, input.childCollection]) { + if (!(await registry.getCollection(collection))) { return { success: false, error: { - code: "VALIDATION_ERROR", - message: - "parentCollection and childCollection are required unless translationOf is set", + code: "COLLECTION_NOT_FOUND", + message: `Collection '${collection}' not found`, }, }; } - const registry = new SchemaRegistry(db); - for (const collection of [input.parentCollection, input.childCollection]) { - if (!(await registry.getCollection(collection))) { - return { - success: false, - error: { - code: "COLLECTION_NOT_FOUND", - message: `Collection '${collection}' not found`, - }, - }; - } - } } - const relation = await repo.create({ - ...input, - locale: input.locale ? resolveConfiguredLocale(input.locale) : undefined, - }); + const relation = await repo.create(input); return { success: true, data: { relation } }; } catch (error) { - // A bad `translationOf` makes the repo throw loudly rather than mint an - // unlinked relation — surface it as 404, not a generic 500. - if ( - error instanceof Error && - error.message.includes("Source relation for translation not found") - ) { - return { - success: false, - error: { code: "NOT_FOUND", message: "Source relation for translation not found" }, - }; - } - // UNIQUE(name, locale) collision, or a second translation for an - // already-present (translation_group, locale) — both are client conflicts. if (isUniqueViolation(error)) { return { success: false, error: { code: "CONFLICT", - message: "A relation with this name or locale already exists", + message: "A relation with this slug already exists", }, }; } @@ -106,17 +90,34 @@ export async function handleRelationCreate( } } +/** + * A relation plus what deleting it would take with it: the reference fields + * that view it, and how many links it holds. Both delete dialogs enumerate + * these before the user confirms. + */ +export interface RelationWithUsage extends Relation { + boundFields: BoundField[]; + linkCount: number; +} + export async function handleRelationGet( db: Kysely<Database>, id: string, -): Promise<ApiResult<{ relation: Relation }>> { +): Promise<ApiResult<{ relation: RelationWithUsage }>> { try { const repo = new RelationRepository(db); const relation = await repo.findById(id); if (!relation) { return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; } - return { success: true, data: { relation } }; + const [boundFields, edgeCounts] = await Promise.all([ + fieldsBoundToRelation(db, relation.slug), + repo.countEdgesByRelation(), + ]); + return { + success: true, + data: { relation: { ...relation, boundFields, linkCount: edgeCounts.get(relation.id) ?? 0 } }, + }; } catch { return { success: false, @@ -125,15 +126,33 @@ export async function handleRelationGet( } } +/** + * List relations, optionally only those `collection` takes part in. + * + * The admin's relation picker filters this way: a reference field can only bind + * to a relation with its own collection on one end. + */ export async function handleRelationList( db: Kysely<Database>, - opts: { locale?: string }, -): Promise<ApiResult<{ relations: Relation[] }>> { + opts: { collection?: string } = {}, +): Promise<ApiResult<{ relations: RelationWithUsage[] }>> { try { const repo = new RelationRepository(db); - const locale = opts.locale ? resolveConfiguredLocale(opts.locale) : undefined; - const relations = await repo.list(locale); - return { success: true, data: { relations } }; + const [relations, boundByRelation, edgeCounts] = await Promise.all([ + opts.collection ? repo.findForCollection(opts.collection) : repo.list(), + fieldsBoundByRelation(db), + repo.countEdgesByRelation(), + ]); + return { + success: true, + data: { + relations: relations.map((relation) => ({ + ...relation, + boundFields: boundByRelation.get(relation.slug) ?? [], + linkCount: edgeCounts.get(relation.id) ?? 0, + })), + }, + }; } catch { return { success: false, @@ -145,7 +164,7 @@ export async function handleRelationList( export async function handleRelationUpdate( db: Kysely<Database>, id: string, - input: { parentLabel?: string; childLabel?: string }, + input: UpdateRelationInput, ): Promise<ApiResult<{ relation: Relation }>> { try { const repo = new RelationRepository(db); @@ -162,64 +181,122 @@ export async function handleRelationUpdate( } } -export async function handleRelationDelete( +/** A reference field that views a relation, and which end it views it from. */ +export interface BoundField { + collectionSlug: string; + fieldSlug: string; + side: "parent" | "child"; +} + +/** + * Every reference field on the site, grouped by the slug of the relation it + * binds. + * + * Filtered in JS rather than through `json_extract`: `_emdash_fields` holds one + * row per field on the whole site, and the reference-typed subset of that is + * small enough that a scan beats a dialect-specific JSON path. Grouping the + * whole set in one pass also keeps the relations list to a single scan instead + * of one per row. + */ +export async function fieldsBoundByRelation( db: Kysely<Database>, - id: string, -): Promise<ApiResult<{ deleted: true }>> { - try { - const repo = new RelationRepository(db); - const deleted = await repo.delete(id); - if (!deleted) { - return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; +): Promise<Map<string, BoundField[]>> { + const rows = await db + .selectFrom("_emdash_fields") + .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") + .select([ + "_emdash_fields.slug as fieldSlug", + "_emdash_collections.slug as collectionSlug", + "_emdash_fields.validation as validation", + ]) + .where("_emdash_fields.type", "=", "reference") + .execute(); + + const byRelation = new Map<string, BoundField[]>(); + for (const row of rows) { + if (!row.validation) continue; + let parsed: unknown; + try { + parsed = JSON.parse(row.validation); + } catch { + continue; } - return { success: true, data: { deleted: true } }; - } catch { - return { - success: false, - error: { code: "RELATION_DELETE_ERROR", message: "Failed to delete relation" }, + if (!isRecord(parsed) || typeof parsed.relation !== "string") continue; + const bound: BoundField = { + collectionSlug: row.collectionSlug, + fieldSlug: row.fieldSlug, + side: parsed.relationSide === "child" ? "child" : "parent", }; + const list = byRelation.get(parsed.relation); + if (list) list.push(bound); + else byRelation.set(parsed.relation, [bound]); } + return byRelation; +} + +/** Every reference field bound to one relation, across both of its ends. */ +export async function fieldsBoundToRelation( + db: Kysely<Database>, + relationSlug: string, +): Promise<BoundField[]> { + return (await fieldsBoundByRelation(db)).get(relationSlug) ?? []; } -export async function handleRelationTranslations( +/** + * Delete a relation, the reference fields bound to it, and its edges. + * + * A relation is only ever deleted deliberately — from the relations admin page, + * or by the checkbox on a field delete — and it cannot leave a field pointing at + * nothing, so the fields go with it either way. Callers show the count first; + * `deletedFields` reports what actually went. + * + * Order matters: fields first, then the relation row and its edges. On D1 + * `withTransaction` degrades to sequential statements, so an interrupted run + * leaves a relation with fewer bound fields — visible on the relations page and + * finishable — rather than fields pointing at a relation that no longer exists. + */ +export async function handleRelationDelete( db: Kysely<Database>, id: string, -): Promise< - ApiResult<{ - translationGroup: string; - translations: { - id: string; - name: string; - locale: string; - parentLabel: string; - childLabel: string; - }[]; - }> -> { +): Promise<ApiResult<{ deleted: true; deletedFields: string[] }>> { try { const repo = new RelationRepository(db); const relation = await repo.findById(id); if (!relation) { return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; } - const siblings = await repo.findTranslations(relation.translationGroup); + + const bound = await fieldsBoundToRelation(db, relation.slug); + + const deleted = await withTransaction(db, async (trx) => { + const registry = new SchemaRegistry(trx); + for (const field of bound) { + await registry.deleteField(field.collectionSlug, field.fieldSlug); + } + return new RelationRepository(trx).delete(id); + }); + + if (!deleted) { + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + } + + for (const collection of new Set(bound.map((f) => f.collectionSlug))) { + invalidateCollectionCache(collection); + invalidateSchemaCache(collection); + } + return { success: true, data: { - translationGroup: relation.translationGroup, - translations: siblings.map((r) => ({ - id: r.id, - name: r.name, - locale: r.locale, - parentLabel: r.parentLabel, - childLabel: r.childLabel, - })), + deleted: true, + deletedFields: bound.map((f) => `${f.collectionSlug}.${f.fieldSlug}`), }, }; - } catch { + } catch (error) { + console.error("Relation delete error:", error); return { success: false, - error: { code: "RELATION_TRANSLATIONS_ERROR", message: "Failed to get translations" }, + error: { code: "RELATION_DELETE_ERROR", message: "Failed to delete relation" }, }; } } @@ -228,20 +305,62 @@ export type EntryRef = { id: string; slug: string | null; collection: string; + /** + * Display label sourced from the collection's configured `titleField`, then + * `title`, then `name` — `null` when none is set, leaving the client to fall + * back to slug/id. Mirrors the admin's `getEntryTitle`. + */ + title: string | null; /** The actual locale of the resolved variant — see `pickVariant`. */ locale: string | null; + /** + * The edge's target: the translation group `id` was resolved from. Stable + * across locales, unlike `id`, so callers comparing a ref against a content + * row (the admin's picker) match the entry rather than one of its variants. + */ + translationGroup: string | null; sortOrder?: number; }; -/** Resolve a relation from an id OR its translation_group. */ +/** + * Display title for a resolved entry: the collection's configured `titleField`, + * then `title`, then `name`, else null. + */ +function entryTitle(data: Record<string, unknown>, titleField?: string): string | null { + if (titleField) { + const configured = data[titleField]; + if (typeof configured === "string" && configured.length > 0) return configured; + } + if (typeof data.title === "string" && data.title.length > 0) return data.title; + if (typeof data.name === "string" && data.name.length > 0) return data.name; + return null; +} + +/** + * The collection's configured `titleField`, memoized for the request: a single + * content read hydrates every reference field, and several of them commonly + * target the same collection. + */ +export async function getReferenceTitleField( + db: Kysely<Database>, + collection: string, +): Promise<string | undefined> { + return requestCached(`reference-title-field:${collection}`, async () => { + const row = await db + .selectFrom("_emdash_collections") + .select("title_field") + .where("slug", "=", collection) + .executeTakeFirst(); + return row?.title_field ?? undefined; + }); +} + +/** Resolve a relation from an id OR its slug. */ async function resolveRelation( repo: RelationRepository, - idOrGroup: string, + idOrSlug: string, ): Promise<Relation | null> { - const byId = await repo.findById(idOrGroup); - if (byId) return byId; - const group = await repo.findTranslations(idOrGroup); - return group[0] ?? null; + return (await repo.findById(idOrSlug)) ?? (await repo.findBySlug(idOrSlug)); } /** @@ -268,21 +387,65 @@ function pickVariant(items: ContentItem[], locale: string | null): ContentItem | * is restricted to published entries so a draft/scheduled entry referenced by an * edge is skipped exactly like a dangling one, never leaking its id/slug/locale. */ -async function resolveEntries( +export function resolveEntries( content: ContentRepository, collection: string, edges: ContentReference[], pick: (e: ContentReference) => string, locale: string | null, includeDrafts: boolean, + titleField?: string, ): Promise<EntryRef[]> { - const groups = edges.map(pick); - const all = await content.findTranslationsForGroups(collection, groups, { - publishedOnly: !includeDrafts, - }); + return resolveGroupSelection( + content, + collection, + edges.map((edge) => ({ group: pick(edge), sortOrder: edge.sortOrder })), + locale, + includeDrafts, + titleField, + ); +} - // Group the flat variant list by translation_group so each edge can pick its - // own locale variant. +/** + * `resolveEntries` for a selection that has no links behind it yet — a pending + * one staged in a draft revision, which is already a list of translation groups + * in the order the editor chose. Position stands in for the `sort_order` the + * links will carry once it is published. + */ +export function resolveEntryGroups( + content: ContentRepository, + collection: string, + groups: string[], + locale: string | null, + includeDrafts: boolean, + titleField?: string, +): Promise<EntryRef[]> { + return resolveGroupSelection( + content, + collection, + groups.map((group, index) => ({ group, sortOrder: index })), + locale, + includeDrafts, + titleField, + ); +} + +async function resolveGroupSelection( + content: ContentRepository, + collection: string, + selection: Array<{ group: string; sortOrder: number }>, + locale: string | null, + includeDrafts: boolean, + titleField?: string, +): Promise<EntryRef[]> { + const all = await content.findTranslationsForGroups( + collection, + selection.map((entry) => entry.group), + { publishedOnly: !includeDrafts }, + ); + + // Group the flat variant list by translation_group so each selected entry can + // pick its own locale variant. const variantsByGroup = new Map<string, ContentItem[]>(); for (const item of all) { if (item.translationGroup == null) continue; @@ -292,8 +455,8 @@ async function resolveEntries( } const refs: EntryRef[] = []; - for (const edge of edges) { - const variants = variantsByGroup.get(pick(edge)); + for (const selected of selection) { + const variants = variantsByGroup.get(selected.group); if (!variants) continue; const entry = pickVariant(variants, locale); if (!entry) continue; @@ -301,8 +464,10 @@ async function resolveEntries( id: entry.id, slug: entry.slug, collection, + title: entryTitle(entry.data, titleField), locale: entry.locale, - sortOrder: edge.sortOrder, + translationGroup: entry.translationGroup, + sortOrder: selected.sortOrder, }); } return refs; @@ -344,7 +509,7 @@ export async function handleReferenceChildrenGet( return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; } - const edges = await repo.getChildrenPage(rel.translationGroup, entry.translationGroup, page); + const edges = await repo.getChildrenPage(rel.id, entry.translationGroup, page); const children = await resolveEntries( content, rel.childCollection, @@ -352,6 +517,7 @@ export async function handleReferenceChildrenGet( (e) => e.childGroup, entry.locale, includeDrafts, + await getReferenceTitleField(db, rel.childCollection), ); return { success: true, data: { children, nextCursor: edges.nextCursor } }; } catch (error) { @@ -359,6 +525,239 @@ export async function handleReferenceChildrenGet( } } +/** + * A selection expressed in the identifiers the link table actually stores: + * `translation_group` on both ends, because an edge names a thing rather than + * one locale's row of it. + */ +export interface ReferenceSelectionWrite { + /** The relation, by id or slug — `setChildren` / `setParents` take either. */ + relation: string; + /** The end of the relation the selecting entry sits on. */ + side: "parent" | "child"; + /** The selecting entry's own translation group. */ + entryGroup: string; + /** The selected entries' translation groups, in the caller's order. */ + groups: string[]; +} + +/** + * Resolve a relation + an entry on one of its ends + the ids it selects, without + * writing anything, so a draft save can validate and canonicalize a selection at + * save time and stage the result. + * + * `side` is the end the entry sits on: a `parent` entry selects children, a + * `child` entry selects the parents pointing at it. + */ +async function resolveReferenceSide( + db: Kysely<Database>, + collection: string, + entryId: string, + relation: string, + selectedIds: string[], + side: "parent" | "child", +): Promise<ApiResult<ReferenceSelectionWrite>> { + const repo = new RelationRepository(db); + const content = new ContentRepository(db); + + const rel = await resolveRelation(repo, relation); + if (!rel) return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + + const ownCollection = side === "parent" ? rel.parentCollection : rel.childCollection; + const otherCollection = side === "parent" ? rel.childCollection : rel.parentCollection; + if (collection !== ownCollection) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `Entry is not the ${side} side of this relation`, + }, + }; + } + + // `relation` may have arrived as either an id or a slug, so look the field up + // by the resolved slug and the side it views. + const constraints = constraintsForRelationSide( + await referenceFieldConstraints(db, collection), + rel.slug, + side, + ); + if (constraints) { + const selection = validateReferenceSelection(constraints, selectedIds); + if (!selection.success) return selection; + } + + const entry = await content.findByIdOrSlug(collection, entryId); + if (!entry?.translationGroup) { + return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; + } + + // Resolve every selected entry within the relation's other collection in one + // batch (constant queries, not an N+1 of point lookups for a set up to 1000). + // An id that does not resolve there fails collection-agreement (invariant 3); + // order is preserved by iterating the caller's ids. + const resolved = await content.findManyByIdOrSlug(otherCollection, selectedIds); + const groups: string[] = []; + for (const selectedId of selectedIds) { + const other = resolved.get(selectedId); + if (!other?.translationGroup) { + return { + success: false, + error: { + code: "NOT_FOUND", + message: `${side === "parent" ? "Child" : "Parent"} entry '${selectedId}' not found in ${otherCollection}`, + }, + }; + } + groups.push(other.translationGroup); + } + + // Cardinality binds both ends, but only one end ever selects: a field on the + // parent side that hands a child its second parent breaks + // `maxParentsPerChild` even though the parent's own limit is untouched. + const farLimit = side === "parent" ? rel.maxParentsPerChild : rel.maxChildrenPerParent; + if (farLimit !== null && groups.length > 0) { + const counts = await repo.countEdgesByGroup( + rel.id, + side === "parent" ? "child" : "parent", + groups, + entry.translationGroup, + ); + for (const [index, group] of groups.entries()) { + if ((counts.get(group) ?? 0) + 1 <= farLimit) continue; + const farSide = side === "parent" ? "parent" : "child"; + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: + farLimit === 1 + ? `Entry '${selectedIds[index]}' already has a ${farSide} on this relation, which allows one.` + : `Entry '${selectedIds[index]}' already has the maximum of ${farLimit} ${farSide} entries on this relation.`, + }, + }; + } + } + + return { + success: true, + data: { relation: rel.id, side, entryGroup: entry.translationGroup, groups }, + }; +} + +/** + * Replace one end's links from an already-resolved selection. + * + * Nothing here can fail on the caller's input: resolution has already proved the + * relation, the entry and every selected entry exist. That is what lets publish + * apply a staged selection on D1, where the surrounding transaction degrades to + * sequential statements and a mid-way failure cannot be rolled back. + */ +export async function writeReferenceSelection( + db: Kysely<Database>, + selection: ReferenceSelectionWrite, +): Promise<void> { + const repo = new RelationRepository(db); + if (selection.side === "parent") { + await repo.setChildren(selection.relation, selection.entryGroup, selection.groups); + } else { + await repo.setParents(selection.relation, selection.entryGroup, selection.groups); + } +} + +/** + * Resolve a relation + an entry on one of its ends + the ids it selects, and + * replace that entry's links. + * + * Returns the resolved relation/entry translation_groups on success so callers + * can re-read and echo the new set without re-deriving them. + */ +async function setReferenceSide( + db: Kysely<Database>, + collection: string, + entryId: string, + relation: string, + selectedIds: string[], + side: "parent" | "child", +): Promise<ApiResult<{ relationId: string; entryGroup: string }>> { + const resolved = await resolveReferenceSide(db, collection, entryId, relation, selectedIds, side); + if (!resolved.success) return resolved; + await writeReferenceSelection(db, resolved.data); + return { + success: true, + data: { relationId: resolved.data.relation, entryGroup: resolved.data.entryGroup }, + }; +} + +/** `setReferenceSide` for the parent end, which the relation-scoped route takes. */ +export function setReferenceChildren( + db: Kysely<Database>, + collection: string, + entryId: string, + relation: string, + childIds: string[], +): Promise<ApiResult<{ relationId: string; entryGroup: string }>> { + return setReferenceSide(db, collection, entryId, relation, childIds, "parent"); +} + +/** + * Replace a reference field's selection on one entry, addressed by field slug. + * + * The field decides which relation and which end: a field bound to the parent + * end replaces that entry's children, one bound to the child end replaces the + * parents pointing at it. This is what the entry create/update body writes + * through, so a site addresses a selection the way it addresses any other field. + */ +export async function setReferenceSelection( + db: Kysely<Database>, + collection: string, + entryId: string, + fieldSlug: string, + selectedIds: string[], +): Promise<ApiResult<{ relationId: string; entryGroup: string }>> { + const resolved = await resolveReferenceSelection(db, collection, entryId, fieldSlug, selectedIds); + if (!resolved.success) return resolved; + await writeReferenceSelection(db, resolved.data); + return { + success: true, + data: { relationId: resolved.data.relation, entryGroup: resolved.data.entryGroup }, + }; +} + +/** + * `setReferenceSelection` up to but not including the write. + * + * A collection that keeps drafts stages the result in its draft revision instead + * of writing links, so a save reports a bad id or an over-long selection exactly + * as a direct write would, and publication has nothing left to resolve. + */ +export async function resolveReferenceSelection( + db: Kysely<Database>, + collection: string, + entryId: string, + fieldSlug: string, + selectedIds: string[], +): Promise<ApiResult<ReferenceSelectionWrite>> { + const constraints = (await referenceFieldConstraints(db, collection)).get(fieldSlug); + if (!constraints) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `Field '${fieldSlug}' is not a reference field on ${collection}`, + }, + }; + } + return resolveReferenceSide( + db, + collection, + entryId, + constraints.relation, + selectedIds, + constraints.relationSide, + ); +} + export async function handleReferenceChildrenSet( db: Kysely<Database>, collection: string, @@ -367,53 +766,27 @@ export async function handleReferenceChildrenSet( childIds: string[], ): Promise<ApiResult<{ children: EntryRef[]; nextCursor?: string }>> { try { + const set = await setReferenceChildren(db, collection, entryId, relation, childIds); + if (!set.success) return set; + const repo = new RelationRepository(db); const content = new ContentRepository(db); + // Re-resolve the relation/entry for their locale + childCollection — cheap + // relative to the write above, and keeps this function independent of + // `setReferenceChildren`'s internals beyond the two returned groups. const rel = await resolveRelation(repo, relation); if (!rel) return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; - if (collection !== rel.parentCollection) { - return { - success: false, - error: { - code: "VALIDATION_ERROR", - message: "Entry is not the parent side of this relation", - }, - }; - } - const entry = await content.findByIdOrSlug(collection, entryId); - if (!entry?.translationGroup) { + if (!entry) { return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; } - // Resolve every child within the relation's child_collection in one batch - // (constant queries, not an N+1 of point lookups for a set up to 1000). A - // child id that does not resolve there fails collection-agreement - // (invariant 3); order is preserved by iterating the caller's `childIds`. - const resolvedChildren = await content.findManyByIdOrSlug(rel.childCollection, childIds); - const childGroups: string[] = []; - for (const childId of childIds) { - const child = resolvedChildren.get(childId); - if (!child?.translationGroup) { - return { - success: false, - error: { - code: "NOT_FOUND", - message: `Child entry '${childId}' not found in ${rel.childCollection}`, - }, - }; - } - childGroups.push(child.translationGroup); - } - - await repo.setChildren(rel.translationGroup, entry.translationGroup, childGroups); - // Return the first page of the new set, mirroring the GET shape. The actor // holds an edit permission (gated by the route), so draft children are // included in the echo. - const edges = await repo.getChildrenPage(rel.translationGroup, entry.translationGroup); + const edges = await repo.getChildrenPage(set.data.relationId, set.data.entryGroup); const children = await resolveEntries( content, rel.childCollection, @@ -421,6 +794,7 @@ export async function handleReferenceChildrenSet( (e) => e.childGroup, entry.locale, true, + await getReferenceTitleField(db, rel.childCollection), ); return { success: true, data: { children, nextCursor: edges.nextCursor } }; } catch { @@ -463,7 +837,7 @@ export async function handleReferenceParentsGet( return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; } - const edges = await repo.getParentsPage(rel.translationGroup, entry.translationGroup, page); + const edges = await repo.getParentsPage(rel.id, entry.translationGroup, page); const parents = await resolveEntries( content, rel.parentCollection, @@ -471,6 +845,7 @@ export async function handleReferenceParentsGet( (e) => e.parentGroup, entry.locale, includeDrafts, + await getReferenceTitleField(db, rel.parentCollection), ); return { success: true, data: { parents, nextCursor: edges.nextCursor } }; } catch (error) { diff --git a/packages/core/src/api/handlers/revision.ts b/packages/core/src/api/handlers/revision.ts index 0683347380..be73ac58dc 100644 --- a/packages/core/src/api/handlers/revision.ts +++ b/packages/core/src/api/handlers/revision.ts @@ -10,6 +10,7 @@ import { RevisionRepository, type Revision } from "../../database/repositories/r import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; import type { ApiResult, ContentResponse } from "../types.js"; +import { applyStagedReferences, readStagedReferences } from "./staged-references.js"; export interface RevisionListResponse { items: Revision[]; @@ -110,8 +111,16 @@ export async function handleRevisionRestore( }; } - // Extract _slug from revision data (stored as metadata, not a real column) - const { _slug, ...fieldData } = revision.data; + // Leading-underscore keys are staged metadata (`_slug`, `_references`), not + // columns — `writableContentData` rejects them as identifiers. Each is + // restored on its own path below, both live: a restore replaces the + // published entry rather than staging a new draft. + const { _slug } = revision.data; + const fieldData: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(revision.data)) { + if (!key.startsWith("_")) fieldData[key] = value; + } + const stagedReferences = readStagedReferences(revision.data); // Atomically update content and create a new revision to record the restore. // If either operation fails, neither is committed (on engines that support @@ -125,6 +134,15 @@ export async function handleRevisionRestore( slug: typeof _slug === "string" ? _slug : undefined, }); + if (stagedReferences && updated.translationGroup) { + await applyStagedReferences( + trx, + revision.collection, + updated.translationGroup, + stagedReferences, + ); + } + const queuedRevision = await trxRevisionRepo.create({ collection: revision.collection, entryId: revision.entryId, diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 8c3496aaa2..91528e3068 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -4,8 +4,14 @@ import type { Kysely } from "kysely"; +import { backfillReferenceEdges } from "../../database/reference-backfill.js"; +import { RelationRepository, type Relation } from "../../database/repositories/relation.js"; +import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; -import { invalidateCollectionCache } from "../../object-cache/index.js"; +import { + invalidateCollectionCache, + invalidateSchemaObjectCache, +} from "../../object-cache/index.js"; import { SchemaRegistry, SchemaError, @@ -19,10 +25,136 @@ import { type CollectionWithFields, } from "../../schema/index.js"; import type { ApiResult } from "../types.js"; +import { fieldsBoundToRelation, handleRelationDelete } from "./relations.js"; + +/** Maximum attempts to allocate a unique relation slug for a new reference + * field: the base `${collection}_${field}` slug, then `_2` through `_5`. */ +const RELATION_NAME_MAX_ATTEMPTS = 5; + +/** True for SQLite UNIQUE / Postgres unique_violation messages — mirrors the + * fingerprint used in the relations API handler. */ +function isUniqueViolation(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ""; + return message.includes("unique constraint failed") || message.includes("duplicate key"); +} + +/** + * Create the relation definition backing a new reference field, retrying + * with a numeric suffix on a name collision. Runs inside the caller's + * transaction so the relation and the field row it backs commit or roll + * back together. + */ +export async function createFieldRelation( + trx: Kysely<Database>, + collectionSlug: string, + fieldSlug: string, + fieldLabel: string, + targetCollection: string, + /** How many entries the new field may hold. `null` is unlimited. */ + maxChildrenPerParent: number | null = null, +): Promise<Relation> { + const registry = new SchemaRegistry(trx); + const relations = new RelationRepository(trx); + + const parent = await registry.getCollection(collectionSlug); + if (!parent) { + throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND"); + } + + if (!(await registry.getCollection(targetCollection))) { + throw new SchemaError( + `Target collection "${targetCollection}" not found`, + "COLLECTION_NOT_FOUND", + ); + } + + const baseSlug = `${collectionSlug}_${fieldSlug}`.slice(0, 63); + for (let attempt = 0; attempt < RELATION_NAME_MAX_ATTEMPTS; attempt++) { + const suffix = attempt === 0 ? "" : `_${attempt + 1}`; + const slug = attempt === 0 ? baseSlug : `${baseSlug.slice(0, 63 - suffix.length)}${suffix}`; + try { + return await relations.create({ + slug, + parentCollection: collectionSlug, + childCollection: targetCollection, + parentLabel: parent.label, + parentLabelSingular: parent.labelSingular ?? null, + childLabel: fieldLabel, + maxChildrenPerParent, + }); + } catch (error) { + const isLastAttempt = attempt === RELATION_NAME_MAX_ATTEMPTS - 1; + if (isLastAttempt || !isUniqueViolation(error)) throw error; + } + } + throw new SchemaError("Could not allocate a unique relation name", "RELATION_NAME_CONFLICT"); +} +/** + * Every cache a field change can stale. + * + * The schema object-cache namespace is bumped here rather than only by the + * collection routes: the render path's reference field map lives in that + * namespace, so a field created, bound, relabelled or deleted would otherwise + * keep resolving against the shape the collection used to have. + */ function invalidateFieldCaches(collectionSlug: string): void { invalidateCollectionCache(collectionSlug); invalidateSchemaCache(collectionSlug); + invalidateSchemaObjectCache(); +} + +/** + * Bind a reference field that has no relation to one, and copy the selection its + * column holds in as edges. + * + * The column is left in place but stops being written, so the field also stops + * being `indexed` and `searchable`: an index over a frozen column would answer + * content-list filters and searches from values that no longer change. Clearing + * both here drops the field index and re-syncs FTS through `updateField`. + */ +export async function bindReferenceField( + db: Kysely<Database>, + collectionSlug: string, + existing: Field, + input: UpdateFieldInput, + targetCollection: string, +): Promise<Field> { + const label = input.label ?? existing.label; + const maxChildren = input.validation?.multiple ? null : 1; + + return withTransaction(db, async (trx) => { + const relation = await createFieldRelation( + trx, + collectionSlug, + existing.slug, + label, + targetCollection, + maxChildren, + ); + const registry = new SchemaRegistry(trx); + const updated = await registry.updateField(collectionSlug, existing.slug, { + ...input, + indexed: false, + searchable: false, + validation: { + ...input.validation, + relation: relation.slug, + relationSide: "parent", + targetCollection, + }, + }); + + await backfillReferenceEdges(trx, { + parentCollection: collectionSlug, + childCollection: targetCollection, + fieldSlug: existing.slug, + relationId: relation.id, + maxChildren, + }); + + return updated; + }); } export interface CollectionListResponse { @@ -208,6 +340,20 @@ export async function handleSchemaCollectionDelete( ): Promise<ApiResult<{ success: boolean }>> { try { const registry = new SchemaRegistry(db); + + // A relation with this collection on either end cannot outlive it: its + // edges point at content that is about to be dropped, and the reference + // fields viewing it — including ones on the *other* collection — would be + // left addressing a collection that no longer exists. The admin lists both + // before confirming. Relations go first, so an interrupted delete leaves a + // collection with fewer relations rather than a dropped table with + // relations still pointing at it. + const relations = new RelationRepository(db); + for (const relation of await relations.findForCollection(slug)) { + const removed = await handleRelationDelete(db, relation.id); + if (!removed.success) return removed; + } + await registry.deleteCollection(slug, options); return { @@ -310,6 +456,102 @@ export async function handleSchemaFieldGet( } } +/** + * Resolve which end of `relation` a field on `collectionSlug` sits on. + * + * The side is only a choice when both ends are the same collection — a + * self-referential relation such as related posts. Anywhere else the matching + * end decides it, and an explicit side that disagrees is a client error rather + * than something to silently override. + */ +function resolveBindingSide( + relation: Relation, + collectionSlug: string, + requested: "parent" | "child" | undefined, +): { side: "parent" | "child"; targetCollection: string } | { error: string } { + const isParent = relation.parentCollection === collectionSlug; + const isChild = relation.childCollection === collectionSlug; + + if (!isParent && !isChild) { + return { + error: `Relation '${relation.slug}' does not touch collection '${collectionSlug}'`, + }; + } + + if (isParent && isChild) { + const side = requested ?? "parent"; + return { + side, + targetCollection: side === "parent" ? relation.childCollection : relation.parentCollection, + }; + } + + const side = isParent ? "parent" : "child"; + if (requested && requested !== side) { + return { + error: `Collection '${collectionSlug}' is the ${side} of relation '${relation.slug}'`, + }; + } + + return { + side, + targetCollection: isParent ? relation.childCollection : relation.parentCollection, + }; +} + +/** + * Create a reference field that views an existing relation. + * + * Only one field may view a relation from a given end: two pickers writing the + * same link set have no defined merge, and the second would silently overwrite + * the first on every save. + */ +async function createBoundReferenceField( + db: Kysely<Database>, + collectionSlug: string, + input: CreateFieldInput, + relationSlug: string, +): Promise<ApiResult<FieldResponse>> { + const relation = await new RelationRepository(db).findBySlug(relationSlug); + if (!relation) { + return { + success: false, + error: { code: "NOT_FOUND", message: `Relation '${relationSlug}' not found` }, + }; + } + + const resolved = resolveBindingSide(relation, collectionSlug, input.validation?.relationSide); + if ("error" in resolved) { + return { success: false, error: { code: "VALIDATION_ERROR", message: resolved.error } }; + } + + const bound = await fieldsBoundToRelation(db, relation.slug); + const taken = bound.find((field) => field.side === resolved.side); + if (taken) { + return { + success: false, + error: { + code: "CONFLICT", + message: `Relation '${relation.slug}' is already picked from by ${taken.collectionSlug}.${taken.fieldSlug}`, + }, + }; + } + + const item = await new SchemaRegistry(db).createField(collectionSlug, { + ...input, + validation: { + ...input.validation, + relation: relation.slug, + relationSide: resolved.side, + targetCollection: resolved.targetCollection, + }, + }); + + invalidateFieldCaches(collectionSlug); + + return { success: true, data: { item } }; +} + /** * Create a field */ @@ -319,6 +561,57 @@ export async function handleSchemaFieldCreate( input: CreateFieldInput, ): Promise<ApiResult<FieldResponse>> { try { + if (input.type === "reference" && input.validation?.relation) { + return await createBoundReferenceField(db, collectionSlug, input, input.validation.relation); + } + + if (input.type === "reference") { + const targetCollection = input.validation?.targetCollection; + if (!targetCollection) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Reference field requires a target collection", + }, + }; + } + + // The relation def and the field row it backs must commit or roll + // back together — a field without its relation (or vice versa) is + // an inconsistent reference field. + const item = await withTransaction(db, async (trx) => { + // A single-reference field is a one-to-many relation: the limit is the + // relation's, so binding its other end later sees the same rule. + const relation = await createFieldRelation( + trx, + collectionSlug, + input.slug, + input.label, + targetCollection, + input.validation?.multiple ? null : 1, + ); + const registry = new SchemaRegistry(trx); + return registry.createField(collectionSlug, { + ...input, + validation: { + ...input.validation, + relation: relation.slug, + relationSide: "parent" as const, + targetCollection, + }, + }); + }); + + // Content snapshots embed field values; a column change invalidates them. + invalidateFieldCaches(collectionSlug); + + return { + success: true, + data: { item }, + }; + } + const registry = new SchemaRegistry(db); const item = await registry.createField(collectionSlug, input); @@ -360,6 +653,85 @@ export async function handleSchemaFieldUpdate( input: UpdateFieldInput, ): Promise<ApiResult<FieldResponse>> { try { + const lookupRegistry = new SchemaRegistry(db); + const existing = await lookupRegistry.getField(collectionSlug, fieldSlug); + const relationSlug = existing?.type === "reference" ? existing.validation?.relation : undefined; + const relationSide = existing?.validation?.relationSide; + + if (existing && relationSlug) { + // The relation's childCollection is immutable — a reference field's + // target collection can't change after the relation is wired up. + const nextTargetCollection = input.validation?.targetCollection; + if ( + nextTargetCollection !== undefined && + nextTargetCollection !== existing.validation?.targetCollection + ) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Cannot change the target collection of an existing reference field", + }, + }; + } + + // `relation` and `targetCollection` are immutable identity for a wired + // reference field. An update that sends `validation: null` or a partial + // validation object omitting these keys must not be allowed to clear + // them -- registry.updateField() writes whatever is passed verbatim, + // which would otherwise orphan the relation row and its edges. + const updateInput = + input.validation !== undefined + ? { + ...input, + validation: { + ...input.validation, + relation: existing.validation?.relation, + relationSide: existing.validation?.relationSide, + targetCollection: existing.validation?.targetCollection, + }, + } + : input; + + const item = await withTransaction(db, async (trx) => { + const registry = new SchemaRegistry(trx); + const updated = await registry.updateField(collectionSlug, fieldSlug, updateInput); + + if (input.label !== undefined && input.label !== existing.label) { + const relations = new RelationRepository(trx); + const relation = await relations.findBySlug(relationSlug); + // The field's label is the role name for the side it binds, so + // renaming the field renames that role and leaves the other alone. + if (relation) { + await relations.update( + relation.id, + relationSide === "child" ? { parentLabel: input.label } : { childLabel: input.label }, + ); + } + } + + return updated; + }); + + invalidateFieldCaches(collectionSlug); + + return { + success: true, + data: { item }, + }; + } + + // Giving an unbound reference field a target collection binds it: a field + // created before relations existed, or one whose target could not be + // resolved on upgrade, becomes a picker here rather than needing to be + // deleted and recreated. + const bindTarget = existing?.type === "reference" ? input.validation?.targetCollection : null; + if (existing && bindTarget) { + const item = await bindReferenceField(db, collectionSlug, existing, input, bindTarget); + invalidateFieldCaches(collectionSlug); + return { success: true, data: { item } }; + } + const registry = new SchemaRegistry(db); const item = await registry.updateField(collectionSlug, fieldSlug, input); @@ -393,12 +765,38 @@ export async function handleSchemaFieldUpdate( /** * Delete a field */ +/** + * Delete a field. + * + * For a reference field, `deleteRelation` also takes the relation, its edges, + * and the field bound to its other side. It is opt-in on the wire and checked + * by default in the admin's confirm dialog, which enumerates what goes first. + */ export async function handleSchemaFieldDelete( db: Kysely<Database>, collectionSlug: string, fieldSlug: string, + options?: { deleteRelation?: boolean }, ): Promise<ApiResult<{ success: boolean }>> { try { + const lookupRegistry = new SchemaRegistry(db); + const existing = await lookupRegistry.getField(collectionSlug, fieldSlug); + const relationSlug = existing?.type === "reference" ? existing.validation?.relation : undefined; + + if (relationSlug && options?.deleteRelation) { + // Taking the relation takes its edges and the field bound to its other + // side, so route through the shared cascade rather than deleting the + // field here and the relation separately. + const relations = new RelationRepository(db); + const relation = await relations.findBySlug(relationSlug); + if (relation) { + const result = await handleRelationDelete(db, relation.id); + if (!result.success) return result; + invalidateFieldCaches(collectionSlug); + return { success: true, data: { success: true } }; + } + } + const registry = new SchemaRegistry(db); await registry.deleteField(collectionSlug, fieldSlug); diff --git a/packages/core/src/api/handlers/staged-references.ts b/packages/core/src/api/handlers/staged-references.ts new file mode 100644 index 0000000000..eed9bef16c --- /dev/null +++ b/packages/core/src/api/handlers/staged-references.ts @@ -0,0 +1,149 @@ +import type { Kysely } from "kysely"; + +import { RelationRepository } from "../../database/repositories/relation.js"; +import type { Database } from "../../database/types.js"; +import type { ApiResult } from "../types.js"; +import { writeReferenceSelection } from "./relations.js"; +import { + referenceFieldConstraints, + validateReferenceSelection, + type ReferenceFieldConstraints, +} from "./validate-references.js"; + +/** + * Where a collection that keeps drafts stages a pending reference selection: in + * the draft revision's data, beside `_slug`. The leading underscore is what + * keeps it out of the column writer, the loaded entry's `data`, and the publish + * promotion loop, all of which already skip `_`-prefixed keys. + * + * The link table holds the live selection only. + */ +export const STAGED_REFERENCES_KEY = "_references"; + +/** + * A staged selection, by field slug, holding `translation_group` values rather + * than entry ids: an edge names a thing, not one locale's row of it, so the + * group is what the link table stores and what survives an entry being + * translated or re-slugged between saving and publishing. The save resolves ids + * to groups so publication has nothing left that can fail to resolve. + * + * Order is significant on the parent side, where it becomes `sort_order`. + */ +export type StagedReferences = Record<string, string[]>; + +function isGroupList(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +/** The staged selection inside a revision's data, if it carries one. */ +export function readStagedReferences( + data: Record<string, unknown> | undefined, +): StagedReferences | undefined { + const staged = data?.[STAGED_REFERENCES_KEY]; + if (typeof staged !== "object" || staged === null || Array.isArray(staged)) return undefined; + + const result: StagedReferences = {}; + for (const [fieldSlug, groups] of Object.entries(staged)) { + if (isGroupList(groups)) result[fieldSlug] = groups; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Fold a save's selections into whatever the previous draft staged. A save that + * names one reference field must not drop another field's pending selection, so + * fields absent from `incoming` keep their staged value. + */ +export function mergeStagedReferences( + base: Record<string, unknown> | undefined, + incoming: StagedReferences, +): StagedReferences { + return { ...readStagedReferences(base), ...incoming }; +} + +/** One bound field's live selection as translation groups. */ +async function liveFieldSelection( + repo: RelationRepository, + field: ReferenceFieldConstraints, + entryGroup: string, +): Promise<string[]> { + const links = + field.relationSide === "child" + ? await repo.getParents(field.relation, entryGroup) + : await repo.getChildren(field.relation, entryGroup); + return links.map((link) => (field.relationSide === "child" ? link.parentGroup : link.childGroup)); +} + +/** + * Re-check what publication is about to make live against the relation's current + * cardinality. + * + * A draft can sit unpublished across a schema edit that makes its field required + * or narrows the relation's limits, and it is publication — not the save that + * staged it — that has to hold the line. So this walks the collection's bound + * fields rather than the staged keys: a field added as required after the entry + * was written appears in no existing draft, and iterating `staged` would never + * reach it. A field the draft does stage needs no link read. + */ +export async function validateStagedReferences( + db: Kysely<Database>, + collection: string, + staged: StagedReferences, + entryGroup: string, +): Promise<ApiResult<true>> { + const repo = new RelationRepository(db); + for (const field of (await referenceFieldConstraints(db, collection)).values()) { + const groups = Object.hasOwn(staged, field.slug) + ? (staged[field.slug] ?? []) + : await liveFieldSelection(repo, field, entryGroup); + const valid = validateReferenceSelection(field, groups); + if (!valid.success) return valid; + } + return { success: true, data: true }; +} + +/** + * The live selection, in the same shape a draft stages: every bound reference + * field on the collection, by field slug, as translation groups. + * + * One link read per bound field. Used where both selections have to be + * comparable — the live and draft sides of a compare — never on a render path. + */ +export async function liveReferenceSelection( + db: Kysely<Database>, + collection: string, + entryGroup: string, +): Promise<StagedReferences> { + const repo = new RelationRepository(db); + const selection: StagedReferences = {}; + for (const field of (await referenceFieldConstraints(db, collection)).values()) { + selection[field.slug] = await liveFieldSelection(repo, field, entryGroup); + } + return selection; +} + +/** + * Promote a staged selection to live links. + * + * A field slug the collection no longer carries as a bound reference field is + * skipped: the revision outlived the field, and there is no relation left to + * write into. + */ +export async function applyStagedReferences( + db: Kysely<Database>, + collection: string, + entryGroup: string, + staged: StagedReferences, +): Promise<void> { + const constraints = await referenceFieldConstraints(db, collection); + for (const [fieldSlug, groups] of Object.entries(staged)) { + const field = constraints.get(fieldSlug); + if (!field) continue; + await writeReferenceSelection(db, { + relation: field.relation, + side: field.relationSide, + entryGroup, + groups, + }); + } +} diff --git a/packages/core/src/api/handlers/validate-references.ts b/packages/core/src/api/handlers/validate-references.ts new file mode 100644 index 0000000000..975f990b7a --- /dev/null +++ b/packages/core/src/api/handlers/validate-references.ts @@ -0,0 +1,139 @@ +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; +import { requestCached } from "../../request-cache.js"; +import type { ApiResult } from "../types.js"; + +export interface ReferenceFieldConstraints { + /** Field slug — how the entry API addresses the selection. */ + slug: string; + /** Relation slug the field binds to. */ + relation: string; + /** Which end of the relation the field's own collection sits on. */ + relationSide: "parent" | "child"; + /** How many entries this side of the relation may hold. `null` is unlimited. */ + maxSelected: number | null; + required: boolean; +} + +function validationError(message: string): ApiResult<never> { + return { success: false, error: { code: "VALIDATION_ERROR", message } }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +/** + * Every reference field on a collection that is bound to a relation, by field + * slug — the key the entry API takes a selection under. + */ +export function referenceFieldConstraints( + db: Kysely<Database>, + collection: string, +): Promise<Map<string, ReferenceFieldConstraints>> { + return requestCached(`reference-field-constraints:${collection}`, async () => { + const fields = await db + .selectFrom("_emdash_fields") + .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") + .select(["_emdash_fields.slug", "_emdash_fields.required", "_emdash_fields.validation"]) + .where("_emdash_collections.slug", "=", collection) + .where("_emdash_fields.type", "=", "reference") + .execute(); + + // Cardinality lives on the relation, not the field: with both ends of a + // relation bindable, two fields carrying their own limits could disagree + // about one edge set. The field contributes only which end it views. + const relations = await db + .selectFrom("_emdash_relations") + .select(["slug", "max_children_per_parent", "max_parents_per_child"]) + .execute(); + const limits = new Map(relations.map((r) => [r.slug, r])); + + const constraints = new Map<string, ReferenceFieldConstraints>(); + for (const field of fields) { + if (!field.validation) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(field.validation); + } catch { + continue; + } + if (!isRecord(parsed) || typeof parsed.relation !== "string") continue; + + const relation = limits.get(parsed.relation); + const relationSide = parsed.relationSide === "child" ? "child" : "parent"; + const maxSelected = + relationSide === "child" + ? (relation?.max_parents_per_child ?? null) + : (relation?.max_children_per_parent ?? null); + + constraints.set(field.slug, { + slug: field.slug, + relation: parsed.relation, + relationSide, + maxSelected, + required: field.required === 1, + }); + } + return constraints; + }); +} + +/** + * The field viewing one end of a relation, for the relation-scoped routes, which + * address a relation rather than a field. At most one field binds each + * (relation, side), so this is unambiguous. + */ +export function constraintsForRelationSide( + constraints: Map<string, ReferenceFieldConstraints>, + relation: string, + side: "parent" | "child", +): ReferenceFieldConstraints | undefined { + for (const field of constraints.values()) { + if (field.relation === relation && field.relationSide === side) return field; + } + return undefined; +} + +export function validateReferenceSelection( + constraints: ReferenceFieldConstraints, + childIds: string[], +): ApiResult<true> { + const max = constraints.maxSelected; + if (max !== null && childIds.length > max) { + return validationError( + max === 1 + ? `Field '${constraints.slug}' accepts a single reference, received ${childIds.length}.` + : `Field '${constraints.slug}' accepts at most ${max} references, received ${childIds.length}.`, + ); + } + if (constraints.required && childIds.length === 0) { + return validationError( + `Field '${constraints.slug}' is required and must reference at least one entry.`, + ); + } + return { success: true, data: true }; +} + +export async function validateRequiredReferencesPresent( + db: Kysely<Database>, + collection: string, + references: Record<string, string[]> | undefined, + translationOf: string | undefined, +): Promise<ApiResult<true>> { + // References belong to a translation group, so a new locale row inherits + // the source group's existing edges when its payload omits them. + if (translationOf) return { success: true, data: true }; + + const constraints = await referenceFieldConstraints(db, collection); + for (const field of constraints.values()) { + if (!field.required) continue; + if (references && Object.hasOwn(references, field.slug)) continue; + return validationError( + `Field '${field.slug}' is required and must reference at least one entry.`, + ); + } + return { success: true, data: true }; +} diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index 1abf8c217a..8573a1dd5a 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { SQL_BATCH_SIZE } from "../../utils/chunks.js"; import { bylineSummarySchema, bylineCreditSchema, contentBylineInputSchema } from "./bylines.js"; import { cursorPaginationQuery, httpUrl, localeCode } from "./common.js"; +import { referenceChildrenResponseSchema } from "./relations.js"; // --------------------------------------------------------------------------- // Content: Input schemas @@ -194,6 +195,10 @@ export const contentCreateBody = z description: "Taxonomy term assignments as { taxonomyName: [termSlug, ...] }, resolved in the entry's locale.", }), + references: z.record(z.string(), z.array(z.string()).max(1000)).optional().meta({ + description: + "Reference selections as { fieldSlug: [entryId, ...] }, in display order. Written as content-reference links in the same transaction as the entry. A field bound to the child end of its relation selects the entries pointing at this one, which carry no order.", + }), publishedAt: contentDateOverride, createdAt: contentDateOverride, }) @@ -217,6 +222,10 @@ export const contentUpdateBody = z description: "Replace taxonomy assignments as { taxonomyName: [termSlug, ...] }. Only named taxonomies are touched; pass an empty array to clear a taxonomy.", }), + references: z.record(z.string(), z.array(z.string()).max(1000)).optional().meta({ + description: + "Reference selections as { fieldSlug: [entryId, ...] }, in display order. Written as content-reference links in the same transaction as the entry. A field bound to the child end of its relation selects the entries pointing at this one, which carry no order.", + }), publishedAt: contentDateOverride, }) .meta({ id: "ContentUpdateBody" }); @@ -352,6 +361,10 @@ export const contentItemSchema = z locale: z.string().nullable(), translationGroup: z.string().nullable(), seo: contentSeoSchema.optional(), + // First page of each reference field's selection, keyed by field slug. Only + // present when the editor GET path opts into hydration + // (`referenceOptions`); omitted otherwise, so it's optional here. + references: z.record(z.string(), referenceChildrenResponseSchema).optional(), }) .meta({ id: "ContentItem" }); diff --git a/packages/core/src/api/schemas/relations.ts b/packages/core/src/api/schemas/relations.ts index 13a16948e6..4fafd60a03 100644 --- a/packages/core/src/api/schemas/relations.ts +++ b/packages/core/src/api/schemas/relations.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import { localeCode } from "./common.js"; - const slugPattern = /^[a-z][a-z0-9_]*$/; const collectionSlug = z .string() @@ -9,42 +7,50 @@ const collectionSlug = z .max(63) .regex(slugPattern, "Invalid collection slug format"); +/** NULL means unlimited on that side. */ +const roleLimit = z.number().int().positive().nullable().optional(); +const roleLabelSingular = z.string().min(1).max(200).nullable().optional(); + export const createRelationBody = z .object({ - name: z + slug: z .string() .min(1) .max(63) - .regex(slugPattern, "Name must be lowercase alphanumeric with underscores"), - parentCollection: collectionSlug.optional(), - childCollection: collectionSlug.optional(), + .regex(slugPattern, "Slug must be lowercase alphanumeric with underscores"), + parentCollection: collectionSlug, + childCollection: collectionSlug, parentLabel: z.string().min(1).max(200), + parentLabelSingular: roleLabelSingular, childLabel: z.string().min(1).max(200), - locale: localeCode.optional(), - translationOf: z.string().min(1).optional(), + childLabelSingular: roleLabelSingular, + maxChildrenPerParent: roleLimit, + maxParentsPerChild: roleLimit, }) - // A translation inherits its structural fields (name, parentCollection, - // childCollection) from the source relation, so the handler ignores any - // collections supplied alongside `translationOf`. Require them only when - // minting a base relation, so callers aren't forced to pass discarded values. - .refine( - (body) => - body.translationOf !== undefined || - (body.parentCollection !== undefined && body.childCollection !== undefined), - { message: "parentCollection and childCollection are required unless translationOf is set" }, - ) .meta({ id: "CreateRelationBody" }); +export const relationListQuery = z + .object({ + collection: collectionSlug.optional().meta({ + description: "Only relations with this collection on one end", + }), + }) + .meta({ id: "RelationListQuery" }); + export const updateRelationBody = z .object({ parentLabel: z.string().min(1).max(200).optional(), + parentLabelSingular: roleLabelSingular, childLabel: z.string().min(1).max(200).optional(), + childLabelSingular: roleLabelSingular, + maxChildrenPerParent: roleLimit, + maxParentsPerChild: roleLimit, }) // Reject empty payloads: an update touching no field is a client mistake, not // a successful no-op. Without this, `{}` validates and the handler returns 200 // with the unchanged row, so a typo'd payload looks like it landed. - .refine((body) => body.parentLabel !== undefined || body.childLabel !== undefined, { - message: "At least one of parentLabel or childLabel is required", + .refine((body) => Object.values(body).some((value) => value !== undefined), { + message: "At least one field is required", }) .meta({ id: "UpdateRelationBody" }); @@ -55,49 +61,57 @@ export const setReferenceChildrenBody = z export const relationDefSchema = z .object({ id: z.string(), - name: z.string(), + slug: z.string(), parentCollection: z.string(), childCollection: z.string(), parentLabel: z.string(), + parentLabelSingular: z.string().nullable(), childLabel: z.string(), - locale: z.string(), - translationGroup: z.string(), + childLabelSingular: z.string().nullable(), + maxChildrenPerParent: z.number().int().nullable(), + maxParentsPerChild: z.number().int().nullable(), }) .meta({ id: "RelationDef" }); +/** A relation plus what deleting it would take: the fields that view it and + * how many links it holds. */ +export const relationWithUsageSchema = relationDefSchema + .extend({ + boundFields: z.array( + z.object({ + collectionSlug: z.string(), + fieldSlug: z.string(), + side: z.enum(["parent", "child"]), + }), + ), + linkCount: z.number().int(), + }) + .meta({ id: "RelationWithUsage" }); + export const relationListResponseSchema = z - .object({ relations: z.array(relationDefSchema) }) + .object({ relations: z.array(relationWithUsageSchema) }) .meta({ id: "RelationListResponse" }); export const relationResponseSchema = z - .object({ relation: relationDefSchema }) + .object({ relation: relationWithUsageSchema }) .meta({ id: "RelationResponse" }); -export const relationTranslationsSchema = z - .object({ - translationGroup: z.string(), - translations: z.array( - z.object({ - id: z.string(), - name: z.string(), - locale: z.string(), - parentLabel: z.string(), - childLabel: z.string(), - }), - ), - }) - .meta({ id: "RelationTranslations" }); - export const entryRefSchema = z .object({ id: z.string(), slug: z.string().nullable(), collection: z.string(), + // Display label sourced from the entry's `title`, then `name`, field — + // `null` when neither is set. Mirrors the runtime `EntryRef`. + title: z.string().nullable(), // The actual locale of the resolved variant. When no variant matches the // requesting entry's locale, the ref falls back to another locale's row; // this field makes that substitution explicit instead of silently // presenting a wrong-locale entry under the requested context. locale: z.string().nullable(), + // The translation group the ref resolved from — the locale-stable identity + // of the referenced entry, which `id` is not. + translationGroup: z.string().nullable(), sortOrder: z.number().int().optional(), }) .meta({ id: "ReferenceEntryRef" }); diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index afc968fc45..9077662731 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -107,6 +107,20 @@ const fieldValidation = z .min(1, "allowedMimeTypes must not be empty — omit the field to allow all types") .max(64, "allowedMimeTypes may contain at most 64 entries") .optional(), + // Reference fields: the picker targets a collection and may allow more + // than one entry. Without these keys Zod strips them and the create + // handler rejects the field for a missing target collection. + targetCollection: z.string().min(1).optional(), + multiple: z.boolean().optional(), + // Reference fields: bind to an existing relation instead of creating one, + // and say which of its ends this collection sits on. + relation: z + .string() + .min(1) + .max(63) + .regex(slugPattern, "Invalid relation slug format") + .optional(), + relationSide: z.enum(["parent", "child"]).optional(), }) .superRefine((validation, ctx) => { for (const [minimum, maximum] of [ diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 2875b78eb9..611211449c 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -186,6 +186,17 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/content/[collection]/[id]/schedule.ts"), }); + // Reference field edge routes (children = parent side, parents = backlinks) + injectRoute({ + pattern: "/_emdash/api/content/[collection]/[id]/references/[relation]/children", + entrypoint: resolveRoute("api/content/[collection]/[id]/references/[relation]/children.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/content/[collection]/[id]/references/[relation]/parents", + entrypoint: resolveRoute("api/content/[collection]/[id]/references/[relation]/parents.ts"), + }); + // Revision management routes (for restore, etc.) injectRoute({ pattern: "/_emdash/api/revisions/[revisionId]", @@ -479,6 +490,17 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/content/[collection]/[id]/terms/[taxonomy].ts"), }); + // Relation definition routes (reference field relations) + injectRoute({ + pattern: "/_emdash/api/relations", + entrypoint: resolveRoute("api/relations/index.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/relations/[id]", + entrypoint: resolveRoute("api/relations/[id]/index.ts"), + }); + // Plugin management routes (under /admin to avoid conflict with plugin API routes) injectRoute({ pattern: "/_emdash/api/admin/plugins", diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id].ts b/packages/core/src/astro/routes/api/content/[collection]/[id].ts index a31713903d..96d46d5f8f 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id].ts @@ -28,7 +28,9 @@ export const GET: APIRoute = async ({ params, url, locals }) => { const id = params.id!; const locale = url.searchParams.get("locale") || undefined; - const result = await emdash.handleContentGet(collection, id, locale); + const result = await emdash.handleContentGet(collection, id, locale, { + includeDrafts: hasPermission(user, "content:read_drafts"), + }); // Hide non-published items from users without content:read_drafts. Return // 404 (not 403) so subscribers can't enumerate draft IDs by status code. diff --git a/packages/core/src/astro/routes/api/relations/[id]/translations.ts b/packages/core/src/astro/routes/api/relations/[id]/translations.ts deleted file mode 100644 index f7e22ba88b..0000000000 --- a/packages/core/src/astro/routes/api/relations/[id]/translations.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Relation translations endpoint - * - * GET /_emdash/api/relations/:id/translations - List locale siblings of a relation - */ - -import type { APIRoute } from "astro"; - -import { requirePerm } from "#api/authorize.js"; -import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js"; -import { handleRelationTranslations } from "#api/handlers/relations.js"; - -export const prerender = false; - -export const GET: APIRoute = async ({ params, locals }) => { - const { emdash, user } = locals; - const { id } = params; - - const dbErr = requireDb(emdash?.db); - if (dbErr) return dbErr; - - const denied = requirePerm(user, "schema:read"); - if (denied) return denied; - - if (!id) return apiError("VALIDATION_ERROR", "Relation id required", 400); - - try { - const result = await handleRelationTranslations(emdash.db, id); - return unwrapResult(result); - } catch (error) { - return handleError(error, "Failed to get translations", "RELATION_TRANSLATIONS_ERROR"); - } -}; diff --git a/packages/core/src/astro/routes/api/relations/index.ts b/packages/core/src/astro/routes/api/relations/index.ts index 3bc3a01cb2..f51e560cc4 100644 --- a/packages/core/src/astro/routes/api/relations/index.ts +++ b/packages/core/src/astro/routes/api/relations/index.ts @@ -1,7 +1,7 @@ /** * Relation definitions endpoint * - * GET /_emdash/api/relations[?locale=xx] - List relation definitions + * GET /_emdash/api/relations[?collection=xx] - List relation definitions * POST /_emdash/api/relations - Create a relation definition */ @@ -11,7 +11,7 @@ import { requirePerm } from "#api/authorize.js"; import { handleError, requireDb, unwrapResult } from "#api/error.js"; import { handleRelationCreate, handleRelationList } from "#api/handlers/relations.js"; import { isParseError, parseBody, parseQuery } from "#api/parse.js"; -import { createRelationBody, localeFilterQuery } from "#api/schemas.js"; +import { createRelationBody, relationListQuery } from "#api/schemas.js"; export const prerender = false; @@ -24,11 +24,11 @@ export const GET: APIRoute = async ({ request, locals }) => { const denied = requirePerm(user, "schema:read"); if (denied) return denied; - const query = parseQuery(new URL(request.url), localeFilterQuery); + const query = parseQuery(new URL(request.url), relationListQuery); if (isParseError(query)) return query; try { - const result = await handleRelationList(emdash.db, { locale: query.locale }); + const result = await handleRelationList(emdash.db, { collection: query.collection }); return unwrapResult(result); } catch (error) { return handleError(error, "Failed to list relations", "RELATION_LIST_ERROR"); diff --git a/packages/core/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts b/packages/core/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts index 8f13b8aeef..73dec2f283 100644 --- a/packages/core/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +++ b/packages/core/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts @@ -54,7 +54,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => { return unwrapResult(result); }; -export const DELETE: APIRoute = async ({ params, locals }) => { +export const DELETE: APIRoute = async ({ params, url, locals }) => { const { emdash, user } = locals; const collectionSlug = params.slug!; const fieldSlug = params.fieldSlug!; @@ -65,6 +65,10 @@ export const DELETE: APIRoute = async ({ params, locals }) => { const denied = requirePerm(user, "schema:manage"); if (denied) return denied; - const result = await handleSchemaFieldDelete(emdash.db, collectionSlug, fieldSlug); + // DELETE carries no body, so the cascade opt-in rides on the query string, + // matching `?force=true` on collection delete. + const result = await handleSchemaFieldDelete(emdash.db, collectionSlug, fieldSlug, { + deleteRelation: url.searchParams.get("deleteRelation") === "true", + }); return unwrapResult(result); }; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index d2154c9eec..d5b38d30a8 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -274,6 +274,7 @@ export interface EmDashHandlers { collection: string, id: string, locale?: string, + referenceOptions?: { includeDrafts: boolean }, ) => Promise< HandlerResponse<{ item: { @@ -296,6 +297,7 @@ export interface EmDashHandlers { locale?: string; translationOf?: string; taxonomies?: Record<string, string[]>; + references?: Record<string, string[]>; createdAt?: string | null; publishedAt?: string | null; }, @@ -319,6 +321,7 @@ export interface EmDashHandlers { noIndex?: boolean; }; taxonomies?: Record<string, string[]>; + references?: Record<string, string[]>; publishedAt?: string | null; _rev?: string; }, diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 5457583492..f1ec1a6a3a 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -27,6 +27,7 @@ import type { SeedFile, SeedCollection, SeedField, + SeedRelation, SeedTaxonomy, SeedTaxonomyTerm, SeedMenu, @@ -124,6 +125,13 @@ export async function exportSeed(db: Kysely<Database>, withContent?: string): Pr // 2. Export collections and fields seed.collections = await exportCollections(db); + // 3. Export the relations reference fields bind to. Emitted even when no + // field binds one: a relation outlives the fields that viewed it. + const relations = await exportRelations(db); + if (relations.length > 0) { + seed.relations = relations; + } + // Decide locale-awareness from the data. The runtime sets the i18n config via // middleware, but the CLI never does, so `isI18nEnabled()` is always false // under `emdash export-seed` (#1330). Detecting multiple locales in the data @@ -135,23 +143,23 @@ export async function exportSeed(db: Kysely<Database>, withContent?: string): Pr // otherwise backfill omitted locales as `en` (#1421). if (defaultLocale) seed.defaultLocale = defaultLocale; - // 3. Export taxonomy definitions and terms + // 4. Export taxonomy definitions and terms seed.taxonomies = await exportTaxonomies(db, i18nEnabled); - // 4. Export menus + // 5. Export menus seed.menus = await exportMenus(db, i18nEnabled); - // 5. Export widget areas + // 6. Export widget areas seed.widgetAreas = await exportWidgetAreas(db); - // 6. Export byline profiles. The returned map (translation_group -> seed-local + // 7. Export byline profiles. The returned map (translation_group -> seed-local // id) lets content credits below reference the same ids the root list emits. const { bylines, groupToSeedId } = await exportBylines(db); if (bylines.length > 0) { seed.bylines = bylines; } - // 7. Export content (if requested) + // 8. Export content (if requested) if (withContent !== undefined) { // Treat "all" as a synonym for the bare flag and "true". The args help // text documents `all` as a valid value, but without this the literal @@ -344,6 +352,43 @@ async function exportCollections(db: Kysely<Database>): Promise<SeedCollection[] return result; } +/** + * Export relations as root-level `relations[]`. + * + * A reference field's `validation.relation` names a relation by slug, and the + * field's validation is exported verbatim, so re-applying the seed binds the + * field to this relation rather than creating another one. + */ +async function exportRelations(db: Kysely<Database>): Promise<SeedRelation[]> { + const rows = await db + .selectFrom("_emdash_relations") + .select([ + "slug", + "parent_collection", + "child_collection", + "parent_label", + "parent_label_singular", + "child_label", + "child_label_singular", + "max_children_per_parent", + "max_parents_per_child", + ]) + .orderBy("slug", "asc") + .execute(); + + return rows.map((row) => ({ + slug: row.slug, + parentCollection: row.parent_collection, + childCollection: row.child_collection, + parentLabel: row.parent_label, + parentLabelSingular: row.parent_label_singular ?? undefined, + childLabel: row.child_label, + childLabelSingular: row.child_label_singular ?? undefined, + maxChildrenPerParent: row.max_children_per_parent, + maxParentsPerChild: row.max_parents_per_child, + })); +} + /** * Export taxonomy definitions and terms */ @@ -716,6 +761,14 @@ async function exportContent( // Media table might not exist or be empty } + // Seed-local id by translation group and by entry id, across every exported + // collection. Filled as entries are emitted and read afterwards to turn + // reference values into `$ref:`, which may point at a collection exported + // later. Links are keyed by group; a column holding one entry id is not. + const groupToSeedId = new Map<string, string>(); + const entryIdToSeedId = new Map<string, string>(); + const exported: ExportedEntry[] = []; + for (const collection of collections) { // Skip if not in include list if (includeCollections && !includeCollections.includes(collection.slug)) { @@ -787,6 +840,17 @@ async function exportContent( entry.bylines = bylines; } + entryIdToSeedId.set(item.id, seedId); + if (item.translationGroup && !groupToSeedId.has(item.translationGroup)) { + groupToSeedId.set(item.translationGroup, seedId); + } + exported.push({ + collection, + entry, + translationGroup: item.translationGroup ?? null, + referenceValues: item.data, + }); + entries.push(entry); } @@ -808,9 +872,105 @@ async function exportContent( } } + await addReferenceLinks(db, exported, groupToSeedId, entryIdToSeedId); + return content; } +interface ExportedEntry { + collection: SeedCollection; + entry: SeedContentEntry; + translationGroup: string | null; + /** The entry's stored `data`, which a reference field's column value is read from. */ + referenceValues: Record<string, unknown>; +} + +/** + * Write each entry's reference selection into its `data` as `$ref:` values, which + * the seed's own content ids resolve on apply. + * + * A field bound to a relation takes its selection from the link table. Only the + * parent side is emitted: both sides view one link set, so a child-side field + * would restate links the parent side already carries, and `setReferenceChildren` + * accepts them only from the parent. A field with no relation takes the entry id + * in its column instead. + * + * A value naming an entry that was not exported is dropped — nothing in the seed + * would resolve it — which is why this runs after every collection has been + * emitted and its ids are known. + */ +async function addReferenceLinks( + db: Kysely<Database>, + exported: ExportedEntry[], + groupToSeedId: Map<string, string>, + entryIdToSeedId: Map<string, string>, +): Promise<void> { + if (exported.length === 0) return; + + const relations = new Map( + ( + await db + .selectFrom("_emdash_relations") + .select(["id", "slug", "max_children_per_parent"]) + .execute() + ).map((row) => [row.slug, row]), + ); + + // Relation id -> parent group -> ordered child groups. + const linksByRelation = new Map<string, Map<string, string[]>>(); + if (relations.size > 0) { + const edges = await db + .selectFrom("_emdash_content_references") + .select(["relation_id", "parent_group", "child_group"]) + .orderBy("sort_order", "asc") + .execute(); + for (const edge of edges) { + const byParent = linksByRelation.get(edge.relation_id) ?? new Map<string, string[]>(); + const children = byParent.get(edge.parent_group) ?? []; + children.push(edge.child_group); + byParent.set(edge.parent_group, children); + linksByRelation.set(edge.relation_id, byParent); + } + } + + for (const { collection, entry, translationGroup, referenceValues } of exported) { + for (const field of collection.fields) { + if (field.type !== "reference") continue; + const slug = field.validation?.relation; + + if (typeof slug !== "string") { + // No relation: the field keeps its own column, holding one entry id or + // a JSON array of them. + const stored = referenceValues[field.slug]; + const ids = (Array.isArray(stored) ? stored : [stored]).filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); + const refs = ids.map((id) => { + const seedId = entryIdToSeedId.get(id); + return seedId ? `$ref:${seedId}` : id; + }); + if (refs.length === 0) continue; + entry.data[field.slug] = Array.isArray(stored) ? refs : refs[0]; + continue; + } + + if (field.validation?.relationSide === "child") continue; + + const relation = relations.get(slug); + if (!relation || !translationGroup) continue; + + const childGroups = linksByRelation.get(relation.id)?.get(translationGroup) ?? []; + const refs = childGroups + .map((group) => groupToSeedId.get(group)) + .filter((seedId): seedId is string => seedId !== undefined) + .map((seedId) => `$ref:${seedId}`); + if (refs.length === 0) continue; + + entry.data[field.slug] = relation.max_children_per_parent === 1 ? refs[0] : refs; + } + } +} + /** * Process content data for export, converting image fields to $media syntax */ @@ -849,17 +1009,11 @@ function processDataForExport( } // Fallback: keep as-is if no media info found result[key] = value; - } else if (fieldType === "reference" && typeof value === "string") { - // Convert reference to $ref syntax (assumes same collection for now) - result[key] = `$ref:${value}`; - } else if (Array.isArray(value)) { - // Process arrays (could contain references or images) - result[key] = value.map((item) => { - if (typeof item === "string" && fieldType === "reference") { - return `$ref:${item}`; - } - return item; - }); + } else if (fieldType === "reference") { + // Left for `addReferenceLinks`, which knows the seed id each entry was + // emitted under. A `$ref:` built here from a raw entry id resolves + // against nothing on apply. + continue; } else { result[key] = value; } diff --git a/packages/core/src/database/migrations/076_relations_structural.ts b/packages/core/src/database/migrations/076_relations_structural.ts new file mode 100644 index 0000000000..3ea55318e2 --- /dev/null +++ b/packages/core/src/database/migrations/076_relations_structural.ts @@ -0,0 +1,207 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +import { columnExists, currentTimestamp, tableExists } from "../dialect-helpers.js"; + +/** + * Relations join the schema side of the i18n line. + * + * Migration 043 modelled a relation row-per-locale, mirroring + * `_emdash_taxonomy_defs`. A relation is schema, not content: it belongs with + * `_emdash_collections` and `_emdash_fields`, neither of which carries a locale + * — their labels are single-valued, and migration 036 localized menus and + * taxonomies while deliberately leaving both alone. + * + * Being row-per-locale bought nothing (no surface ever exposed a relation's + * locale or labels) and cost the ability to state the one invariant that + * matters: a slug identifies exactly one relation. `UNIQUE(name, locale)` let + * two unrelated relations share a slug in different locales, so a slug could + * not be resolved without a locale in hand — which is what a reference field + * addressing a relation by slug needs to do from any locale. + * + * `id` is preserved from the old `translation_group`, so values already stored + * in `_emdash_content_references` stay valid and that column is renamed rather + * than remapped. + * + * Role cardinality and the singular label forms arrive here too, rather than in + * a follow-up: the table is being rebuilt anyway, and a reference field binds + * to a relation *and a side*, so "how many may this side hold" is a property of + * the relation rather than of whichever field exposes it. + * + * Migration 043 is not re-runnable once this has applied — it indexes `locale` + * and `translation_group`, which no longer exist. Kysely never re-runs a + * recorded migration, so this only affects the replay window in + * `migrations.test.ts`, which starts after 043 for that reason. + */ + +interface OldRelationRow { + id: string; + name: string; + parent_collection: string; + child_collection: string; + parent_label: string; + child_label: string; + locale: string; + translation_group: string; +} + +interface CollapsedRelation { + id: string; + slug: string; + parent_collection: string; + child_collection: string; + parent_label: string; + child_label: string; +} + +/** + * Collapse the per-locale rows to one row per relation, with a slug unique + * across all of them. + * + * `UNIQUE(name, locale)` allowed two groups to share a name, so the collapse + * can collide; a colliding relation takes a numeric suffix rather than failing + * the migration. Groups are processed in a fixed order so every replica lands + * on the same result. + */ +function collapseGroups(rows: OldRelationRow[]): CollapsedRelation[] { + const byGroup = new Map<string, OldRelationRow[]>(); + for (const row of rows) { + const group = byGroup.get(row.translation_group); + if (group) group.push(row); + else byGroup.set(row.translation_group, [row]); + } + + const collapsed: CollapsedRelation[] = []; + const takenSlugs = new Set<string>(); + + // Fixed order so every replica lands on the same slug suffixes. + for (const translationGroup of [...byGroup.keys()].toSorted()) { + const groupRows = byGroup.get(translationGroup) ?? []; + // Structural fields are identical across a group by construction; the + // labels are not, so the lowest locale code wins — deterministic, and it + // keeps the default locale's wording on a two-locale site. + const canonical = groupRows.toSorted((a, b) => (a.locale < b.locale ? -1 : 1))[0]; + if (!canonical) continue; + + let slug = canonical.name; + for (let suffix = 2; takenSlugs.has(slug); suffix++) { + slug = `${canonical.name.slice(0, 63 - String(suffix).length - 1)}_${suffix}`; + } + takenSlugs.add(slug); + + collapsed.push({ + id: translationGroup, + slug, + parent_collection: canonical.parent_collection, + child_collection: canonical.child_collection, + parent_label: canonical.parent_label, + child_label: canonical.child_label, + }); + } + + return collapsed; +} + +async function createRelationsTable(db: Kysely<unknown>, name: string): Promise<void> { + await db.schema + .createTable(name) + .addColumn("id", "text", (c) => c.primaryKey()) + .addColumn("slug", "text", (c) => c.notNull()) + .addColumn("parent_collection", "text", (c) => c.notNull()) + .addColumn("child_collection", "text", (c) => c.notNull()) + .addColumn("parent_label", "text", (c) => c.notNull()) + .addColumn("child_label", "text", (c) => c.notNull()) + .addColumn("parent_label_singular", "text") + .addColumn("child_label_singular", "text") + // NULL means unlimited on that side. + .addColumn("max_children_per_parent", "integer") + .addColumn("max_parents_per_child", "integer") + .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db))) + .addColumn("updated_at", "text", (c) => c.defaultTo(currentTimestamp(db))) + .addUniqueConstraint("_emdash_relations_slug_unique", ["slug"]) + .execute(); +} + +async function createRelationIndexes(db: Kysely<unknown>): Promise<void> { + await db.schema + .createIndex("idx__emdash_relations_parent_collection") + .ifNotExists() + .on("_emdash_relations") + .column("parent_collection") + .execute(); + await db.schema + .createIndex("idx__emdash_relations_child_collection") + .ifNotExists() + .on("_emdash_relations") + .column("child_collection") + .execute(); +} + +/** + * Rename the edge table's relation column. The column holds a relation id now, + * not a translation group. Values are unchanged — `id` was preserved from the + * group — so this is a rename, not a remap. SQLite and Postgres both carry + * indexes and constraints across `RENAME COLUMN`. + */ +async function renameEdgeRelationColumn(db: Kysely<unknown>): Promise<void> { + if (!(await columnExists(db, "_emdash_content_references", "relation_group"))) return; + await sql + .raw(`ALTER TABLE "_emdash_content_references" RENAME COLUMN "relation_group" TO "relation_id"`) + .execute(db); +} + +export async function up(db: Kysely<unknown>): Promise<void> { + // The rebuild drops the old table before renaming the new one into place, so + // a run interrupted between those two statements leaves the data in + // `_emdash_relations_new` and no `_emdash_relations` at all. Finish that + // rename before anything else looks for the old table. + if ( + (await tableExists(db, "_emdash_relations_new")) && + !(await tableExists(db, "_emdash_relations")) + ) { + await sql.raw(`ALTER TABLE "_emdash_relations_new" RENAME TO "_emdash_relations"`).execute(db); + } + + if (await columnExists(db, "_emdash_relations", "slug")) { + // The table is already rebuilt. The edge-column rename is the last + // statement and has its own guard, so a run interrupted between the two + // still completes here rather than leaving `relation_group` behind + // forever. + await createRelationIndexes(db); + await renameEdgeRelationColumn(db); + return; + } + + const existing = await sql<OldRelationRow>` + SELECT id, name, parent_collection, child_collection, + parent_label, child_label, locale, translation_group + FROM ${sql.ref("_emdash_relations")} + `.execute(db); + const collapsed = collapseGroups(existing.rows); + + await sql.raw(`DROP TABLE IF EXISTS "_emdash_relations_new"`).execute(db); + await createRelationsTable(db, "_emdash_relations_new"); + + for (const row of collapsed) { + // oxlint-disable-next-line no-await-in-loop -- one statement per relation; the table holds a handful of rows + await sql` + INSERT INTO ${sql.ref("_emdash_relations_new")} + (id, slug, parent_collection, child_collection, parent_label, child_label) + VALUES (${row.id}, ${row.slug}, ${row.parent_collection}, ${row.child_collection}, + ${row.parent_label}, ${row.child_label}) + `.execute(db); + } + + await db.schema.dropTable("_emdash_relations").execute(); + await sql.raw(`ALTER TABLE "_emdash_relations_new" RENAME TO "_emdash_relations"`).execute(db); + + await createRelationIndexes(db); + await renameEdgeRelationColumn(db); +} + +export async function down(_db: Kysely<unknown>): Promise<void> { + // no-op: the collapse is not reversible. Each relation's per-locale rows were + // merged into one, so the locale variants they carried no longer exist to be + // restored — rebuilding the old shape would fabricate a single locale's + // labels for every language. +} diff --git a/packages/core/src/database/migrations/077_reference_field_relations.ts b/packages/core/src/database/migrations/077_reference_field_relations.ts new file mode 100644 index 0000000000..fe110c7ec2 --- /dev/null +++ b/packages/core/src/database/migrations/077_reference_field_relations.ts @@ -0,0 +1,326 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; +import { ulid } from "ulidx"; + +import { currentTimestampValue, tableExists } from "../dialect-helpers.js"; +import { validateIdentifier } from "../validate.js"; + +/** + * Give a reference field created before relations existed the relation it needs + * to work as a picker. + * + * Such a field names its target in `options.collection` and keeps a TEXT column + * holding either one entry id or a JSON array of them. A field with no + * `validation.relation` still reads and writes that column, so this migration is + * an upgrade rather than a repair: it creates one relation per field, copies the + * column's ids in as edges, and records the relation on the field row. + * + * The column is left in place. Nothing reads it once the field is bound, but + * dropping it would discard the only copy of any value whose target entry could + * not be resolved. + * + * Writing `validation.relation` is the completion fence: a rerun sees it and + * skips the field, so a lost D1 response cannot wire a field twice. Relation + * creation and the edge copy are each idempotent on their own — the relation's + * slug is derived from the field, so a rerun finds it by name before inserting, + * and the edge table's unique constraint absorbs a repeated copy. + * + * A field the migration skips keeps behaving exactly as it did; an editor can + * bind it later by setting a target collection in the schema editor. + * + * The edge copy below is duplicated from `backfillReferenceEdges`, which the + * schema handler runs for that manual binding, and it batches with a local + * constant rather than `SQL_BATCH_SIZE`. A migration reaches only for modules + * nothing outside the runner imports: the bundler otherwise puts a shared module + * in a chunk that cycles with the runner's, and the built package throws on + * import. A shipped migration has to keep batching the way it did anyway. + */ + +/** Ids per statement while resolving children, within D1's 100-parameter ceiling. */ +const ID_BATCH_SIZE = 50; + +/** Parsed `_emdash_fields` row for a reference field with no relation. */ +interface LegacyReferenceField { + fieldId: string; + collectionSlug: string; + collectionLabel: string; + collectionLabelSingular: string | null; + fieldSlug: string; + fieldLabel: string; + validation: Record<string, unknown>; + targetCollection: string; + allowMultiple: boolean; +} + +interface FieldRow { + field_id: string; + field_slug: string; + field_label: string; + validation: string | null; + options: string | null; + indexed: number | null; + searchable: number | null; + collection_slug: string; + collection_label: string; + collection_label_singular: string | null; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +function parseJsonObject(value: string | null): Record<string, unknown> { + if (!value) return {}; + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function readString(source: Record<string, unknown>, key: string): string | undefined { + const value = source[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * The ids one legacy column value holds: a JSON array for a multiple-reference + * field (`serializeValue` stringifies it), or the id itself. + */ +function parseColumnIds(value: unknown): string[] { + if (typeof value !== "string" || value.length === 0) return []; + if (!value.startsWith("[")) return [value]; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return [value]; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +/** + * Copy one field's column values in as edges. + * + * Both ends of an edge are translation groups, so the locale siblings of one + * entry contribute to the same parent group. They are read in a fixed order and + * their ids deduped, and the result is capped at the relation's limit — a + * single-reference field whose locale rows point at different entries keeps the + * first rather than storing a selection the relation forbids. + */ +async function backfillEdges( + db: Kysely<unknown>, + field: LegacyReferenceField, + relationId: string, + maxChildren: number | null, +): Promise<void> { + const parentTable = `ec_${field.collectionSlug}`; + const childTable = `ec_${field.targetCollection}`; + validateIdentifier(parentTable, "content table name"); + validateIdentifier(childTable, "content table name"); + validateIdentifier(field.fieldSlug, "content field name"); + + if (!(await tableExists(db, parentTable)) || !(await tableExists(db, childTable))) return; + + const entries = await sql<{ translation_group: string | null; value: unknown }>` + SELECT translation_group, ${sql.ref(field.fieldSlug)} AS value + FROM ${sql.ref(parentTable)} + WHERE ${sql.ref(field.fieldSlug)} IS NOT NULL + ORDER BY locale, id + `.execute(db); + + // Parent group -> the child entry ids it selects, in order, deduped. + const selections = new Map<string, string[]>(); + for (const entry of entries.rows) { + if (!entry.translation_group) continue; + const existing = selections.get(entry.translation_group) ?? []; + for (const id of parseColumnIds(entry.value)) { + if (!existing.includes(id)) existing.push(id); + } + selections.set(entry.translation_group, existing); + } + if (selections.size === 0) return; + + const childIds = [...new Set([...selections.values()].flat())]; + const childGroups = new Map<string, string>(); + for (let offset = 0; offset < childIds.length; offset += ID_BATCH_SIZE) { + const batch = childIds.slice(offset, offset + ID_BATCH_SIZE); + // oxlint-disable-next-line no-await-in-loop -- one statement per bind-parameter batch + const resolved = await sql<{ id: string; translation_group: string | null }>` + SELECT id, translation_group + FROM ${sql.ref(childTable)} + WHERE id IN (${sql.join(batch)}) + `.execute(db); + for (const row of resolved.rows) { + if (row.translation_group) childGroups.set(row.id, row.translation_group); + } + } + + const now = currentTimestampValue(db); + for (const parentGroup of [...selections.keys()].toSorted()) { + const groups: string[] = []; + for (const id of selections.get(parentGroup) ?? []) { + const group = childGroups.get(id); + // An id whose entry is gone is dropped; its value stays in the column. + if (group && !groups.includes(group)) groups.push(group); + } + const selected = maxChildren === null ? groups : groups.slice(0, maxChildren); + + for (const [sortOrder, childGroup] of selected.entries()) { + // oxlint-disable-next-line no-await-in-loop -- one insert per edge; the unique constraint makes a rerun a no-op + await sql` + INSERT INTO ${sql.ref("_emdash_content_references")} + (id, relation_id, parent_group, child_group, sort_order, created_at) + VALUES (${ulid()}, ${relationId}, ${parentGroup}, ${childGroup}, ${sortOrder}, ${now}) + ON CONFLICT DO NOTHING + `.execute(db); + } + } +} + +/** + * Which reference fields to bind, and to what. + * + * A field is skipped when its target cannot be resolved to an existing + * collection, and when it is `indexed` or `searchable`. Both flags mean the site + * queries the column through an index — a content-list field filter, or the FTS + * table — and binding the field freezes that column, so the migration leaves + * those fields alone rather than changing the results of a query the site + * already runs. + */ +async function findLegacyReferenceFields(db: Kysely<unknown>): Promise<{ + fields: LegacyReferenceField[]; + /** Relation slugs some reference field already binds, which none of these may take. */ + boundSlugs: Set<string>; +}> { + const fields = await sql<FieldRow>` + SELECT f.id AS field_id, f.slug AS field_slug, f.label AS field_label, + f.validation, f.options, f.indexed, f.searchable, + c.slug AS collection_slug, c.label AS collection_label, + c.label_singular AS collection_label_singular + FROM ${sql.ref("_emdash_fields")} AS f + INNER JOIN ${sql.ref("_emdash_collections")} AS c ON c.id = f.collection_id + WHERE f.type = 'reference' + ORDER BY c.slug, f.slug + `.execute(db); + + const collections = await sql<{ slug: string }>` + SELECT slug FROM ${sql.ref("_emdash_collections")} + `.execute(db); + const known = new Set(collections.rows.map((row) => row.slug)); + + const legacy: LegacyReferenceField[] = []; + const boundSlugs = new Set<string>(); + for (const row of fields.rows) { + const validation = parseJsonObject(row.validation); + const boundTo = readString(validation, "relation"); + if (boundTo) { + boundSlugs.add(boundTo); + continue; + } + if (row.indexed === 1 || row.searchable === 1) continue; + + const options = parseJsonObject(row.options); + const targetCollection = + readString(options, "collection") ?? + readString(validation, "targetCollection") ?? + readString(validation, "collection"); + if (!targetCollection || !known.has(targetCollection)) continue; + + legacy.push({ + fieldId: row.field_id, + collectionSlug: row.collection_slug, + collectionLabel: row.collection_label, + collectionLabelSingular: row.collection_label_singular, + fieldSlug: row.field_slug, + fieldLabel: row.field_label, + validation, + targetCollection, + allowMultiple: options.allowMultiple === true, + }); + } + return { fields: legacy, boundSlugs }; +} + +export async function up(db: Kysely<unknown>): Promise<void> { + const { fields, boundSlugs } = await findLegacyReferenceFields(db); + + for (const field of fields) { + const maxChildren = field.allowMultiple ? null : 1; + const slug = `${field.collectionSlug}_${field.fieldSlug}`.slice(0, 63); + + // The slug is `{collection}_{field}` with no collision suffix, so a rerun + // knows the relation it would have created by name. `createFieldRelation` + // suffixes on collision, but a suffix picked from whatever slugs were free + // at the time is not something a restart can recompute: a lost response + // after the insert would leave the suffixed relation behind and allocate + // the next one. A field whose slug is taken by a relation of some other + // shape is left unbound instead, and an editor can bind it by hand. + // oxlint-disable-next-line no-await-in-loop -- each field's relation must exist before its edges + const existing = await sql<{ + id: string; + parent_collection: string; + child_collection: string; + max_children_per_parent: number | null; + }>` + SELECT id, parent_collection, child_collection, max_children_per_parent + FROM ${sql.ref("_emdash_relations")} + WHERE slug = ${slug} + `.execute(db); + + let relationId: string; + const claimed = existing.rows[0]; + if (claimed) { + const matchesField = + claimed.parent_collection === field.collectionSlug && + claimed.child_collection === field.targetCollection && + claimed.max_children_per_parent === maxChildren && + !boundSlugs.has(slug); + if (!matchesField) continue; + relationId = claimed.id; + } else { + relationId = ulid(); + // oxlint-disable-next-line no-await-in-loop -- one relation per field + await sql` + INSERT INTO ${sql.ref("_emdash_relations")} + (id, slug, parent_collection, child_collection, parent_label, parent_label_singular, + child_label, max_children_per_parent, created_at, updated_at) + VALUES (${relationId}, ${slug}, ${field.collectionSlug}, ${field.targetCollection}, + ${field.collectionLabel}, ${field.collectionLabelSingular}, + ${field.fieldLabel}, ${maxChildren}, + ${currentTimestampValue(db)}, ${currentTimestampValue(db)}) + `.execute(db); + } + + // oxlint-disable-next-line no-await-in-loop -- edges depend on the relation above + await backfillEdges(db, field, relationId, maxChildren); + + const validation = { + ...field.validation, + relation: slug, + relationSide: "parent", + targetCollection: field.targetCollection, + multiple: field.allowMultiple, + }; + // Last, and alone: this is what a rerun reads to skip the field. + // oxlint-disable-next-line no-await-in-loop -- one field at a time so a partial run stays restartable + await sql` + UPDATE ${sql.ref("_emdash_fields")} + SET validation = ${JSON.stringify(validation)} + WHERE id = ${field.fieldId} + `.execute(db); + + // Two long field slugs on one collection can truncate to the same relation + // slug; without this the second would bind to the first's relation and + // their selections would merge. + boundSlugs.add(slug); + } +} + +export async function down(_db: Kysely<unknown>): Promise<void> { + // no-op: the relations and edges this created are indistinguishable from ones + // an editor made afterwards, and the columns it read were left in place, so + // there is nothing to restore and nothing safe to remove. +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 2e7eae2d1d..56341366b0 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -78,6 +78,8 @@ import * as m072 from "./072_media_folders.js"; import * as m073 from "./073_media_focal_point.js"; import * as m074 from "./074_content_deleted_scheduled_index.js"; import * as m075 from "./075_entry_edit_locks.js"; +import * as m076 from "./076_relations_structural.js"; +import * as m077 from "./077_reference_field_relations.js"; const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({ "001_initial": m001, @@ -154,6 +156,8 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({ "073_media_focal_point": m073, "074_content_deleted_scheduled_index": m074, "075_entry_edit_locks": m075, + "076_relations_structural": m076, + "077_reference_field_relations": m077, }); /** Ordered names from the statically registered migration set. */ diff --git a/packages/core/src/database/reference-backfill.ts b/packages/core/src/database/reference-backfill.ts new file mode 100644 index 0000000000..dd70dfdfed --- /dev/null +++ b/packages/core/src/database/reference-backfill.ts @@ -0,0 +1,141 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; +import { ulid } from "ulidx"; + +import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js"; +import { currentTimestampValue, tableExists } from "./dialect-helpers.js"; +import { REFERENCE_INSERT_BATCH_SIZE } from "./repositories/relation.js"; +import { validateIdentifier } from "./validate.js"; + +/** What to copy, and which relation to copy it into. */ +export interface ReferenceBackfill { + /** Collection owning the field, the relation's parent end. */ + parentCollection: string; + /** The relation's child end, where the column's ids resolve. */ + childCollection: string; + fieldSlug: string; + relationId: string; + /** The relation's limit for this side. `null` is unlimited. */ + maxChildren: number | null; +} + +/** The ids one column value holds. */ +function parseColumnIds(value: unknown): string[] { + if (typeof value !== "string" || value.length === 0) return []; + if (!value.startsWith("[")) return [value]; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return [value]; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +/** + * Copy a reference field's column values in as relation edges. + * + * A reference field created before relations existed holds its selection in a + * TEXT column: one entry id, or a JSON array of them for a multiple-reference + * field (`serializeValue` stringifies the array). Binding the field to a relation + * moves that selection into `_emdash_content_references`, and this is the copy. + * + * The column is not touched. On a site that predates pickers it was a free-text + * box, so it can hold anything an editor typed, and only the ids that resolve to + * an entry become edges — clearing it would destroy the rest. Nothing writes to + * it once the field is bound and typegen stops declaring the key, but a content + * read still reports the frozen value in `data` beside the live `references`. + * + * Both ends of an edge are translation groups, so the locale siblings of one + * entry contribute to the same parent group. They are read in a fixed order and + * their ids deduped, and the result is capped at the relation's limit — a + * single-reference field whose locale rows point at different entries keeps the + * first rather than storing a selection the relation forbids. + * + * Every insert is `ON CONFLICT DO NOTHING` against the edge table's unique + * constraint, so running this twice adds nothing the first run already wrote. + * + * Migration 077 carries its own copy of this for the upgrade path. A migration + * must not share a module with the handler layer — the bundler then puts the two + * in chunks that cycle through the migration runner, and the built package throws + * on import — and a shipped migration has to keep behaving the way it did anyway. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- runs against a migration's untyped Kysely as well as the app's +export async function backfillReferenceEdges( + db: Kysely<any>, + backfill: ReferenceBackfill, +): Promise<void> { + const parentTable = `ec_${backfill.parentCollection}`; + const childTable = `ec_${backfill.childCollection}`; + validateIdentifier(parentTable, "content table name"); + validateIdentifier(childTable, "content table name"); + validateIdentifier(backfill.fieldSlug, "content field name"); + + if (!(await tableExists(db, parentTable)) || !(await tableExists(db, childTable))) return; + + const entries = await sql<{ translation_group: string | null; value: unknown }>` + SELECT translation_group, ${sql.ref(backfill.fieldSlug)} AS value + FROM ${sql.ref(parentTable)} + WHERE ${sql.ref(backfill.fieldSlug)} IS NOT NULL + ORDER BY locale, id + `.execute(db); + + // Parent group -> the child entry ids it selects, in order, deduped. + const selections = new Map<string, string[]>(); + for (const entry of entries.rows) { + if (!entry.translation_group) continue; + const existing = selections.get(entry.translation_group) ?? []; + for (const id of parseColumnIds(entry.value)) { + if (!existing.includes(id)) existing.push(id); + } + selections.set(entry.translation_group, existing); + } + if (selections.size === 0) return; + + const childIds = [...new Set([...selections.values()].flat())]; + const childGroups = new Map<string, string>(); + for (const batch of chunks(childIds, SQL_BATCH_SIZE)) { + // oxlint-disable-next-line no-await-in-loop -- one statement per bind-parameter batch + const resolved = await sql<{ id: string; translation_group: string | null }>` + SELECT id, translation_group + FROM ${sql.ref(childTable)} + WHERE id IN (${sql.join(batch)}) + `.execute(db); + for (const row of resolved.rows) { + if (row.translation_group) childGroups.set(row.id, row.translation_group); + } + } + + const now = currentTimestampValue(db); + const rows: Array<{ parentGroup: string; childGroup: string; sortOrder: number }> = []; + for (const parentGroup of [...selections.keys()].toSorted()) { + const groups: string[] = []; + for (const id of selections.get(parentGroup) ?? []) { + const group = childGroups.get(id); + // An id whose entry is gone is dropped; its value stays in the column. + if (group && !groups.includes(group)) groups.push(group); + } + const selected = backfill.maxChildren === null ? groups : groups.slice(0, backfill.maxChildren); + for (const [sortOrder, childGroup] of selected.entries()) { + rows.push({ parentGroup, childGroup, sortOrder }); + } + } + + // This runs inside the transaction that binds the field, so an editor binding + // a field on a large legacy collection waits for it: batch rather than paying + // a round trip per edge. + for (const batch of chunks(rows, REFERENCE_INSERT_BATCH_SIZE)) { + const values = batch.map( + (row) => + sql`(${ulid()}, ${backfill.relationId}, ${row.parentGroup}, ${row.childGroup}, ${row.sortOrder}, ${now})`, + ); + // oxlint-disable-next-line no-await-in-loop -- one statement per bind-parameter batch + await sql` + INSERT INTO ${sql.ref("_emdash_content_references")} + (id, relation_id, parent_group, child_group, sort_order, created_at) + VALUES ${sql.join(values)} + ON CONFLICT DO NOTHING + `.execute(db); + } +} diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 2699c2a919..543d6101b7 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -3,7 +3,7 @@ import { ulid } from "ulidx"; import type { ContentFieldFilterValue, ContentFieldFilters } from "../../content-list-query.js"; import { invalidateCollectionCache } from "../../object-cache/index.js"; -import { isIndexableFieldType, type FieldType } from "../../schema/types.js"; +import { isIndexableFieldType, isStoragelessFieldRow, type FieldType } from "../../schema/types.js"; import { buildFtsPrefixMatch, buildSlugGlobPrefix } from "../../search/match.js"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import { isMissingTableError } from "../../utils/db-errors.js"; @@ -1665,6 +1665,24 @@ export class ContentRepository { return result.rows.map((row) => this.mapRow(type, row)); } + /** + * Whether any row still shares `translationGroup`, trashed rows included. + * Group-keyed satellite data (reference edges) is owned by the group, not by a + * single locale row, so a purge may only cascade to it once nothing is left to + * own it — and a trashed sibling is still restorable. + */ + async hasTranslationsIncludingTrashed(type: string, translationGroup: string): Promise<boolean> { + const tableName = getTableName(type); + + const result = await sql<Record<string, unknown>>` + SELECT id FROM ${sql.ref(tableName)} + WHERE translation_group = ${translationGroup} + LIMIT 1 + `.execute(this.db); + + return result.rows.length > 0; + } + /** * Batch variant of {@link findTranslations}: every (non-deleted) locale * variant for any of `translationGroups`, in one `WHERE translation_group IN @@ -2407,9 +2425,16 @@ export class ContentRepository { .where("collection.slug", "=", type) .where("field.slug", "in", fields) .where("field.indexed", "=", 1) - .select(["field.slug", "field.type"]) + .select(["field.slug", "field.type", "field.validation"]) .execute(); - const metadata = new Map(rows.map((row) => [row.slug, row.type as FieldType])); + // `indexed` alone is not enough: a storage-less field has no column to + // filter on. A reference field bound to a relation after it was indexed can + // still carry the flag, and its column no longer receives writes. + const metadata = new Map( + rows + .filter((row) => !isStoragelessFieldRow(row)) + .map((row) => [row.slug, row.type as FieldType]), + ); if (metadata.size === 0 && !(await this.collectionExists(type))) return []; diff --git a/packages/core/src/database/repositories/relation.ts b/packages/core/src/database/repositories/relation.ts index fbb71d1bb8..81ab5c2355 100644 --- a/packages/core/src/database/repositories/relation.ts +++ b/packages/core/src/database/repositories/relation.ts @@ -3,48 +3,69 @@ import { ulid } from "ulidx"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import type { Database, RelationTable, ContentReferenceTable } from "../types.js"; -import { decodeCursor, encodeCursor, InvalidCursorError, type FindManyResult } from "./types.js"; +import { + decodeCursor, + encodeCursor, + InvalidCursorError, + STAGED_CURSOR_MARKER, + type FindManyResult, +} from "./types.js"; + +// Each reference-edge row binds six values. Derive the row count so every +// INSERT stays within D1's 100-parameter statement ceiling. +const REFERENCE_INSERT_BIND_COLUMNS = 6; +const D1_MAX_BOUND_PARAMETERS = 100; +export const REFERENCE_INSERT_BATCH_SIZE = Math.floor( + D1_MAX_BOUND_PARAMETERS / REFERENCE_INSERT_BIND_COLUMNS, +); +/** + * A relation definition. Not localized: a relation joins the same two + * collections whatever language you read it in, and its role labels are + * single-valued like a collection's or a field's. That is what lets `slug` be + * unique outright (migration 076) and resolve without a locale. + */ export interface Relation { id: string; - name: string; + slug: string; parentCollection: string; childCollection: string; parentLabel: string; childLabel: string; - locale: string; - translationGroup: string; + parentLabelSingular: string | null; + childLabelSingular: string | null; + /** How many children one parent may hold. `null` means unlimited. */ + maxChildrenPerParent: number | null; + /** How many parents one child may hold. `null` means unlimited. */ + maxParentsPerChild: number | null; } export interface CreateRelationInput { - name: string; - /** Required for a base relation; ignored (inherited from the source) when - * `translationOf` is set. */ - parentCollection?: string; - /** Required for a base relation; ignored (inherited from the source) when - * `translationOf` is set. */ - childCollection?: string; + slug: string; + parentCollection: string; + childCollection: string; parentLabel: string; childLabel: string; - /** Omit to let the DB default (current value: 'en') apply. Higher layers - * resolve locale from request context / i18n config. */ - locale?: string; - /** When set, joins the source relation's translation_group AND inherits its - * structural fields (name, parentCollection, childCollection). Only locale + - * labels may differ on a translation. */ - translationOf?: string; + parentLabelSingular?: string | null; + childLabelSingular?: string | null; + maxChildrenPerParent?: number | null; + maxParentsPerChild?: number | null; } export interface UpdateRelationInput { - /** Only localized fields are mutable per row. Changing structural fields - * (name/collections) is a cross-group operation deferred to a later slice. */ + /** Structural fields are immutable: a reference field stores the slug, and + * the edges are keyed by the id. */ parentLabel?: string; childLabel?: string; + parentLabelSingular?: string | null; + childLabelSingular?: string | null; + maxChildrenPerParent?: number | null; + maxParentsPerChild?: number | null; } export interface ContentReference { id: string; - relationGroup: string; + relationId: string; parentGroup: string; childGroup: string; sortOrder: number; @@ -53,74 +74,47 @@ export interface ContentReference { /** * Content-references repository. * - * Owns relation *definitions* (`_emdash_relations`, row-per-locale, mirroring - * `_emdash_taxonomy_defs`) and the *edge* junction (`_emdash_content_references`, - * keyed by `translation_group` so edges are locale-agnostic, mirroring + * Owns relation *definitions* (`_emdash_relations`) and the *edge* junction + * (`_emdash_content_references`, whose endpoints are content + * `translation_group`s so edges are locale-agnostic, mirroring * `content_taxonomies`). * + * A relation is schema, so it is not localized — it sits with + * `_emdash_collections` and `_emdash_fields`, not with the row-per-locale + * tables. See migration 076. + * * Like `TaxonomyRepository`, this is not the validation boundary: it trusts its * typed inputs. The API slice supplies Zod schemas at the route and enforces - * collection-agreement / relation-existence invariants in the handler. The repo - * does not resolve locale fallbacks — callers pass the locale they want. + * collection-agreement / relation-existence invariants in the handler. */ export class RelationRepository { constructor(private db: Kysely<Database>) {} /** - * Create a relation. Without `translationOf`, mints a fresh group - * (`translation_group = id`, matching the migration backfill pattern). With - * `translationOf`, the structural fields (name, parentCollection, - * childCollection) and the translation_group are inherited from the source; - * locale and the two labels are taken from `input`. + * Create a relation. + * + * `slug` is unique across all relations, so a duplicate raises the DB's + * unique violation for the handler to translate into a conflict. */ async create(input: CreateRelationInput): Promise<Relation> { const id = ulid(); const now = new Date().toISOString(); - let translationGroup = id; - let name: string; - let parentCollection: string; - let childCollection: string; - - if (input.translationOf) { - const source = await this.findById(input.translationOf); - // translation_group is NOT NULL here, so we cannot fall back to a - // fresh group like TaxonomyRepository does — a bad translationOf must - // fail loudly rather than silently mint an unlinked relation. - if (!source) throw new Error("Source relation for translation not found"); - translationGroup = source.translationGroup; - name = source.name; - parentCollection = source.parentCollection; - childCollection = source.childCollection; - } else { - // A base relation carries its own structural fields. The API layer's Zod - // schema enforces this; guard here too since the repo trusts its inputs - // and the columns are NOT NULL. - if (input.parentCollection === undefined || input.childCollection === undefined) { - throw new Error( - "parentCollection and childCollection are required unless translationOf is set", - ); - } - name = input.name; - parentCollection = input.parentCollection; - childCollection = input.childCollection; - } - await this.db .insertInto("_emdash_relations") .values({ id, - name, - parent_collection: parentCollection, - child_collection: childCollection, + slug: input.slug, + parent_collection: input.parentCollection, + child_collection: input.childCollection, parent_label: input.parentLabel, child_label: input.childLabel, + parent_label_singular: input.parentLabelSingular ?? null, + child_label_singular: input.childLabelSingular ?? null, + max_children_per_parent: input.maxChildrenPerParent ?? null, + max_parents_per_child: input.maxParentsPerChild ?? null, created_at: now, updated_at: now, - // Omit `locale` so the DB DEFAULT (configured defaultLocale) - // applies — matches TaxonomyRepository.create. - ...(input.locale !== undefined ? { locale: input.locale } : {}), - translation_group: translationGroup, }) .execute(); @@ -139,62 +133,46 @@ export class RelationRepository { } /** - * Find a relation by name. With `locale`, filter by it; without, return the - * lowest-locale-code match deterministically. Mirrors - * `TaxonomyRepository.findBySlug` — note this returns a single row, unlike - * `TaxonomyRepository.findByName` which returns every term in a taxonomy. + * Find a relation by its slug. No locale: a slug identifies a relation + * outright (`UNIQUE(slug)`, migration 076), which is what lets an entry in + * any locale address it. */ - async findByName(name: string, locale?: string): Promise<Relation | null> { - let query = this.db.selectFrom("_emdash_relations").selectAll().where("name", "=", name); - if (locale !== undefined) query = query.where("locale", "=", locale); - const row = await query.orderBy("locale", "asc").executeTakeFirst(); + async findBySlug(slug: string): Promise<Relation | null> { + const row = await this.db + .selectFrom("_emdash_relations") + .selectAll() + .where("slug", "=", slug) + .executeTakeFirst(); return row ? this.rowToRelation(row) : null; } - /** Every translation sibling (including itself) sharing a translation_group. */ - async findTranslations(translationGroup: string): Promise<Relation[]> { + /** All relations, ordered by slug. */ + async list(): Promise<Relation[]> { const rows = await this.db .selectFrom("_emdash_relations") .selectAll() - .where("translation_group", "=", translationGroup) - .orderBy("locale", "asc") + .orderBy("slug", "asc") .execute(); return rows.map((row) => this.rowToRelation(row)); } - /** - * All relations, ordered by name then id (id is a stable tiebreak for - * relations sharing a name across locales). Optionally filtered by locale. - */ - async list(locale?: string): Promise<Relation[]> { - let query = this.db - .selectFrom("_emdash_relations") - .selectAll() - .orderBy("name", "asc") - .orderBy("id", "asc"); - if (locale !== undefined) query = query.where("locale", "=", locale); - const rows = await query.execute(); - return rows.map((row) => this.rowToRelation(row)); - } - /** Relations where `collection` is the parent OR the child side. */ - async findForCollection(collection: string, locale?: string): Promise<Relation[]> { - let query = this.db + async findForCollection(collection: string): Promise<Relation[]> { + const rows = await this.db .selectFrom("_emdash_relations") .selectAll() .where((eb) => eb.or([eb("parent_collection", "=", collection), eb("child_collection", "=", collection)]), ) - .orderBy("name", "asc") - .orderBy("id", "asc"); - if (locale !== undefined) query = query.where("locale", "=", locale); - const rows = await query.execute(); + .orderBy("slug", "asc") + .execute(); return rows.map((row) => this.rowToRelation(row)); } /** - * Update the localized labels of one relation row. Structural fields are - * immutable here (a cross-group concern). No-ops when nothing is supplied. + * Update a relation's role labels and cardinality. Structural fields are + * immutable — a reference field stores the slug, and the edges are keyed by + * the id. No-ops when nothing is supplied. */ async update(id: string, input: UpdateRelationInput): Promise<Relation | null> { const existing = await this.findById(id); @@ -203,6 +181,18 @@ export class RelationRepository { const updates: Record<string, unknown> = {}; if (input.parentLabel !== undefined) updates.parent_label = input.parentLabel; if (input.childLabel !== undefined) updates.child_label = input.childLabel; + if (input.parentLabelSingular !== undefined) { + updates.parent_label_singular = input.parentLabelSingular; + } + if (input.childLabelSingular !== undefined) { + updates.child_label_singular = input.childLabelSingular; + } + if (input.maxChildrenPerParent !== undefined) { + updates.max_children_per_parent = input.maxChildrenPerParent; + } + if (input.maxParentsPerChild !== undefined) { + updates.max_parents_per_child = input.maxParentsPerChild; + } if (Object.keys(updates).length > 0) { updates.updated_at = new Date().toISOString(); @@ -213,26 +203,14 @@ export class RelationRepository { } /** - * Delete one relation row. When it is the *last* translation of its group, - * purge edges referencing that group (application-layer cascade — group - * linking precludes a SQL FK). Mirrors `TaxonomyRepository.delete`. + * Delete a relation and its edges (application-layer cascade — the edge + * table has no FK). */ async delete(id: string): Promise<boolean> { const relation = await this.findById(id); if (!relation) return false; - const siblings = await this.db - .selectFrom("_emdash_relations") - .select("id") - .where("translation_group", "=", relation.translationGroup) - .where("id", "!=", id) - .execute(); - if (siblings.length === 0) { - await this.db - .deleteFrom("_emdash_content_references") - .where("relation_group", "=", relation.translationGroup) - .execute(); - } + await this.db.deleteFrom("_emdash_content_references").where("relation_id", "=", id).execute(); const result = await this.db .deleteFrom("_emdash_relations") @@ -241,22 +219,22 @@ export class RelationRepository { return (result.numDeletedRows ?? 0n) > 0n; } - /** Normalize a relation id OR group to its translation_group. Returns null - * for an unknown relation (edge methods then no-op, matching + /** Normalize a relation id OR slug to its id. Returns null for an unknown + * relation (edge methods then no-op, matching * `TaxonomyRepository.attachToEntry`). */ - private async resolveRelationGroup(idOrGroup: string): Promise<string | null> { + private async resolveRelationId(idOrSlug: string): Promise<string | null> { const row = await this.db .selectFrom("_emdash_relations") - .select(["translation_group"]) - .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)])) + .select(["id"]) + .where((eb) => eb.or([eb("id", "=", idOrSlug), eb("slug", "=", idOrSlug)])) .executeTakeFirst(); - return row?.translation_group ?? null; + return row?.id ?? null; } private rowToReference(row: Selectable<ContentReferenceTable>): ContentReference { return { id: row.id, - relationGroup: row.relation_group, + relationId: row.relation_id, parentGroup: row.parent_group, childGroup: row.child_group, sortOrder: row.sort_order, @@ -279,15 +257,15 @@ export class RelationRepository { childGroup: string, sortOrder?: number, ): Promise<void> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return; let order = sortOrder; if (order === undefined) { const max = await this.db .selectFrom("_emdash_content_references") .select((eb) => eb.fn.max("sort_order").as("max")) - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("parent_group", "=", parentGroup) .executeTakeFirst(); order = max?.max === null || max?.max === undefined ? 0 : Number(max.max) + 1; @@ -297,7 +275,7 @@ export class RelationRepository { .insertInto("_emdash_content_references") .values({ id: ulid(), - relation_group: relationGroup, + relation_id: relationId, parent_group: parentGroup, child_group: childGroup, sort_order: order, @@ -309,12 +287,12 @@ export class RelationRepository { /** Remove one `parentGroup → childGroup` edge under a relation. */ async removeReference(relation: string, parentGroup: string, childGroup: string): Promise<void> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return; await this.db .deleteFrom("_emdash_content_references") - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("parent_group", "=", parentGroup) .where("child_group", "=", childGroup) .execute(); @@ -322,13 +300,13 @@ export class RelationRepository { /** Forward traversal: a parent's children for a relation, ordered. */ async getChildren(relation: string, parentGroup: string): Promise<ContentReference[]> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return []; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return []; const rows = await this.db .selectFrom("_emdash_content_references") .selectAll() - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("parent_group", "=", parentGroup) .orderBy("sort_order", "asc") .orderBy("id", "asc") @@ -348,31 +326,63 @@ export class RelationRepository { parentGroup: string, options: { limit?: number; cursor?: string } = {}, ): Promise<FindManyResult<ContentReference>> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return { items: [] }; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return { items: [] }; + return this.getChildrenPageById(relationId, parentGroup, options); + } + /** `getChildrenPage` for a caller that already holds the relation's id. */ + async getChildrenPageById( + relationId: string, + parentGroup: string, + options: { limit?: number; cursor?: string } = {}, + ): Promise<FindManyResult<ContentReference>> { const limit = Math.min(options.limit || 50, 100); let query = this.db .selectFrom("_emdash_content_references") .selectAll() - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("parent_group", "=", parentGroup); if (options.cursor) { const decoded = decodeCursor(options.cursor); - const sortOrder = Number(decoded.orderValue); - // `decodeCursor` only guarantees `orderValue` is a string; a hand-crafted - // cursor with a non-numeric order value would coerce to NaN and blow up at - // the driver bind as a 500. A bad cursor is a client error — surface it as - // INVALID_CURSOR (400). Server-issued cursors are always numeric here. - if (!Number.isFinite(sortOrder)) throw new InvalidCursorError(options.cursor); - query = query.where((eb) => - eb.or([ - eb("sort_order", ">", sortOrder), - eb.and([eb("sort_order", "=", sortOrder), eb("id", ">", decoded.id)]), - ]), - ); + if (decoded.orderValue === STAGED_CURSOR_MARKER) { + // A cursor issued over a draft's pending selection anchors on a + // translation group rather than a row here, which is what a render + // sees when the draft publishes mid-pagination. Resume after that + // group's edge so the walk continues; if the group is no longer + // selected there is nothing to resume from, so the page restarts. + query = query.where((eb) => { + const anchor = () => + eb + .selectFrom("_emdash_content_references as anchor") + .where("anchor.relation_id", "=", relationId) + .where("anchor.parent_group", "=", parentGroup) + .where("anchor.child_group", "=", decoded.id); + return eb.or([ + eb.not(eb.exists(anchor().select("anchor.id"))), + eb("sort_order", ">", anchor().select("anchor.sort_order")), + eb.and([ + eb("sort_order", "=", anchor().select("anchor.sort_order")), + eb("id", ">", anchor().select("anchor.id")), + ]), + ]); + }); + } else { + const sortOrder = Number(decoded.orderValue); + // `decodeCursor` only guarantees `orderValue` is a string; a hand-crafted + // cursor with a non-numeric order value would coerce to NaN and blow up at + // the driver bind as a 500. A bad cursor is a client error — surface it as + // INVALID_CURSOR (400). Server-issued cursors are always numeric here. + if (!Number.isFinite(sortOrder)) throw new InvalidCursorError(options.cursor); + query = query.where((eb) => + eb.or([ + eb("sort_order", ">", sortOrder), + eb.and([eb("sort_order", "=", sortOrder), eb("id", ">", decoded.id)]), + ]), + ); + } } const rows = await query @@ -393,13 +403,13 @@ export class RelationRepository { /** Backlink traversal: the parents that reference a child for a relation. */ async getParents(relation: string, childGroup: string): Promise<ContentReference[]> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return []; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return []; const rows = await this.db .selectFrom("_emdash_content_references") .selectAll() - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("child_group", "=", childGroup) .orderBy("id", "asc") .execute(); @@ -409,15 +419,14 @@ export class RelationRepository { /** * Replace all children of `parentGroup` under a relation with `childGroups`, * assigning positional sort_order (index in the deduped array). Deletes the - * old set for this (relation, parent) and re-inserts — simple and correct; - * the set is small (one parent's children). Mirrors the intent of - * `TaxonomyRepository.setTermsForEntry`. + * old set for this (relation, parent) and re-inserts in D1-safe batches. + * Mirrors the intent of `TaxonomyRepository.setTermsForEntry`. * * A parent references a given child at most once (the unique edge), so * duplicate `childGroups` are collapsed first-occurrence-wins rather than * relying on the insert's onConflict to silently drop them. Not wrapped in a - * transaction: a crash between the delete and insert leaves the parent with - * no children — acceptable for a replace-all, since a retry restores state. + * transaction: an interruption after the delete can leave the parent with an + * empty or partial replacement. A retry restores the complete requested set. * * Concurrency: two simultaneous replace-all calls for the same (relation, * parent) can interleave their deletes and inserts and merge into the union of @@ -429,12 +438,12 @@ export class RelationRepository { * unsupported by design rather than guarded here. */ async setChildren(relation: string, parentGroup: string, childGroups: string[]): Promise<void> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return; await this.db .deleteFrom("_emdash_content_references") - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("parent_group", "=", parentGroup) .execute(); @@ -443,23 +452,156 @@ export class RelationRepository { if (uniqueChildGroups.length === 0) return; const now = new Date().toISOString(); + const rows = uniqueChildGroups.map((childGroup, index) => ({ + id: ulid(), + relation_id: relationId, + parent_group: parentGroup, + child_group: childGroup, + sort_order: index, + created_at: now, + })); + for (const rowBatch of chunks(rows, REFERENCE_INSERT_BATCH_SIZE)) { + await this.db + .insertInto("_emdash_content_references") + .values(rowBatch) + // Belt-and-suspenders: the DELETE above already cleared this + // (relation, parent), so no conflict is possible within one call. + // This is NOT a concurrency guarantee — delete-then-insert is not atomic. + .onConflict((oc) => oc.doNothing()) + .execute(); + } + } + + /** + * Replace all parents of `childGroup` under a relation with `parentGroups`: + * the mirror of `setChildren`, for a field bound to the child side. + * + * Duplicates collapse first-occurrence-wins, and the same + * non-transactional caveats apply — see `setChildren`, including that two + * concurrent replace-all calls for one (relation, child) can merge. + * + * `sort_order` orders children within a parent and has no counterpart on this + * side, so each new edge takes the next position among that parent's existing + * children rather than a position in this child's list. A child-side field + * therefore has no order of its own; `getParents` reads by `id`. + */ + async setParents(relation: string, childGroup: string, parentGroups: string[]): Promise<void> { + const relationId = await this.resolveRelationId(relation); + if (!relationId) return; + await this.db - .insertInto("_emdash_content_references") - .values( - uniqueChildGroups.map((childGroup, index) => ({ - id: ulid(), - relation_group: relationGroup, - parent_group: parentGroup, - child_group: childGroup, - sort_order: index, - created_at: now, - })), - ) - // Belt-and-suspenders: the DELETE above already cleared this - // (relation, parent), so no conflict is possible within one call. - // This is NOT a concurrency guarantee — delete-then-insert is not atomic. - .onConflict((oc) => oc.doNothing()) + .deleteFrom("_emdash_content_references") + .where("relation_id", "=", relationId) + .where("child_group", "=", childGroup) .execute(); + + const uniqueParentGroups = [...new Set(parentGroups)]; + if (uniqueParentGroups.length === 0) return; + + // One query for every new parent's highest position, so appending stays a + // fixed number of round trips rather than one per parent. + const nextSortOrder = new Map<string, number>(); + for (const groupBatch of chunks(uniqueParentGroups, REFERENCE_INSERT_BATCH_SIZE)) { + // oxlint-disable-next-line no-await-in-loop -- one statement per bind-parameter batch + const maxima = await this.db + .selectFrom("_emdash_content_references") + .select((eb) => ["parent_group", eb.fn.max("sort_order").as("max")]) + .where("relation_id", "=", relationId) + .where("parent_group", "in", groupBatch) + .groupBy("parent_group") + .execute(); + for (const row of maxima) { + nextSortOrder.set(row.parent_group, row.max === null ? 0 : Number(row.max) + 1); + } + } + + const now = new Date().toISOString(); + const rows = uniqueParentGroups.map((parentGroup) => ({ + id: ulid(), + relation_id: relationId, + parent_group: parentGroup, + child_group: childGroup, + sort_order: nextSortOrder.get(parentGroup) ?? 0, + created_at: now, + })); + for (const rowBatch of chunks(rows, REFERENCE_INSERT_BATCH_SIZE)) { + // oxlint-disable-next-line no-await-in-loop -- one statement per D1-safe batch + await this.db + .insertInto("_emdash_content_references") + .values(rowBatch) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + } + + /** + * Copy every outgoing edge of `fromParentGroup` onto `toParentGroup`, + * preserving relation, child, and sort order. Used when duplicating a content + * entry so the copy carries the same reference selections (edges are + * storage-less, keyed by translation_group, so they don't ride along in the + * row's `data`). Only the parent side is copied — backlinks pointing at the + * original are intentionally left alone. Idempotent per edge via onConflict. + */ + async copyParentEdges(fromParentGroup: string, toParentGroup: string): Promise<void> { + const rows = await this.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("parent_group", "=", fromParentGroup) + .execute(); + if (rows.length === 0) return; + + const now = new Date().toISOString(); + const copies = rows.map((row) => ({ + id: ulid(), + relation_id: row.relation_id, + parent_group: toParentGroup, + child_group: row.child_group, + sort_order: row.sort_order, + created_at: now, + })); + for (const rowBatch of chunks(copies, REFERENCE_INSERT_BATCH_SIZE)) { + // oxlint-disable-next-line no-await-in-loop -- one statement per D1-safe batch + await this.db + .insertInto("_emdash_content_references") + .values(rowBatch) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + } + + /** + * How many edges each of `groups` already holds at one end of a relation, + * ignoring edges whose opposite end is `excludeOppositeGroup`. + * + * The exclusion is what lets a re-save of an unchanged selection pass: the + * selecting entry's own edges are not counted against the limit it is about + * to re-establish. Groups with no edges are absent from the map. + */ + async countEdgesByGroup( + relationId: string, + side: "parent" | "child", + groups: string[], + excludeOppositeGroup: string, + ): Promise<Map<string, number>> { + const column = side === "parent" ? "parent_group" : "child_group"; + const opposite = side === "parent" ? "child_group" : "parent_group"; + + const counts = new Map<string, number>(); + for (const groupBatch of chunks([...new Set(groups)], SQL_BATCH_SIZE)) { + // oxlint-disable-next-line no-await-in-loop -- one statement per bind-parameter batch + const rows = await this.db + .selectFrom("_emdash_content_references") + .select((eb) => [column, eb.fn.countAll().as("count")]) + .where("relation_id", "=", relationId) + .where(column, "in", groupBatch) + .where(opposite, "!=", excludeOppositeGroup) + .groupBy(column) + .execute(); + for (const row of rows) { + counts.set(row[column], Number(row.count)); + } + } + return counts; } /** @@ -475,20 +617,46 @@ export class RelationRepository { childGroup: string, options: { limit?: number; cursor?: string } = {}, ): Promise<FindManyResult<ContentReference>> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return { items: [] }; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return { items: [] }; + return this.getParentsPageById(relationId, childGroup, options); + } + /** `getParentsPage` for a caller that already holds the relation's id. */ + async getParentsPageById( + relationId: string, + childGroup: string, + options: { limit?: number; cursor?: string } = {}, + ): Promise<FindManyResult<ContentReference>> { const limit = Math.min(options.limit || 50, 100); let query = this.db .selectFrom("_emdash_content_references") .selectAll() - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("child_group", "=", childGroup); if (options.cursor) { const decoded = decodeCursor(options.cursor); - query = query.where("id", ">", decoded.id); + if (decoded.orderValue === STAGED_CURSOR_MARKER) { + // The mirror of `getChildrenPageById`: a staged cursor anchors on a + // translation group, which compared against `id` would page from an + // arbitrary point. Resume after that group's edge, or restart when the + // group is no longer selected. + query = query.where((eb) => { + const anchor = eb + .selectFrom("_emdash_content_references as anchor") + .where("anchor.relation_id", "=", relationId) + .where("anchor.child_group", "=", childGroup) + .where("anchor.parent_group", "=", decoded.id); + return eb.or([ + eb.not(eb.exists(anchor.select("anchor.id"))), + eb("id", ">", anchor.select("anchor.id")), + ]); + }); + } else { + query = query.where("id", ">", decoded.id); + } } const rows = await query @@ -510,8 +678,8 @@ export class RelationRepository { * Remove every edge where `group` is the parent OR the child — i.e. ensure no * orphaned reference edges survive when a content entry is deleted. The * application-layer cascade that group-linking precludes at the SQL level. - * Wiring this into the content-delete path is a later (handler) slice. - * Returns the number of edges removed. + * Callers must be sure the whole group is gone: edges outlive any single + * locale row. Returns the number of edges removed. */ async clearReferencesForGroup(group: string): Promise<number> { const result = await this.db @@ -523,12 +691,12 @@ export class RelationRepository { /** Count a parent's children under a relation. */ async countChildren(relation: string, parentGroup: string): Promise<number> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return 0; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return 0; const result = await this.db .selectFrom("_emdash_content_references") .select((eb) => eb.fn.count("id").as("count")) - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("parent_group", "=", parentGroup) .executeTakeFirst(); return Number(result?.count ?? 0); @@ -536,17 +704,34 @@ export class RelationRepository { /** Count a child's parents (backlinks) under a relation. */ async countParents(relation: string, childGroup: string): Promise<number> { - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return 0; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return 0; const result = await this.db .selectFrom("_emdash_content_references") .select((eb) => eb.fn.count("id").as("count")) - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("child_group", "=", childGroup) .executeTakeFirst(); return Number(result?.count ?? 0); } + /** + * Total edges per relation, for every relation at once. Relations with no + * edges are absent from the map. + * + * One grouped scan rather than a count per relation: the delete dialogs name + * how many links go with a relation, and the relations list shows the same + * number on every row. + */ + async countEdgesByRelation(): Promise<Map<string, number>> { + const rows = await this.db + .selectFrom("_emdash_content_references") + .select(["relation_id", (eb) => eb.fn.count("id").as("count")]) + .groupBy("relation_id") + .execute(); + return new Map(rows.map((row) => [row.relation_id, Number(row.count ?? 0)])); + } + /** * Batch child-counts for many parents under a relation. Chunks at * SQL_BATCH_SIZE for D1's bind-parameter limit. Returns parent_group → count @@ -559,14 +744,14 @@ export class RelationRepository { ): Promise<Map<string, number>> { const counts = new Map<string, number>(); if (parentGroups.length === 0) return counts; - const relationGroup = await this.resolveRelationGroup(relation); - if (!relationGroup) return counts; + const relationId = await this.resolveRelationId(relation); + if (!relationId) return counts; for (const chunk of chunks(parentGroups, SQL_BATCH_SIZE)) { const rows = await this.db .selectFrom("_emdash_content_references") .select(["parent_group", (eb) => eb.fn.count("id").as("count")]) - .where("relation_group", "=", relationGroup) + .where("relation_id", "=", relationId) .where("parent_group", "in", chunk) .groupBy("parent_group") .execute(); @@ -580,13 +765,15 @@ export class RelationRepository { private rowToRelation(row: Selectable<RelationTable>): Relation { return { id: row.id, - name: row.name, + slug: row.slug, parentCollection: row.parent_collection, childCollection: row.child_collection, parentLabel: row.parent_label, childLabel: row.child_label, - locale: row.locale, - translationGroup: row.translation_group, + parentLabelSingular: row.parent_label_singular, + childLabelSingular: row.child_label_singular, + maxChildrenPerParent: row.max_children_per_parent, + maxParentsPerChild: row.max_parents_per_child, }; } } diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index fe7f0310ce..87ee293924 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -231,6 +231,18 @@ export interface FindManyResult<T> { total?: number; } +/** + * Order value stamped into a cursor over a *staged* reference selection, whose + * anchor is a translation group rather than a row in the link table. + * + * A preview and a public render page the same field from different places, so a + * cursor can cross that boundary in either direction — the draft publishes, or + * the preview session ends, mid-pagination. Both sides recognise this marker so + * they can tell a foreign cursor from a malformed one and restart the field's + * page rather than failing or silently emptying it. + */ +export const STAGED_CURSOR_MARKER = "staged"; + /** Encode a cursor from order value + id */ export function encodeCursor(orderValue: string, id: string): string { return encodeBase64(JSON.stringify({ orderValue, id })); @@ -309,6 +321,32 @@ export interface ContentItem { * revision history. */ liveData?: Record<string, unknown>; + /** + * First page of each reference field's resolved selection, keyed by field + * slug. Only populated when the caller opts in via `handleContentGet`'s + * `referenceOptions` param (see content.ts) — hydration is never + * unconditional because it can leak draft ids/slugs to callers without + * `content:read_drafts`. + * + * Shape mirrors `EntryRef` from `api/handlers/relations.ts`, duplicated + * here (rather than imported) so the database layer doesn't depend on + * the api/handlers layer. + */ + references?: Record< + string, + { + children: Array<{ + id: string; + slug: string | null; + collection: string; + title: string | null; + locale: string | null; + translationGroup: string | null; + sortOrder?: number; + }>; + nextCursor?: string; + } + >; } export class EmDashValidationError extends Error { diff --git a/packages/core/src/database/transaction.ts b/packages/core/src/database/transaction.ts index ebd60a051b..69bf167998 100644 --- a/packages/core/src/database/transaction.ts +++ b/packages/core/src/database/transaction.ts @@ -29,7 +29,18 @@ export async function withTransaction<DB, T>( db: Kysely<DB>, fn: (trx: Kysely<DB> | Transaction<DB>) => Promise<T>, ): Promise<T> { - if (db.isTransaction) return fn(db); + // Nested call: `db` is already a transaction. Kysely rejects calling + // `.transaction()` on a `Transaction` outright (a hard error, not the + // "transactions are not supported" message the probe below expects), so a + // naive nested `withTransaction(trx, ...)` call would always throw on any + // dialect that supports real transactions. Running `fn` directly against + // the existing transaction makes the nested work part of the enclosing + // one — exactly what nested callers (e.g. a handler composing two + // repositories that each self-wrap in `withTransaction`) want: the whole + // chain commits or rolls back together. + if (db.isTransaction) { + return fn(db); + } // Fast path: we already know transactions work if (transactionsSupported === true) { return db.transaction().execute(fn); diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index fdf25d057d..9451416dd2 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -830,23 +830,32 @@ export interface BylineFieldGroupValueTable { // between content entries, linked by `translation_group` so they are // locale-agnostic — no foreign keys, mirroring `content_taxonomies`. +/** + * A relation definition. Not localized — a relation joins the same two + * collections whatever language you read it in, and its role labels are + * single-valued like a collection's. See migration 076. + */ export interface RelationTable { id: string; - name: string; + slug: string; parent_collection: string; child_collection: string; parent_label: string; child_label: string; - locale: Generated<string>; - translation_group: string; + parent_label_singular: string | null; + child_label_singular: string | null; + /** How many children one parent may hold. NULL means unlimited. */ + max_children_per_parent: number | null; + /** How many parents one child may hold. NULL means unlimited. */ + max_parents_per_child: number | null; created_at: Generated<string>; updated_at: Generated<string>; } export interface ContentReferenceTable { id: string; - /** Stores `_emdash_relations.translation_group` (locale-agnostic). No FK. */ - relation_group: string; + /** Stores `_emdash_relations.id`. No FK. */ + relation_id: string; /** Parent entry's `translation_group`. */ parent_group: string; /** Child entry's `translation_group`. */ diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index a8a39f56dd..b58c16e3d2 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -15,6 +15,12 @@ import { z } from "zod"; import { ErrorCode } from "./api/errors.js"; import { buildManifestCollections } from "./api/handlers/manifest.js"; +import { resolveReferenceSelection } from "./api/handlers/relations.js"; +import { + mergeStagedReferences, + STAGED_REFERENCES_KEY, + type StagedReferences, +} from "./api/handlers/staged-references.js"; import { assertMediaUsageActivationWriteAllowed } from "./api/media-usage-write-fence.js"; import { validateRev } from "./api/rev.js"; import type { @@ -233,7 +239,7 @@ import { publishDueContent, type PublishedRef } from "./scheduled-publish.js"; import { FTSManager } from "./search/fts-manager.js"; import { invalidateSiteSettingsCache } from "./settings/index.js"; -const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision"]); +const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision", "references"]); const MAX_DRAFT_STAGE_ATTEMPTS = 32; /** @@ -2711,8 +2717,13 @@ export class EmDashRuntime { return handleContentAuthors(this.db, collection); } - async handleContentGet(collection: string, id: string, locale?: string) { - const result = await handleContentGet(this.db, collection, id, locale); + async handleContentGet( + collection: string, + id: string, + locale?: string, + referenceOptions?: { includeDrafts: boolean }, + ) { + const result = await handleContentGet(this.db, collection, id, locale, referenceOptions); return this.hydrateDraftData(result); } @@ -2794,6 +2805,7 @@ export class EmDashRuntime { locale?: string; translationOf?: string; taxonomies?: Record<string, string[]>; + references?: Record<string, string[]>; }, ) { // Run beforeSave hooks (trusted plugins) @@ -2865,6 +2877,7 @@ export class EmDashRuntime { noIndex?: boolean; }; taxonomies?: Record<string, string[]>; + references?: Record<string, string[]>; publishedAt?: string | null; locale?: string; /** Replace the previous autosave revision after staging this save. */ @@ -2946,10 +2959,33 @@ export class EmDashRuntime { // Draft data lives only in the revisions table. let usesDraftRevisions = false; let draftStorageChanged = false; - if (processedData) { + if (processedData || bodyWithoutRev.references) { const collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection); if (collectionInfo?.supports?.includes("revisions")) { usesDraftRevisions = true; + + // Resolve a pending selection to translation groups before it is + // staged, so a bad id or an over-long selection fails this save the + // way a direct link write would, and publication has nothing left to + // resolve. + let stagedReferences: StagedReferences | undefined; + if (bodyWithoutRev.references) { + stagedReferences = {}; + for (const [fieldSlug, selectedIds] of Object.entries(bodyWithoutRev.references)) { + const resolved = await resolveReferenceSelection( + this.db, + collection, + resolvedId, + fieldSlug, + selectedIds, + ); + if (!resolved.success) { + return { success: false as const, error: resolved.error }; + } + stagedReferences[fieldSlug] = resolved.data.groups; + } + } + const revisionRepo = new RevisionRepository(this.db); let existing = await repo.findById(collection, resolvedId); @@ -2966,6 +3002,9 @@ export class EmDashRuntime { if (bodyWithoutRev.slug !== undefined) { mergedData._slug = bodyWithoutRev.slug; } + if (stagedReferences) { + mergedData[STAGED_REFERENCES_KEY] = mergeStagedReferences(baseData, stagedReferences); + } const revision = await revisionRepo.create({ collection, @@ -3056,13 +3095,19 @@ export class EmDashRuntime { ...bodyWithoutRev, data: usesDraftRevisions ? undefined : processedData, slug: usesDraftRevisions ? undefined : bodyWithoutRev.slug, + references: usesDraftRevisions ? undefined : bodyWithoutRev.references, authorId: bodyWithoutRev.authorId, bylines: bodyWithoutRev.bylines, }); const liveContentChanged = usesDraftRevisions ? liveMetaTouched - : Boolean(processedData || bodyWithoutRev.slug !== undefined || liveMetaTouched); + : Boolean( + processedData || + bodyWithoutRev.slug !== undefined || + bodyWithoutRev.references || + liveMetaTouched, + ); // Hydrate draft data BEFORE firing afterSave hooks so the hook sees // the same effective data the response surfaces — for revision- diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26d5cdf06c..522cb819df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,6 +140,7 @@ export { decodeSlug, slugify } from "./utils/slugify.js"; export { getEmDashCollection, getEmDashEntry, + getEmDashReferences, getEditMeta, getTranslations, resolveEmDashPath, @@ -152,8 +153,17 @@ export type { EditFieldMeta, EntryResult, EmDashCollections, + EmDashCollectionReferences, InferCollectionData, + InferCollectionReferences, + ReferencePage, + ReferencePages, + ReferenceQuery, + ReferenceResult, + ReferenceSelection, ResolvePathResult, + SelectableReferences, + SelectedReferences, TranslationSummary, TranslationsResult, WhereRange, diff --git a/packages/core/src/loader.ts b/packages/core/src/loader.ts index 4ba4a22131..9c92bcf6cb 100644 --- a/packages/core/src/loader.ts +++ b/packages/core/src/loader.ts @@ -22,6 +22,7 @@ import { getI18nConfig } from "./i18n/config.js"; import type { Database } from "./index.js"; import { primeSeoPanel } from "./page/seo-panel.js"; import { getRequestContext } from "./request-context.js"; +import { chunks, SQL_BATCH_SIZE } from "./utils/chunks.js"; import { isMissingColumnError, isMissingTableError } from "./utils/db-errors.js"; const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; @@ -569,6 +570,90 @@ function mapRowToData( return data; } +/** + * The entry id Astro addresses a row by: its slug, prefixed with the locale + * whenever i18n routing would prefix the URL. Shared by every path that builds + * a loader entry so a referenced entry carries the same id it would have been + * loaded under directly. + */ +function entryIdForRow(row: Record<string, unknown>): string { + const i18nConfig = virtualConfig?.i18n; + const slug = rowStr(row, "slug") || rowStr(row, "id"); + const locale = rowStr(row, "locale"); + const shouldPrefix = + i18nConfig && + i18nConfig.locales.length > 1 && + locale !== "" && + (locale !== i18nConfig.defaultLocale || i18nConfig.prefixDefaultLocale); + return shouldPrefix ? `${locale}/${slug}` : slug; +} + +/** A loader entry as {@link emdashLoader} builds one, before the query layer wraps it. */ +export interface LoadedEntry { + id: string; + slug: string; + status: string; + data: Record<string, unknown>; + cacheHint: { tags: string[]; lastModified?: Date }; +} + +/** + * Load every locale variant of each translation group, in one query per + * `SQL_BATCH_SIZE` chunk of groups. + * + * Reference resolution is the caller: a selection names translation groups, and + * the group's variants are what a render picks a locale from. The rows go + * through the same {@link mapRowToData} as a direct entry load, so a referenced + * entry's `data` carries the same dates, booleans and normalized media values a + * caller would get from `getEmDashEntry` — a hand-rolled row mapper would drift + * from it silently. + * + * Byline and taxonomy hydration is deliberately not folded in: those are + * per-row correlated subqueries, and a referenced entry is rendered as a link + * or a card far more often than as a full page. + */ +export async function loadEntriesByGroups( + type: string, + translationGroups: string[], + options: { publishedOnly?: boolean } = {}, +): Promise<LoadedEntry[]> { + if (translationGroups.length === 0) return []; + const db = await getDb(); + const tableName = getTableName(type); + const statusFilter = options.publishedOnly ? sql`AND status = ${"published"}` : sql``; + const booleanFieldsSelect = foldedBooleanFieldsSelect(db, type); + + const entries: LoadedEntry[] = []; + try { + for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) { + const result = await sql<Record<string, unknown>>` + SELECT *, ${booleanFieldsSelect} FROM ${sql.ref(tableName)} + WHERE translation_group IN (${sql.join(chunk.map((group) => sql`${group}`))}) + AND deleted_at IS NULL + ${statusFilter} + ORDER BY translation_group ASC, locale ASC + `.execute(db); + const booleanFields = parseFoldedBooleanFields(result.rows[0]); + for (const row of result.rows) { + entries.push({ + id: entryIdForRow(row), + slug: rowStr(row, "slug"), + status: rowStr(row, "status", "draft"), + data: mapRowToData(row, booleanFields), + cacheHint: { + tags: [rowStr(row, "id")], + lastModified: row.updated_at ? new Date(rowStr(row, "updated_at")) : undefined, + }, + }); + } + } + } catch (error) { + if (isMissingTableError(error)) return []; + throw error; + } + return entries; +} + function parseFoldedBooleanFields(row: Record<string, unknown> | undefined): Set<string> { const raw = row?.[BOOLEAN_FIELDS_FOLDED_COLUMN]; let values: unknown; @@ -1422,17 +1507,9 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil const rows = hasMore ? result.rows.slice(0, limit) : result.rows; // Map rows to entries - const i18nConfig = virtualConfig?.i18n; - const i18nEnabled = i18nConfig && i18nConfig.locales.length > 1; const booleanFields = parseFoldedBooleanFields(rows[0]); const entries = rows.map((row) => { - const slug = rowStr(row, "slug") || rowStr(row, "id"); - const rowLocale = rowStr(row, "locale"); - const shouldPrefix = - i18nEnabled && - rowLocale !== "" && - (rowLocale !== i18nConfig.defaultLocale || i18nConfig.prefixDefaultLocale); - const id = shouldPrefix ? `${rowLocale}/${slug}` : slug; + const id = entryIdForRow(row); const data = mapRowToData(row, booleanFields); stashFolded(data, row); return { @@ -1576,13 +1653,7 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil const i18nConfig = virtualConfig?.i18n; const i18nEnabled = i18nConfig && i18nConfig.locales.length > 1; - const entrySlug = rowStr(row, "slug") || rowStr(row, "id"); - const entryLocale = rowStr(row, "locale"); - const shouldPrefixEntry = - i18nEnabled && - entryLocale !== "" && - (entryLocale !== i18nConfig.defaultLocale || i18nConfig.prefixDefaultLocale); - const entryId = shouldPrefixEntry ? `${entryLocale}/${entrySlug}` : entrySlug; + const entryId = entryIdForRow(row); // Preview mode: override content fields with revision data, // keeping system metadata from the content table row. diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index a73b178c74..f3048e48f7 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -37,10 +37,12 @@ import { } from "./loader.js"; import { cachedQuery, + contentCacheNamespaces, contentNamespaces, invalidateSchemaObjectCache, } from "./object-cache/index.js"; import { primeSeoPanel } from "./page/seo-panel.js"; +import type { ReferenceQuery, ReferenceSelection } from "./references/types.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; import { resetRegisteredCollectionsCache } from "./schema/collection-slugs-cache.js"; @@ -86,6 +88,58 @@ export type InferCollectionData<T extends string> = T extends keyof EmDashCollec ? EmDashCollections[T] : Record<string, unknown>; +/** + * Reference type registry, the counterpart to {@link EmDashCollections}. + * + * Extended by the generated emdash-env.d.ts for every collection that has at + * least one reference field bound to a relation, so a selection resolves to the + * target collection's own interface. + * + * @example + * ```ts + * // In emdash-env.d.ts (generated): + * declare module "emdash" { + * interface EmDashCollectionReferences { + * posts: { author: ReferencePage<Author>; related_posts: ReferencePage<Post> }; + * } + * } + * + * // Then in your code: + * const { entry } = await getEmDashEntry("posts", slug, { references: { author: true } }); + * // entry.references.author.entries[0].data.name is typed as string + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface EmDashCollectionReferences {} + +/** + * Helper type to infer the reference shapes for a collection. + * Returns the registered type if known, otherwise falls back to one + * un-narrowed page per field slug. + */ +export type InferCollectionReferences<T extends string> = T extends keyof EmDashCollectionReferences + ? EmDashCollectionReferences[T] + : ReferencePages; + +/** + * What `getEmDashEntry`'s `references` option accepts for a collection: any + * subset of its reference fields, or any field slug at all for a collection + * with no generated entry. + */ +export type SelectableReferences<T extends string> = Partial< + Record<keyof InferCollectionReferences<T> & string, ReferenceQuery> +>; + +/** + * The `references` a selection produces: one page per field it named, and no + * key for a field it did not, so a render reads `entry.references.author` + * without a second optional check. + */ +export type SelectedReferences<T extends string, S> = Pick< + InferCollectionReferences<T>, + Extract<keyof S, keyof InferCollectionReferences<T>> +>; + /** * Sort direction */ @@ -100,6 +154,7 @@ export type SortDirection = "asc" | "desc"; export type OrderBySpec = Record<string, SortDirection>; export type { WhereRange, WhereValue }; +export type { ReferenceQuery, ReferenceSelection }; /** * Fields shared by every collection query, independent of pagination mode. @@ -197,13 +252,37 @@ export interface OffsetCollectionFilter extends CollectionFilterBase { */ export type CollectionFilter = CursorCollectionFilter | OffsetCollectionFilter; -export interface ContentEntry<T = Record<string, unknown>> { +export interface ContentEntry<T = Record<string, unknown>, R = ReferencePages> { id: string; data: T; + /** + * One page of each reference field the caller opted into, by field slug. + * Absent unless `references` was passed to {@link getEmDashEntry}. + */ + references?: R; /** Visual editing annotations. Spread onto elements: {...entry.edit.title} */ edit: EditProxy; } +/** + * One page of a reference field's selection: the entries it points at, in the + * order the editor chose, plus the cursor for the next page when the field + * holds more than the limit asked for. + */ +export interface ReferencePage<T = Record<string, unknown>> { + entries: ContentEntry<T>[]; + nextCursor?: string; +} + +/** The un-narrowed shape of `entry.references` — one page per field slug. */ +export type ReferencePages = Record<string, ReferencePage>; + +/** A reference page with the error channel the standalone query returns. */ +export interface ReferenceResult<T = Record<string, unknown>> extends ReferencePage<T> { + /** Set only for actual errors; an unknown field or entry is an empty page. */ + error?: Error; +} + /** Cache hint returned by the content loader for route caching */ export interface CacheHint { tags?: string[]; @@ -237,9 +316,9 @@ export interface CollectionResult<T> { /** * Result from getEmDashEntry */ -export interface EntryResult<T> { +export interface EntryResult<T, R = ReferencePages> { /** The entry, or null if not found */ - entry: ContentEntry<T> | null; + entry: ContentEntry<T, R> | null; /** Error if the query failed (not set for "not found", only for actual errors) */ error?: Error; /** Whether we're in preview mode (valid token was provided) */ @@ -667,23 +746,14 @@ type ContentSnapshot<S> = | { ok: true; value: S } | { ok: false; error?: Error; cacheHint: CacheHint }; -function entrySnapshot<D>(entry: ContentEntry<D>): Record<string, unknown> { - const data = entryData(entry); +function dataSnapshot(data: Record<string, unknown>): Record<string, unknown> { const rawCursor = Reflect.get(data, CURSOR_RAW_VALUES); - // Drop the `edit` function; copy enumerable data + the cursor-raw values. - const { edit: _edit, ...rest } = entry as ContentEntry<D> & { edit?: unknown }; - return { - ...rest, - data: { ...data, [CURSOR_RAW_FIELD]: rawCursor ?? {} }, - }; + return { ...data, [CURSOR_RAW_FIELD]: rawCursor ?? {} }; } -function reviveEntry<D>(raw: unknown): ContentEntry<D> { - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot shape produced by entrySnapshot - const entry = raw as Record<string, unknown>; - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot `data` is always a record - const data: Record<string, unknown> = { ...(entry.data as Record<string, unknown>) }; - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot field written by entrySnapshot +function reviveData(raw: Record<string, unknown>): Record<string, unknown> { + const data: Record<string, unknown> = { ...raw }; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot field written by dataSnapshot const rawCursor = (data[CURSOR_RAW_FIELD] as Record<string, string> | undefined) ?? {}; delete data[CURSOR_RAW_FIELD]; Object.defineProperty(data, CURSOR_RAW_VALUES, { @@ -692,8 +762,75 @@ function reviveEntry<D>(raw: unknown): ContentEntry<D> { configurable: false, writable: false, }); + return data; +} + +function entrySnapshot<D>(entry: ContentEntry<D>): Record<string, unknown> { + // Drop the `edit` function; copy enumerable data + the cursor-raw values. + const { edit: _edit, references, ...rest } = entry as ContentEntry<D> & { edit?: unknown }; + return { + ...rest, + data: dataSnapshot(entryData(entry)), + ...(references ? { references: referencesSnapshot(references) } : {}), + }; +} + +function referencesSnapshot(references: ReferencePages): Record<string, unknown> { + const snapshot: Record<string, unknown> = {}; + for (const [field, page] of Object.entries(references)) { + snapshot[field] = { + entries: page.entries.map((child) => ({ + id: child.id, + data: dataSnapshot(entryData(child)), + })), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + }; + } + return snapshot; +} + +/** + * Rebuild the reference pages a snapshot carried. + * + * Every child comes back with a no-op `edit` proxy and no revision metadata, + * which is lossless: the object cache is bypassed for edit-mode and preview + * requests, so a snapshot is only ever written — and read back — by a render + * that had neither to begin with. + */ +function reviveReferences(raw: unknown): ReferencePages | undefined { + if (!isRecord(raw)) return undefined; + const references: ReferencePages = {}; + for (const [field, page] of Object.entries(raw)) { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- page shape produced by referencesSnapshot + const snapshot = page as { entries: Record<string, unknown>[]; nextCursor?: string }; + references[field] = { + entries: snapshot.entries.map((child) => ({ + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot ids are always strings + id: child.id as string, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot `data` is always a record + data: reviveData(child.data as Record<string, unknown>), + edit: createNoop(), + })), + ...(snapshot.nextCursor === undefined ? {} : { nextCursor: snapshot.nextCursor }), + }; + } + return references; +} + +function reviveEntry<D>(raw: unknown): ContentEntry<D> { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot shape produced by entrySnapshot + const entry = raw as Record<string, unknown>; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot `data` is always a record + const data = reviveData(entry.data as Record<string, unknown>); + const references = reviveReferences(entry.references); + const revived = { + ...entry, + data, + ...(references ? { references } : {}), + edit: createNoop(), + }; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- rebuilt to the ContentEntry shape with a no-op edit proxy - return { ...entry, data, edit: createNoop() } as ContentEntry<D>; + return revived as ContentEntry<D>; } /** Resolve the effective locale used by content reads, for the L2 cache key. */ @@ -803,11 +940,160 @@ async function getEmDashCollectionUncached<T extends string, D = InferCollection * const { entry: post, isPreview, error } = await getEmDashEntry("posts", "my-slug"); * if (!post) return Astro.redirect("/404"); * ``` + * + * @example + * ```ts + * // Opt into reference fields, by field slug + * const { entry: post } = await getEmDashEntry("posts", slug, { + * references: { author: true, related_posts: { limit: 6 } }, + * }); + * const author = post?.references?.author.entries[0]; + * ``` + */ +export async function getEmDashEntry< + T extends string, + D = InferCollectionData<T>, + S extends SelectableReferences<T> = {}, +>( + type: T, + id: string, + options?: { locale?: string; references?: S }, +): Promise<EntryResult<D, SelectedReferences<T, S>>> { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- the resolver returns one page per field the selection named, which is what SelectedReferences picks out + return resolveEmDashEntry<T, D>(type, id, options) as Promise< + EntryResult<D, SelectedReferences<T, S>> + >; +} + +/** + * Attach one page of each selected reference field to a resolved entry, and + * report the cache hint its children contribute. + * + * An entry with no translation group predates i18n and has no links; there is + * nothing to resolve, and `references` stays absent rather than becoming an + * empty object that reads as "this entry references nothing". + */ +async function attachReferences<D>( + type: string, + entry: ContentEntry<D>, + options: { + selection: ReferenceSelection; + /** + * Whether this render may see drafts. It decides both whether a pending + * selection replaces the published one and whether an unpublished target + * resolves at all — a preview token that did not match served this entry + * as public, and its children have to stay just as invisible. + */ + serveDrafts: boolean; + /** Read before `stripRevisionMetadata` removes it from a public render's data. */ + draftRevisionId?: string; + }, +): Promise<CacheHint> { + const data = entryData(entry); + const entryGroup = dataStr(data, "translationGroup"); + if (!entryGroup) return {}; + + const { resolveReferencePages } = await import("./references/resolve.js"); + const pages = await resolveReferencePages({ + collection: type, + entryGroup, + locale: dataStr(data, "locale") || null, + draftRevisionId: options.draftRevisionId, + serveDrafts: options.serveDrafts, + selection: options.selection, + }); + + const references: ReferencePages = {}; + const tags: string[] = []; + let lastModified: Date | undefined; + for (const [field, page] of Object.entries(pages)) { + for (const child of page.entries) { + tags.push(...child.cacheHint.tags); + const modified = child.cacheHint.lastModified; + if (modified && (!lastModified || modified > lastModified)) lastModified = modified; + } + references[field] = { + entries: page.entries.map((child) => wrapReferencedEntry(page.collection, child)), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + }; + } + entry.references = references; + return { tags, ...(lastModified ? { lastModified } : {}) }; +} + +/** + * Fold the children's cache hint into the entry's own. + * + * A render that shows a referenced entry has read that row, so the route's tags + * have to name it and its `Last-Modified` has to move when it changes. Without + * this the route cache's `invalidate({ tags: [collection, id] })` on a child + * write would never reach the pages that render the child. + */ +function mergeCacheHints(base: CacheHint, extra: CacheHint): CacheHint { + if (!extra.tags?.length && !extra.lastModified) return base; + const tags = [...new Set([...(base.tags ?? []), ...(extra.tags ?? [])])]; + const newest = + base.lastModified && extra.lastModified + ? base.lastModified > extra.lastModified + ? base.lastModified + : extra.lastModified + : (base.lastModified ?? extra.lastModified); + return { + ...(tags.length > 0 ? { tags } : {}), + ...(newest ? { lastModified: newest } : {}), + }; +} + +/** + * The object-cache namespaces a selection's targets live in. + * + * Without them a cached parent snapshot would outlive a write to the entries it + * points at. Byline and taxonomy namespaces are deliberately absent: referenced + * entries are loaded without either hydration. The targets are sorted so two + * callers that name the same fields in a different order share one snapshot. + */ +async function referenceTargetNamespaces( + collection: string, + selection: ReferenceSelection, +): Promise<string[]> { + const { getReferenceFieldMap } = await import("./references/field-map.js"); + const fieldMap = await getReferenceFieldMap(collection); + const targets = new Set<string>(); + for (const [slug, query] of Object.entries(selection)) { + if (query === undefined) continue; + const binding = fieldMap.get(slug); + if (binding) targets.add(binding.targetCollection); + } + return [...targets].toSorted().flatMap((target) => [...contentCacheNamespaces(target)]); +} + +/** + * Wrap a referenced entry the way the collection paths wrap their own: an edit + * proxy in edit mode, revision metadata stripped for anyone who may not see it. + * + * The proxy is scoped to the *referenced* entry's collection and row, so + * clicking through from a card opens the entry the card is about. */ -export async function getEmDashEntry<T extends string, D = InferCollectionData<T>>( +function wrapReferencedEntry<D = Record<string, unknown>>( + collection: string, + child: { id: string; data: Record<string, unknown> }, +): ContentEntry<D> { + const isEditMode = getRequestContext()?.editMode ?? false; + const dbId = entryDatabaseId(child); + if (isEditMode) tagEditableFields(child.data, collection, dbId); + if (!canExposeRevisionMetadata(child, collection)) stripRevisionMetadata(child); + return { + id: child.id, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- row data is shaped by the target collection, which only generated types know + data: child.data as D, + edit: isEditMode ? createEditable(collection, dbId, entryEditOptions(child)) : createNoop(), + }; +} + +async function resolveEmDashEntry<T extends string, D = InferCollectionData<T>>( type: T, id: string, - options?: { locale?: string }, + options?: { locale?: string; references?: ReferenceSelection }, ): Promise<EntryResult<D>> { // Dynamic import to avoid build-time issues const { getLiveEntry } = await import("astro:content"); @@ -822,6 +1108,7 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T // Resolve locale: explicit option > ALS context > undefined (no filter) const requestedLocale = options?.locale ?? ctx?.locale; + const references = options?.references; /** Wrap a raw Astro entry with edit proxy, tagging editable fields if needed */ function wrapEntry(raw: ContentEntry<D>): ContentEntry<D> { @@ -845,26 +1132,39 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T const localeChain = requestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale]; - /** Return a successful EntryResult with bylines and taxonomy terms hydrated */ + /** Return a successful EntryResult with bylines, taxonomy terms and references hydrated */ async function successResult( wrapped: ContentEntry<D>, opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint }, ): Promise<EntryResult<D>> { + // Read the draft pointer before stripping it: a public render drops it + // from `data`, and a staged selection is resolved from it. + const draftRevisionId = dataStr(entryData(wrapped), "draftRevisionId") || undefined; if (!opts.isPreview) stripRevisionMetadata(wrapped); // No-i18n callers use the legacy wildcard cache key. The query path still // resolves against the stored content-row locale when this is undefined. const termLocale = isI18nEnabled() ? dataStr(entryData(wrapped), "locale") || undefined : undefined; - await Promise.all([ + // References resolve alongside bylines and terms rather than after the + // entry returns: all three are independent reads, and running them + // together keeps an opted-in render to one extra round of queries. + const [, , referenceHint] = await Promise.all([ hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped], termLocale), + references + ? attachReferences(type, wrapped, { + selection: references, + serveDrafts: opts.isPreview, + draftRevisionId, + }) + : Promise.resolve<CacheHint>({}), ]); return { entry: wrapped, isPreview: opts.isPreview, fallbackLocale: opts.fallbackLocale, - cacheHint: opts.cacheHint, + cacheHint: mergeCacheHints(opts.cacheHint, referenceHint), }; } @@ -968,9 +1268,20 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T return { entry: null, isPreview: false, cacheHint: {} }; }; + // A snapshot now carries whatever references the caller asked for, so both + // halves of the cache identity have to account for the selection: the key, + // or a render that asked for references would be served one that did not; + // the namespaces, or the snapshot would outlive a write to a child. A caller + // that asked for none keeps the key and namespaces it had before references + // existed, so entries cached by the previous release stay reachable. + const namespaces = references + ? [...contentNamespaces(type), ...(await referenceTargetNamespaces(type, references))] + : contentNamespaces(type); + const referenceKey = references ? `|refs=${stableStringify(references)}` : ""; + const snapshot = await cachedQuery<ContentSnapshot<CachedEntryValue>>({ - namespace: contentNamespaces(type), - key: `entry:${id}|loc=${requestedLocale ?? ""}`, + namespace: namespaces, + key: `entry:${id}|loc=${requestedLocale ?? ""}${referenceKey}`, load: async () => { const result = await resolveNormal(); if (result.error) { @@ -1011,6 +1322,80 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T }; } +/** + * Get one page of a single reference field, without re-reading the entry it + * hangs off. + * + * `getEmDashEntry({ references })` returns the first page of each field it is + * asked for; this is how a "load more" walks past it, using the `nextCursor` + * that page carried. Draft visibility follows the same request context — a + * preview token for this entry, or edit mode — so a walk started in preview + * keeps seeing the pending selection. + * + * @example + * ```ts + * import { getEmDashReferences } from "emdash"; + * + * const more = await getEmDashReferences("posts", post.id, "related_posts", { + * cursor, + * limit: 20, + * }); + * ``` + */ +export async function getEmDashReferences<D = Record<string, unknown>>( + type: string, + id: string, + field: string, + options?: { limit?: number; cursor?: string; locale?: string }, +): Promise<ReferenceResult<D>> { + const ctx = getRequestContext(); + const preview = ctx?.preview; + const isEditMode = ctx?.editMode ?? false; + const locale = options?.locale ?? ctx?.locale; + + try { + const { getDb } = await import("./loader.js"); + const { ContentRepository } = await import("./database/repositories/content.js"); + const { resolveReferencePages } = await import("./references/resolve.js"); + + const db = await getDb(); + const entry = await new ContentRepository(db).findByIdOrSlug(type, id, locale); + if (!entry?.translationGroup) return { entries: [] }; + + // Preview tokens are entry-scoped, so a token minted for another entry + // gives no draft access here; edit mode is collection-wide. + const previewMatches = + !!preview && preview.collection === type && (preview.id === entry.id || preview.id === id); + const serveDrafts = isEditMode || previewMatches; + // Anchoring on an entry the caller may not see would let its links be + // probed. An unpublished anchor is simply empty, as a missing one is. + if (!serveDrafts && entry.status !== "published") return { entries: [] }; + + const query: ReferenceQuery = + options?.limit === undefined && options?.cursor === undefined + ? true + : { limit: options.limit, cursor: options.cursor }; + + const pages = await resolveReferencePages({ + collection: type, + entryGroup: entry.translationGroup, + locale: entry.locale, + draftRevisionId: entry.draftRevisionId ?? undefined, + serveDrafts, + selection: { [field]: query }, + }); + + const page = pages[field]; + if (!page) return { entries: [] }; + return { + entries: page.entries.map((child) => wrapReferencedEntry<D>(page.collection, child)), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + }; + } catch (error) { + return { entries: [], error: error instanceof Error ? error : new Error(String(error)) }; + } +} + /** Shape of a cached single-entry snapshot. */ interface CachedEntryValue { entry: Record<string, unknown> | null; diff --git a/packages/core/src/references/field-map.ts b/packages/core/src/references/field-map.ts new file mode 100644 index 0000000000..1a1605220e --- /dev/null +++ b/packages/core/src/references/field-map.ts @@ -0,0 +1,115 @@ +/** + * The reference fields a collection carries, for the public query path. + * + * `referenceFieldConstraints` (api/handlers) answers the write question — how + * many entries may a field select, and is it required. Rendering asks a + * different one: which collection do I load, and which end of the relation am I + * standing on. Both read the same rows; keeping them apart keeps the render + * path off the relation table, which the constraints map has to read for its + * limits. + */ + +import { sql, type Kysely } from "kysely"; + +import { jsonExtractExpr } from "../database/dialect-helpers.js"; +import type { Database } from "../database/types.js"; +import { getDb } from "../loader.js"; +import { cachedQuery, CacheNamespace } from "../object-cache/index.js"; +import { requestCached } from "../request-cache.js"; +import { isMissingTableError } from "../utils/db-errors.js"; + +/** One reference field, as a render needs it. */ +export interface ReferenceFieldBinding { + /** Field slug — the key a caller names a selection under. */ + slug: string; + /** Relation slug the field binds to. */ + relation: string; + /** + * The relation's id, read in the same statement as the binding. Rendering + * pages the link table by id, so carrying it here is what keeps a field's + * cost at one link read rather than a slug lookup and then the read. + */ + relationId: string; + /** Which end of the relation this field's own collection sits on. */ + side: "parent" | "child"; + /** The collection at the other end. */ + targetCollection: string; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function loadBindings( + db: Kysely<Database>, + collection: string, +): Promise<ReferenceFieldBinding[]> { + // The relation's id joins in here rather than being looked up per field at + // render time: the field stores a slug, but the link table is keyed by id. + const relationSlug = jsonExtractExpr(db, "validation", "relation"); + let rows: { slug: string; validation: string | null; relation_id: string | null }[]; + try { + const result = await sql<{ + slug: string; + validation: string | null; + relation_id: string | null; + }>` + SELECT f.slug AS slug, f.validation AS validation, r.id AS relation_id + FROM ${sql.ref("_emdash_fields")} AS f + INNER JOIN ${sql.ref("_emdash_collections")} AS c ON c.id = f.collection_id + LEFT JOIN ${sql.ref("_emdash_relations")} AS r ON r.slug = ${sql.raw(relationSlug)} + WHERE c.slug = ${collection} AND f.type = 'reference' + `.execute(db); + rows = result.rows; + } catch (error) { + if (isMissingTableError(error)) return []; + throw error; + } + + const bindings: ReferenceFieldBinding[] = []; + for (const row of rows) { + if (!row.validation) continue; + let parsed: unknown; + try { + parsed = JSON.parse(row.validation); + } catch { + continue; + } + if (!isRecord(parsed)) continue; + const { relation, targetCollection } = parsed; + // A field with neither is unbound: it keeps its own column, and its value + // is already in `data`. There is nothing to resolve. Nor is there when the + // named relation is gone — the join leaves no id to page by. + if (typeof relation !== "string" || typeof targetCollection !== "string") continue; + if (!row.relation_id) continue; + bindings.push({ + slug: row.slug, + relation, + relationId: row.relation_id, + targetCollection, + side: parsed.relationSide === "child" ? "child" : "parent", + }); + } + return bindings; +} + +/** + * Every bound reference field on `collection`, by field slug. + * + * Cached twice over: once per request, and once in the schema object-cache + * namespace, which a schema edit already bumps. A render that asks for + * references therefore pays for this lookup at most once per isolate between + * schema changes, and a render that asks for none never issues it. + */ +export function getReferenceFieldMap( + collection: string, +): Promise<Map<string, ReferenceFieldBinding>> { + return requestCached(`reference-field-map:${collection}`, async () => { + const bindings = await cachedQuery({ + namespace: CacheNamespace.SCHEMA, + key: `reference-field-map:${collection}`, + load: async () => loadBindings(await getDb(), collection), + }); + return new Map(bindings.map((binding) => [binding.slug, binding])); + }); +} diff --git a/packages/core/src/references/resolve.ts b/packages/core/src/references/resolve.ts new file mode 100644 index 0000000000..84b3c04fd4 --- /dev/null +++ b/packages/core/src/references/resolve.ts @@ -0,0 +1,212 @@ +/** + * Resolve an entry's reference selections to loadable entries, for the public + * query path. + * + * The cost is bounded and predictable: one link read per selected field, then + * one entry read per *distinct* target collection — never one per link. A + * caller that selects no fields issues nothing at all, which is what keeps + * references off the logged-out hot path of every render that does not ask for + * them. + */ + +import { readStagedReferences } from "../api/handlers/staged-references.js"; +import { RelationRepository } from "../database/repositories/relation.js"; +import { RevisionRepository } from "../database/repositories/revision.js"; +import { + decodeCursor, + encodeCursor, + STAGED_CURSOR_MARKER, +} from "../database/repositories/types.js"; +import { getDb, loadEntriesByGroups, type LoadedEntry } from "../loader.js"; +import { getReferenceFieldMap } from "./field-map.js"; +import type { ReferenceQuery, ReferenceSelection } from "./types.js"; + +/** Default page size for one reference field, matching the list endpoints. */ +const DEFAULT_LIMIT = 50; +/** Hard ceiling, matching the list endpoints. */ +const MAX_LIMIT = 100; + +/** One field's page of translation groups, before the entries are loaded. */ +interface PageOfGroups { + groups: string[]; + nextCursor?: string; +} + +/** One reference field's resolved page, before the query layer wraps the entries. */ +export interface ResolvedReferencePage { + /** The collection the entries belong to — what the `edit` proxy is scoped to. */ + collection: string; + entries: LoadedEntry[]; + nextCursor?: string; +} + +export interface ResolveReferencesOptions { + /** The collection the selecting entry belongs to. */ + collection: string; + /** The selecting entry's own translation group. */ + entryGroup: string; + /** The locale the parent resolved to, used to pick each target's variant. */ + locale: string | null; + /** The selecting entry's draft revision, when it has one. */ + draftRevisionId?: string; + /** + * Whether this render may see unpublished content. It decides both halves of + * draft visibility: whether a pending selection staged in the draft revision + * replaces the published one, and whether an unpublished target resolves at + * all. + */ + serveDrafts: boolean; + /** The fields to resolve, by field slug. */ + selection: ReferenceSelection; +} + +function pageOptions(query: ReferenceQuery): { limit: number; cursor?: string } { + if (query === true) return { limit: DEFAULT_LIMIT }; + const requested = query.limit ?? DEFAULT_LIMIT; + return { + limit: Math.min(Math.max(requested, 1), MAX_LIMIT), + cursor: query.cursor, + }; +} + +/** + * Page a staged selection, which is a list in memory rather than a table. + * + * The cursor anchors on the last group of the previous page rather than on its + * index: the editor can reorder or drop entries between one page and the next, + * and an index would then silently skip or repeat. An anchor that is no longer + * in the selection means the entries it pointed past are gone, so the walk ends. + */ +function pageStagedGroups( + groups: string[], + options: { limit: number; cursor?: string }, +): PageOfGroups { + let start = 0; + if (options.cursor) { + const decoded = decodeCursor(options.cursor); + // A cursor from the link table anchors on an edge row id, which is not a + // group and would match nothing. That happens when a preview session opens + // mid-pagination over the published selection, so restart the field rather + // than handing back an empty page that reads as "no more". + if (decoded.orderValue === STAGED_CURSOR_MARKER) { + const index = groups.indexOf(decoded.id); + if (index === -1) return { groups: [] }; + start = index + 1; + } + } + const page = groups.slice(start, start + options.limit); + const last = page.at(-1); + const nextCursor = + last && start + page.length < groups.length + ? encodeCursor(STAGED_CURSOR_MARKER, last) + : undefined; + return { groups: page, nextCursor }; +} + +/** + * Pick the locale variant matching `locale`, falling back to the first (lowest + * locale code). A link names a translation group, not one locale's row of it, + * so a target that exists only in another locale is still a real reference. + * + * Mirrors `pickVariant` in `api/handlers/relations.ts`, which answers the same + * question for the admin's resolved refs. + */ +function pickVariant(variants: LoadedEntry[], locale: string | null): LoadedEntry | undefined { + if (locale === null) return variants[0]; + return variants.find((entry) => entry.data.locale === locale) ?? variants[0]; +} + +export async function resolveReferencePages( + options: ResolveReferencesOptions, +): Promise<Record<string, ResolvedReferencePage>> { + const fieldMap = await getReferenceFieldMap(options.collection); + const requested = Object.entries(options.selection).filter( + (entry): entry is [string, ReferenceQuery] => entry[1] !== undefined && fieldMap.has(entry[0]), + ); + if (requested.length === 0) return {}; + + const db = await getDb(); + const relations = new RelationRepository(db); + + const staged = + options.serveDrafts && options.draftRevisionId + ? readStagedReferences( + (await new RevisionRepository(db).findById(options.draftRevisionId))?.data, + ) + : undefined; + + // Phase one: each field's page of translation groups, in selection order. + // Concurrent, like phase two — the fields are independent, and awaiting them + // in turn would make N fields N sequential round trips. + const pages = new Map( + await Promise.all( + requested.map(async ([slug, query]): Promise<[string, PageOfGroups]> => { + const binding = fieldMap.get(slug)!; + const page = pageOptions(query); + + const stagedGroups = staged?.[slug]; + if (stagedGroups) return [slug, pageStagedGroups(stagedGroups, page)]; + + const links = + binding.side === "child" + ? await relations.getParentsPageById(binding.relationId, options.entryGroup, page) + : await relations.getChildrenPageById(binding.relationId, options.entryGroup, page); + return [ + slug, + { + groups: links.items.map((link) => + binding.side === "child" ? link.parentGroup : link.childGroup, + ), + nextCursor: links.nextCursor, + }, + ]; + }), + ), + ); + + // Phase two: one entry read per distinct target collection, however many + // fields point at it. + const groupsByCollection = new Map<string, Set<string>>(); + for (const [slug, page] of pages) { + const target = fieldMap.get(slug)!.targetCollection; + const groups = groupsByCollection.get(target) ?? new Set<string>(); + for (const group of page.groups) groups.add(group); + groupsByCollection.set(target, groups); + } + + const variantsByCollection = new Map<string, Map<string, LoadedEntry[]>>(); + await Promise.all( + Array.from(groupsByCollection, async ([collection, groups]) => { + const loaded = await loadEntriesByGroups(collection, [...groups], { + publishedOnly: !options.serveDrafts, + }); + const byGroup = new Map<string, LoadedEntry[]>(); + for (const entry of loaded) { + const group = entry.data.translationGroup; + if (typeof group !== "string") continue; + const variants = byGroup.get(group); + if (variants) variants.push(entry); + else byGroup.set(group, [entry]); + } + variantsByCollection.set(collection, byGroup); + }), + ); + + // Phase three: rebuild each field in link order. A group with no surviving + // variant — deleted, or unpublished for a render that may not see drafts — + // drops out, exactly as a dangling link does. + const resolved: Record<string, ResolvedReferencePage> = {}; + for (const [slug, page] of pages) { + const collection = fieldMap.get(slug)!.targetCollection; + const byGroup = variantsByCollection.get(collection); + const entries: LoadedEntry[] = []; + for (const group of page.groups) { + const variant = pickVariant(byGroup?.get(group) ?? [], options.locale); + if (variant) entries.push(variant); + } + resolved[slug] = page.nextCursor + ? { collection, entries, nextCursor: page.nextCursor } + : { collection, entries }; + } + return resolved; +} diff --git a/packages/core/src/references/types.ts b/packages/core/src/references/types.ts new file mode 100644 index 0000000000..96642218cc --- /dev/null +++ b/packages/core/src/references/types.ts @@ -0,0 +1,16 @@ +/** + * How much of one reference field's selection a render wants. + * + * `true` is the first page at the default limit — the common case, where a + * field holds one entry or a handful. The object form is for a field that can + * hold many, and for walking past the first page with the cursor the previous + * page returned. + */ +export type ReferenceQuery = true | { limit?: number; cursor?: string }; + +/** + * The selection a caller opts into, by field slug. A field mapped to + * `undefined` is not requested, so a render can name one conditionally without + * building the object in two branches. + */ +export type ReferenceSelection = Record<string, ReferenceQuery | undefined>; diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 100d2fa468..4381737671 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -10,7 +10,12 @@ import { sql } from "kysely"; import { ulid } from "ulidx"; import { refreshDevTypes } from "../astro/dev-typegen.js"; -import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js"; +import { + columnExists, + currentTimestamp, + listTablesLike, + tableExists, +} from "../database/dialect-helpers.js"; import { withTransaction } from "../database/transaction.js"; import type { CollectionTable, Database, FieldTable } from "../database/types.js"; import { validateIdentifier } from "../database/validate.js"; @@ -46,8 +51,10 @@ import { type UpdateFieldInput, type CollectionWithFields, type FieldType, + type FieldValidation, FIELD_TYPE_TO_COLUMN, isIndexableFieldType, + isStoragelessField, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS, } from "./types.js"; @@ -114,10 +121,21 @@ const UNORDERED_COLLECTION_RANK = 2147483647; */ const collectionOrder = sql<number>`coalesce(sort_order, ${sql.lit(UNORDERED_COLLECTION_RANK)})`; -function assertIndexableField(type: FieldType, indexed: boolean | undefined, slug: string): void { - if (indexed && !isIndexableFieldType(type)) { +function assertIndexableField( + field: { type: FieldType; validation?: FieldValidation | null }, + indexed: boolean | undefined, + slug: string, +): void { + if (!indexed) return; + if (!isIndexableFieldType(field.type)) { + throw new SchemaError( + `Field "${slug}" cannot be indexed because type "${field.type}" is not a scalar query type`, + "FIELD_NOT_INDEXABLE", + ); + } + if (isStoragelessField(field)) { throw new SchemaError( - `Field "${slug}" cannot be indexed because type "${type}" is not a scalar query type`, + `Field "${slug}" cannot be indexed because it stores no column to index`, "FIELD_NOT_INDEXABLE", ); } @@ -537,7 +555,7 @@ export class SchemaRegistry { const fieldSlugs = new Set<string>(); for (const field of fields) { this.validateSlug(field.slug, "field"); - assertIndexableField(field.type, field.indexed, field.slug); + assertIndexableField(field, field.indexed, field.slug); if (RESERVED_FIELD_SLUGS.includes(field.slug)) { throw new SchemaError(`Field slug "${field.slug}" is reserved`, "RESERVED_SLUG"); } @@ -942,7 +960,7 @@ export class SchemaRegistry { const id = ulid(); const columnType = FIELD_TYPE_TO_COLUMN[input.type]; - assertIndexableField(input.type, input.indexed, input.slug); + assertIndexableField(input, input.indexed, input.slug); // Get max sort order const maxSort = await this.db @@ -985,17 +1003,21 @@ export class SchemaRegistry { .execute(); schemaMutated = true; - // Add column to content table — pass trx to stay on the same connection - await this.addColumn( - collectionSlug, - input.slug, - input.type, - { - required: input.required, - defaultValue: input.defaultValue, - }, - trx, - ); + // Add column to content table — pass trx to stay on the same connection. + // A storage-less field persists no column; its values live in a side + // table (see `isStoragelessField`). Insert the field row only. + if (!isStoragelessField(input)) { + await this.addColumn( + collectionSlug, + input.slug, + input.type, + { + required: input.required, + defaultValue: input.defaultValue, + }, + trx, + ); + } if (input.indexed) { await this.createFieldIndex(collectionSlug, id, input.slug, trx); @@ -1083,8 +1105,27 @@ export class SchemaRegistry { const field = this.mapFieldRow(fieldRow); const updates: Updateable<FieldTable> = {}; let nextType = field.type; + const nextValidation = input.validation !== undefined ? input.validation : field.validation; if (input.type !== undefined && input.type !== field.type) { + // A change into or out of storage-less is never a no-op column change: + // string -> reference both map to TEXT and would slip past the affinity + // check below, yet one has a column and the other does not. An unwired + // reference field is column-backed, so its refusal comes from the + // text-alias check instead. + const storagelessBefore = isStoragelessField(field); + const storagelessAfter = isStoragelessField({ + type: input.type, + validation: nextValidation, + }); + if (storagelessBefore !== storagelessAfter) { + throw new SchemaError( + `Cannot change field "${fieldSlug}" in collection "${collectionSlug}" between ` + + `storage-less and column-backed types ("${field.type}" -> "${input.type}").`, + "FIELD_TYPE_COLUMN_CHANGE", + ); + } + const newColumnType = FIELD_TYPE_TO_COLUMN[input.type]; if (newColumnType !== field.columnType) { throw new SchemaError( @@ -1156,7 +1197,11 @@ export class SchemaRegistry { if (input.options !== undefined) updates.options = JSON.stringify(input.options); if (input.sortOrder !== undefined) updates.sort_order = input.sortOrder; - assertIndexableField(nextType, input.indexed ?? field.indexed, fieldSlug); + assertIndexableField( + { type: nextType, validation: nextValidation }, + input.indexed ?? field.indexed, + fieldSlug, + ); if (Object.keys(updates).length === 0) return field; activeCoverageInvalidated = await invalidateContentMediaUsageSchemaChange( @@ -1328,8 +1373,19 @@ export class SchemaRegistry { await this.dropFieldIndex(field.id, trx); } - // Drop column from content table — safe now because FTS triggers are gone - await this.dropColumn(collectionSlug, fieldSlug, trx); + // Drop column from content table — safe now because FTS triggers are gone. + // Whether a field is storage-less is a property of the row rather than of + // its type: reference fields created before they became storage-less + // still carry a column, and skipping the DDL would strand it and block + // the slug from ever being reused. + const hasColumn = await columnExists( + trx, + this.getTableName(collectionSlug), + this.getColumnName(fieldSlug), + ); + if (hasColumn) { + await this.dropColumn(collectionSlug, fieldSlug, trx); + } }); if (activeCoverageInvalidated) { await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); @@ -1469,6 +1525,8 @@ export class SchemaRegistry { if (options.ifNotExists) table = table.ifNotExists(); for (const field of fields) { + if (isStoragelessField(field)) continue; + const columnName = this.getColumnName(field.slug); const columnType = COLUMN_TYPE_TO_DATA_TYPE[FIELD_TYPE_TO_COLUMN[field.type]]; table = table.addColumn(columnName, columnType, (column) => { diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a3bb0b2ea7..6974b4cc17 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -92,6 +92,48 @@ export const FIELD_TYPE_TO_COLUMN: Record<FieldType, ColumnType> = { repeater: "JSON", }; +/** + * Field types that *can* persist no `ec_*` column — see `isStoragelessField` + * for whether a given row actually does. The `FIELD_TYPE_TO_COLUMN` entry above + * is retained deliberately: it doubles as the `isFieldType` guard. + */ +export const STORAGELESS_FIELD_TYPES: ReadonlySet<string> = new Set<FieldType>(["reference"]); + +/** + * Whether a field row keeps its values outside the content table. + * + * Storage-less is a property of the row, not of the type. A `reference` field + * is storage-less once it is bound to a relation: the selection lives as edges + * in `_emdash_content_references`. A reference field created before relations + * existed — or one whose target collection could not be resolved — still owns a + * TEXT column holding an entry id, and behaves like a string field until + * something wires it. + */ +export function isStoragelessField(field: { + type: string; + validation?: FieldValidation | null; +}): boolean { + if (!STORAGELESS_FIELD_TYPES.has(field.type)) return false; + return typeof field.validation?.relation === "string" && field.validation.relation.length > 0; +} + +/** + * `isStoragelessField` for a raw `_emdash_fields` row, whose `validation` is + * unparsed JSON. Malformed JSON reads as unwired: a field nothing can resolve a + * relation for keeps its column. + */ +export function isStoragelessFieldRow(row: { type: string; validation: string | null }): boolean { + if (!STORAGELESS_FIELD_TYPES.has(row.type) || !row.validation) return false; + let parsed: unknown; + try { + parsed = JSON.parse(row.validation); + } catch { + return false; + } + if (typeof parsed !== "object" || parsed === null) return false; + return isStoragelessField({ type: row.type, validation: parsed }); +} + /** * Features a collection can support */ @@ -159,6 +201,19 @@ export interface FieldValidation { minItems?: number; // For repeater fields maxItems?: number; // For repeater fields allowedMimeTypes?: string[]; + /** Reference fields: the relation's slug. */ + relation?: string; + /** + * Reference fields: which end of the relation this collection sits on. + * `parent` picks children and controls their order; `child` picks parents + * and is unordered, since `sort_order` is scoped to a parent. + */ + relationSide?: "parent" | "child"; + /** Reference fields: the collection on the *other* end (derived from the + * relation and the side, denormalized here). */ + targetCollection?: string; + /** Reference fields: allow selecting more than one entry (UI constraint). */ + multiple?: boolean; } /** @@ -404,6 +459,9 @@ export const RESERVED_COLLECTION_SLUGS = [ // Shadowed by the static POST /schema/collections/reorder route: a // collection with this slug could never be addressed at its own URL. "reorder", + // Shadowed by the static /content-types/relations admin route, for the same + // reason: a collection with this slug would be unreachable in the admin. + "relations", ]; /** diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index 7ac2e4ff4d..366eff6862 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -1,7 +1,13 @@ import { z, type ZodType } from "zod"; import { hashString } from "../utils/hash.js"; -import type { CollectionWithFields, Field, FieldType, RepeaterSubField } from "./types.js"; +import { + isStoragelessField, + type CollectionWithFields, + type Field, + type FieldType, + type RepeaterSubField, +} from "./types.js"; /** Pattern to split on underscores, hyphens, and spaces for PascalCase conversion */ const PASCAL_CASE_SPLIT_PATTERN = /[_\-\s]+/; @@ -18,6 +24,7 @@ export function generateZodSchema( const shape: Record<string, ZodType> = {}; for (const field of collection.fields) { + if (isStoragelessField(field)) continue; shape[field.slug] = generateFieldSchema(field); } @@ -338,6 +345,10 @@ export function generateTypeScript( lines.push(` status: string;`); for (const field of collection.fields) { + // A storage-less field holds no value in `data`; a reference field bound to + // a relation resolves through `references` instead. One that predates + // relations still owns its column, so it stays an entry id string. + if (isStoragelessField(field)) continue; const tsType = fieldTypeToTypeScript(field); const optional = field.required ? "" : "?"; lines.push(` ${field.slug}${optional}: ${tsType};`); @@ -375,12 +386,17 @@ export function generateTypesFile(collections: CollectionWithFields[]): string { c.fields.some((f) => f.type === "portableText"), ); + const withReferences = collections.filter((c) => c.fields.some(isStoragelessField)); + // Build imports - ContentBylineCredit and TaxonomyTerm are always needed // for the hydrated bylines/terms fields const imports = ["ContentBylineCredit", "TaxonomyTerm"]; if (needsPortableText) { imports.push("PortableTextBlock"); } + if (withReferences.length > 0) { + imports.push("ReferencePage"); + } lines.push(`import type { ${imports.join(", ")} } from "emdash";`); lines.push(``); @@ -388,6 +404,14 @@ export function generateTypesFile(collections: CollectionWithFields[]): string { // (e.g. `book` and `books` both -> `Book`), so resolve collisions up front // to keep every interface identifier unique within the file. const interfaceNames = uniqueInterfaceNames(collections); + // `{Collection}References`, built on an already-unique name. No slug can + // produce a data interface name ending in `References` -- `singularize` + // always strips the trailing `s` of a final `references` segment -- so the + // suffix cannot collide with one. + const referenceInterfaces: [CollectionWithFields, string][] = withReferences.map((c) => [ + c, + `${interfaceNames.get(c.slug)}References`, + ]); // Generate individual interfaces for (const collection of collections) { @@ -395,6 +419,11 @@ export function generateTypesFile(collections: CollectionWithFields[]): string { lines.push(``); } + for (const [collection, name] of referenceInterfaces) { + lines.push(generateReferencesTypeScript(collection, name, interfaceNames)); + lines.push(``); + } + // Generate the Collections interface for module augmentation lines.push(`declare module "emdash" {`); lines.push(` interface EmDashCollections {`); @@ -402,11 +431,48 @@ export function generateTypesFile(collections: CollectionWithFields[]): string { lines.push(` ${collection.slug}: ${interfaceNames.get(collection.slug)};`); } lines.push(` }`); + if (referenceInterfaces.length > 0) { + lines.push(` interface EmDashCollectionReferences {`); + for (const [collection, name] of referenceInterfaces) { + lines.push(` ${collection.slug}: ${name};`); + } + lines.push(` }`); + } lines.push(`}`); return lines.join("\n"); } +/** + * Generate the references interface for one collection: a page per reference + * field bound to a relation, keyed by field slug, which is how + * `getEmDashEntry({ references })` both asks for and returns them. + * + * A field is always present on the interface, not optional -- a selection that + * names it always yields a page, empty when nothing is linked. The narrowing in + * `getEmDashEntry` picks out the fields the caller asked for. + */ +function generateReferencesTypeScript( + collection: CollectionWithFields, + interfaceName: string, + interfaceNames: Map<string, string>, +): string { + const lines: string[] = [`export interface ${interfaceName} {`]; + + for (const field of collection.fields) { + if (!isStoragelessField(field)) continue; + // A target outside this file (dropped collection, or a relation whose + // other end has not been created) leaves the page's data un-narrowed + // rather than referring to an identifier that does not exist. + const target = field.validation?.targetCollection; + const targetName = target ? interfaceNames.get(target) : undefined; + lines.push(` ${field.slug}: ReferencePage${targetName ? `<${targetName}>` : ""};`); + } + + lines.push(`}`); + return lines.join("\n"); +} + /** * Generate schema hash for cache invalidation */ diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index ee95411804..46f0b48188 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -10,6 +10,8 @@ import type { Kysely } from "kysely"; import mime from "mime/lite"; import { ulid } from "ulidx"; +import { setReferenceSelection } from "../api/handlers/relations.js"; +import { bindReferenceField, createFieldRelation } from "../api/handlers/schema.js"; import { BylineRepository } from "../database/repositories/byline.js"; import { ContentRepository } from "../database/repositories/content.js"; import { MediaRepository } from "../database/repositories/media.js"; @@ -22,15 +24,19 @@ import type { MediaValue } from "../fields/types.js"; import { getI18nConfig, resolveConfiguredLocale } from "../i18n/config.js"; import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js"; import { markContentMediaUsageCollectionStaleSafely } from "../media/usage/content-refresh.js"; -import { SchemaRegistry } from "../schema/registry.js"; +import { SchemaError, SchemaRegistry } from "../schema/registry.js"; +import type { Field } from "../schema/types.js"; import { FTSManager } from "../search/fts-manager.js"; import { setSiteSettings } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; +import { chunks } from "../utils/chunks.js"; import type { SeedFile, + SeedField, SeedApplyOptions, SeedApplyResult, SeedCollection, + SeedRelation, SeedTaxonomyTerm, SeedMenuItem, SeedWidget, @@ -54,6 +60,8 @@ async function applyDisplayDateFields( } const FILE_EXTENSION_PATTERN = /\.([a-z0-9]+)(?:\?|$)/i; +const SEED_RELATION_NAME_MAX_ATTEMPTS = 5; +const SEED_RELATION_INSERT_BATCH_SIZE = 10; import { validateSeed } from "./validate.js"; /** Pattern to remove file extensions */ @@ -100,6 +108,7 @@ export async function applySeed( const result: SeedApplyResult = { collections: { created: 0, skipped: 0, updated: 0 }, fields: { created: 0, skipped: 0, updated: 0 }, + relations: { created: 0, skipped: 0, updated: 0 }, taxonomies: { created: 0, terms: 0 }, bylines: { created: 0, skipped: 0, updated: 0 }, menus: { created: 0, items: 0 }, @@ -121,12 +130,13 @@ export async function applySeed( // Apply order (critical for foreign keys and references): // 1. Site settings - // 2. Collections + Fields - // 3. Taxonomy definitions + Terms - // 4. Content (so menu refs can resolve) - // 5. Menus + Menu items (can now resolve content refs) - // 6. Redirects - // 7. Widget areas + Widgets + // 2. Relations (so the fields that name them can resolve) + // 3. Collections + Fields + // 4. Taxonomy definitions + Terms + // 5. Content (so menu refs can resolve) + // 6. Menus + Menu items (can now resolve content refs) + // 7. Redirects + // 8. Widget areas + Widgets // Track seed content IDs for reference resolution (shared across content and menus) const seedIdMap = new Map<string, string>(); // seed id -> real entry id @@ -174,9 +184,28 @@ export async function applySeed( result.settings.applied = Object.keys(seed.settings).length; } - // 2-3. Collections and Fields + // 2. Declared relations, before the fields that name them + if (seed.relations) { + await applySeedRelations(db, seed.relations, seed.collections ?? [], onConflict, result); + } + + // 3. Collections and Fields if (seed.collections) { const registry = new SchemaRegistry(db); + const seedCollectionSlugs = new Set(seed.collections.map((collection) => collection.slug)); + const knownRelations = await readRelationEnds(db); + const relationSlugs = new Set(knownRelations.keys()); + const externalTargetExists = new Map<string, boolean>(); + const pendingRelations: Array<{ + id: string; + slug: string; + parent_collection: string; + child_collection: string; + parent_label: string; + parent_label_singular: string | null; + child_label: string; + max_children_per_parent: number | null; + }> = []; for (const collection of seed.collections) { // Check if collection exists @@ -207,36 +236,9 @@ export async function applySeed( // Update or create fields for (const field of collection.fields) { const existingField = await registry.getField(collection.slug, field.slug); - if (existingField) { - await registry.updateField(collection.slug, field.slug, { - label: field.label, - type: field.type, - required: field.required || false, - unique: field.unique || false, - searchable: field.searchable || false, - indexed: field.indexed || false, - defaultValue: field.defaultValue, - validation: field.validation, - widget: field.widget, - options: field.options, - }); - result.fields.updated++; - } else { - await registry.createField(collection.slug, { - slug: field.slug, - label: field.label, - type: field.type, - required: field.required || false, - unique: field.unique || false, - searchable: field.searchable || false, - indexed: field.indexed || false, - defaultValue: field.defaultValue, - validation: field.validation, - widget: field.widget, - options: field.options, - }); - result.fields.created++; - } + await upsertSeedField(db, collection.slug, field, existingField, knownRelations); + if (existingField) result.fields.updated++; + else result.fields.created++; } // Second write: display/date fields, now that fields exist. @@ -250,19 +252,70 @@ export async function applySeed( continue; } - const fields = collection.fields.map((field) => ({ - slug: field.slug, - label: field.label, - type: field.type, - required: field.required || false, - unique: field.unique || false, - searchable: field.searchable || false, - indexed: field.indexed || false, - defaultValue: field.defaultValue, - validation: field.validation, - widget: field.widget, - options: field.options, - })); + const fields = []; + for (const field of collection.fields) { + let fieldValidation = field.validation; + // A field naming a relation binds to it; one naming only a target + // collection gets a relation created for it below. + const bound = resolveSeedFieldRelation(collection.slug, field, knownRelations); + const targetCollection = + !bound && + field.type === "reference" && + typeof fieldValidation?.targetCollection === "string" + ? fieldValidation.targetCollection + : undefined; + if (bound) { + fieldValidation = { ...fieldValidation, ...bound }; + } + if (targetCollection) { + let targetExists = seedCollectionSlugs.has(targetCollection); + if (!targetExists) { + targetExists = externalTargetExists.get(targetCollection) ?? false; + if (!externalTargetExists.has(targetCollection)) { + targetExists = Boolean(await registry.getCollection(targetCollection)); + externalTargetExists.set(targetCollection, targetExists); + } + } + if (!targetExists) { + throw new SchemaError( + `Target collection "${targetCollection}" not found`, + "COLLECTION_NOT_FOUND", + ); + } + + const relationId = ulid(); + const relationSlug = allocateSeedRelationName(collection.slug, field.slug, relationSlugs); + pendingRelations.push({ + id: relationId, + slug: relationSlug, + parent_collection: collection.slug, + child_collection: targetCollection, + parent_label: collection.label, + parent_label_singular: collection.labelSingular ?? null, + child_label: field.label, + max_children_per_parent: fieldValidation?.multiple ? null : 1, + }); + fieldValidation = { + ...fieldValidation, + relation: relationSlug, + relationSide: "parent", + }; + } + + fields.push({ + slug: field.slug, + label: field.label, + type: field.type, + required: field.required || false, + unique: field.unique || false, + searchable: field.searchable || false, + indexed: field.indexed || false, + defaultValue: field.defaultValue, + validation: fieldValidation, + widget: field.widget, + options: field.options, + }); + } // Create a fresh seed schema in bulk to stay within D1's query budget. await registry.createSeedCollection( @@ -287,7 +340,11 @@ export async function applySeed( // the schema exists. await applyDisplayDateFields(registry, collection); result.collections.created++; - result.fields.created += fields.length; + result.fields.created += collection.fields.length; + } + + for (const relationBatch of chunks(pendingRelations, SEED_RELATION_INSERT_BATCH_SIZE)) { + await db.insertInto("_emdash_relations").values(relationBatch).execute(); } } @@ -498,6 +555,7 @@ export async function applySeed( for (const [collectionSlug, entries] of Object.entries(seed.content)) { const collectionRoutable = (await schemaRegistry.getCollection(collectionSlug))?.routable !== false; + const referenceFields = await referenceFieldsOf(schemaRegistry, collectionSlug); for (const entry of entries) { const entrySlug = typeof entry.slug === "string" && entry.slug.trim().length > 0 ? entry.slug : null; @@ -527,6 +585,13 @@ export async function applySeed( mediaContext, result, ); + // Reference fields are storage-less — route their resolved values to + // edges and keep them out of the column/revision data. + const { columnData, edges } = splitReferenceFields( + collectionSlug, + referenceFields, + resolvedData, + ); // Update content + bylines + taxonomies atomically const status = entry.status || "published"; @@ -539,7 +604,7 @@ export async function applySeed( await trxContentRepo.update(collectionSlug, existing.id, { status, - data: resolvedData, + data: columnData, }); contentMutated = true; @@ -552,6 +617,7 @@ export async function applySeed( true, ); await applyContentTaxonomies(trx, collectionSlug, existing.id, entry, true); + await applyContentReferences(trx, collectionSlug, existing.id, edges); // Seed is declarative — when status is "published", promote to a live // revision so the admin UI shows "Unpublish" instead of "Save & Publish" @@ -564,7 +630,7 @@ export async function applySeed( const draft = await trxRevisionRepo.create({ collection: collectionSlug, entryId: existing.id, - data: resolvedData, + data: columnData, }); try { await trxContentRepo.setDraftRevision(collectionSlug, existing.id, draft.id); @@ -613,6 +679,13 @@ export async function applySeed( // Resolve $ref and $media in data const resolvedData = await resolveReferences(entry.data, seedIdMap, mediaContext, result); + // Reference fields are storage-less — route their resolved values to + // edges and keep them out of the column/revision data. + const { columnData, edges } = splitReferenceFields( + collectionSlug, + referenceFields, + resolvedData, + ); // Resolve translationOf: map from seed-local ID to real EmDash ID let translationOf: string | undefined; @@ -641,7 +714,7 @@ export async function applySeed( type: collectionSlug, slug: entrySlug, status, - data: resolvedData, + data: columnData, locale: entryLocale, translationOf, publishedAt: status === "published" ? new Date().toISOString() : null, @@ -656,6 +729,7 @@ export async function applySeed( seedBylineIdMap, ); await applyContentTaxonomies(trx, collectionSlug, item.id, entry, false); + await applyContentReferences(trx, collectionSlug, item.id, edges); // Seed is declarative — when status is "published", promote to a live // revision so the admin UI shows "Unpublish" instead of "Save & Publish" @@ -929,6 +1003,179 @@ export async function applySeed( return result; } +/** Every relation the database knows, by slug, with the collections it joins. */ +type RelationEnds = Map<string, { parentCollection: string; childCollection: string }>; + +async function readRelationEnds(db: Kysely<Database>): Promise<RelationEnds> { + const rows = await db + .selectFrom("_emdash_relations") + .select(["slug", "parent_collection", "child_collection"]) + .execute(); + return new Map( + rows.map((row) => [ + row.slug, + { parentCollection: row.parent_collection, childCollection: row.child_collection }, + ]), + ); +} + +/** + * Resolve the relation a seed reference field names, and which end of it this + * collection sits on. + * + * `relationSide` is only needed for a self-referential relation, where both ends + * are this collection; otherwise the side follows from which end matches. + * `targetCollection` is the collection at the other end, derived rather than + * trusted, so a seed cannot declare a target the relation disagrees with. + */ +function resolveSeedFieldRelation( + collectionSlug: string, + field: SeedField, + knownRelations: RelationEnds, +): { relation: string; relationSide: "parent" | "child"; targetCollection: string } | null { + if (field.type !== "reference") return null; + const named = field.validation?.relation; + if (typeof named !== "string" || named.length === 0) return null; + + const relation = knownRelations.get(named); + if (!relation) { + throw new SchemaError(`Relation "${named}" not found`, "RELATION_NOT_FOUND"); + } + + const declared = field.validation?.relationSide; + const side = + declared === "parent" || declared === "child" + ? declared + : relation.parentCollection === collectionSlug + ? "parent" + : "child"; + + const end = side === "parent" ? relation.parentCollection : relation.childCollection; + if (end !== collectionSlug) { + throw new SchemaError( + `Relation "${named}" has no ${side} end on collection "${collectionSlug}"`, + "VALIDATION_ERROR", + ); + } + + return { + relation: named, + relationSide: side, + targetCollection: side === "parent" ? relation.childCollection : relation.parentCollection, + }; +} + +/** + * Create or update the relations a seed declares, before the fields that name + * them. + * + * A relation's two collections are fixed once it exists: changing one would + * leave its links pointing into a collection that is no longer an end of it, so + * a seed that names different ones fails rather than rewriting the row. Labels + * and limits are updated under `onConflict: "update"`. + */ +async function applySeedRelations( + db: Kysely<Database>, + relations: SeedRelation[], + seedCollections: SeedCollection[], + onConflict: "skip" | "update" | "error", + result: SeedApplyResult, +): Promise<void> { + const existing = await readRelationEnds(db); + const known = new Set(seedCollections.map((collection) => collection.slug)); + for (const row of await db.selectFrom("_emdash_collections").select("slug").execute()) { + known.add(row.slug); + } + + const now = new Date().toISOString(); + for (const relation of relations) { + for (const end of [relation.parentCollection, relation.childCollection]) { + if (!known.has(end)) { + throw new SchemaError( + `Relation "${relation.slug}" names collection "${end}", which does not exist`, + "COLLECTION_NOT_FOUND", + ); + } + } + + const current = existing.get(relation.slug); + if (current) { + if ( + current.parentCollection !== relation.parentCollection || + current.childCollection !== relation.childCollection + ) { + throw new SchemaError( + `Relation "${relation.slug}" joins ${current.parentCollection} to ${current.childCollection}; ` + + `a relation's collections cannot change`, + "RELATION_COLLECTIONS_IMMUTABLE", + ); + } + if (onConflict === "error") { + throw new Error(`Conflict: relation "${relation.slug}" already exists`); + } + if (onConflict !== "update") { + result.relations.skipped++; + continue; + } + await db + .updateTable("_emdash_relations") + .set({ + parent_label: relation.parentLabel, + parent_label_singular: relation.parentLabelSingular ?? null, + child_label: relation.childLabel, + child_label_singular: relation.childLabelSingular ?? null, + max_children_per_parent: relation.maxChildrenPerParent ?? null, + max_parents_per_child: relation.maxParentsPerChild ?? null, + updated_at: now, + }) + .where("slug", "=", relation.slug) + .execute(); + result.relations.updated++; + continue; + } + + await db + .insertInto("_emdash_relations") + .values({ + id: ulid(), + slug: relation.slug, + parent_collection: relation.parentCollection, + child_collection: relation.childCollection, + parent_label: relation.parentLabel, + parent_label_singular: relation.parentLabelSingular ?? null, + child_label: relation.childLabel, + child_label_singular: relation.childLabelSingular ?? null, + max_children_per_parent: relation.maxChildrenPerParent ?? null, + max_parents_per_child: relation.maxParentsPerChild ?? null, + created_at: now, + updated_at: now, + }) + .execute(); + existing.set(relation.slug, { + parentCollection: relation.parentCollection, + childCollection: relation.childCollection, + }); + result.relations.created++; + } +} + +function allocateSeedRelationName( + collectionSlug: string, + fieldSlug: string, + usedNames: Set<string>, +): string { + const baseName = `${collectionSlug}_${fieldSlug}`.slice(0, 63); + for (let attempt = 0; attempt < SEED_RELATION_NAME_MAX_ATTEMPTS; attempt++) { + const suffix = attempt === 0 ? "" : `_${attempt + 1}`; + const name = attempt === 0 ? baseName : `${baseName.slice(0, 63 - suffix.length)}${suffix}`; + if (!usedNames.has(name)) { + usedNames.add(name); + return name; + } + } + throw new SchemaError("Could not allocate a unique relation name", "RELATION_NAME_CONFLICT"); +} + /** * Apply hierarchical taxonomy terms (parents before children) */ @@ -1062,6 +1309,212 @@ async function applyContentBylines( * Apply taxonomy term assignments to a content entry. * In update mode, clears existing assignments before re-attaching. */ +/** + * Create or update a field from a seed. + * + * A reference field bound to a relation is storage-less: it persists no column + * and its edges live in `_emdash_content_references`. Seeds create fields + * through the registry (not the schema handler that owns the relation + * lifecycle), so this mirrors the handler — it creates the relation on first + * insert (field + relation in one transaction) and preserves the + * server-assigned `validation.relation`/`targetCollection` on re-apply, since a + * seed's field validation omits them and would otherwise orphan the relation. A + * reference field with no `targetCollection` cannot form a relation, so it is + * created column-backed, holding a plain entry id. + */ +async function upsertSeedField( + db: Kysely<Database>, + collectionSlug: string, + field: SeedField, + existing: Field | null, + knownRelations: RelationEnds, +): Promise<void> { + const bound = resolveSeedFieldRelation(collectionSlug, field, knownRelations); + + if (existing) { + const update = { + label: field.label, + type: field.type, + required: field.required || false, + unique: field.unique || false, + searchable: field.searchable || false, + indexed: field.indexed || false, + defaultValue: field.defaultValue, + widget: field.widget, + options: field.options, + }; + + // A reference field from before relations existed has no relation to + // preserve, so a seed naming a target binds it the way the admin does + // rather than leaving it unbound forever. + if ( + !bound && + field.type === "reference" && + !existing.validation?.relation && + typeof field.validation?.targetCollection === "string" + ) { + await bindReferenceField( + db, + collectionSlug, + existing, + { ...update, validation: field.validation }, + field.validation.targetCollection, + ); + return; + } + + // A field naming a relation binds to that one. Otherwise keep whatever + // relation the field is already bound to: a seed's field validation omits + // the server-assigned keys and would orphan the relation row. + const validation = bound + ? { ...field.validation, ...bound } + : field.type === "reference" && existing.validation?.relation + ? { + ...field.validation, + relation: existing.validation.relation, + relationSide: existing.validation.relationSide, + targetCollection: existing.validation.targetCollection, + } + : field.validation; + const registry = new SchemaRegistry(db); + await registry.updateField(collectionSlug, field.slug, { ...update, validation }); + return; + } + + const input = { + slug: field.slug, + label: field.label, + type: field.type, + required: field.required || false, + unique: field.unique || false, + searchable: field.searchable || false, + indexed: field.indexed || false, + defaultValue: field.defaultValue, + validation: field.validation, + widget: field.widget, + options: field.options, + }; + + if (bound) { + const registry = new SchemaRegistry(db); + await registry.createField(collectionSlug, { + ...input, + validation: { ...field.validation, ...bound }, + }); + return; + } + + const targetCollection = + field.type === "reference" && typeof field.validation?.targetCollection === "string" + ? field.validation.targetCollection + : undefined; + + if (targetCollection) { + await withTransaction(db, async (trx) => { + const relation = await createFieldRelation( + trx, + collectionSlug, + field.slug, + field.label, + targetCollection, + field.validation?.multiple ? null : 1, + ); + const registry = new SchemaRegistry(trx); + await registry.createField(collectionSlug, { + ...input, + validation: { + ...field.validation, + relation: relation.slug, + relationSide: "parent" as const, + }, + }); + }); + return; + } + + const registry = new SchemaRegistry(db); + await registry.createField(collectionSlug, input); +} + +/** + * Split resolved content `data` into the plain column data and the reference + * edge writes. A reference field bound to a relation is storage-less, so its key + * left in `data` would hit the column writer (and `syncDataColumns` on publish) + * and throw "no such column". Its `$ref:`-resolved value — a child entry id or + * an array of them — is captured as an edge write instead, keyed by the field's + * relation. A reference field with no relation still owns its column, so its + * resolved id is written there like any other string. + */ +/** One collection's reference fields by slug, read once per collection: the + * schema phase has finished by the time content is applied. */ +async function referenceFieldsOf( + registry: SchemaRegistry, + collectionSlug: string, +): Promise<Map<string, Field>> { + const collection = await registry.getCollectionWithFields(collectionSlug); + return new Map( + (collection?.fields ?? []).filter((f) => f.type === "reference").map((f) => [f.slug, f]), + ); +} + +function splitReferenceFields( + collectionSlug: string, + referenceFields: Map<string, Field>, + data: Record<string, unknown>, +): { + columnData: Record<string, unknown>; + edges: Array<{ fieldSlug: string; childIds: string[] }>; +} { + if (referenceFields.size === 0) return { columnData: data, edges: [] }; + + const columnData: Record<string, unknown> = {}; + const edges: Array<{ fieldSlug: string; childIds: string[] }> = []; + for (const [key, value] of Object.entries(data)) { + const field = referenceFields.get(key); + if (!field?.validation?.relation) { + columnData[key] = value; + continue; + } + const childIds: string[] = []; + for (const candidate of Array.isArray(value) ? value : [value]) { + if (typeof candidate !== "string" || candidate.length === 0) continue; + // `seedIdMap` fills forward-only, so a reference pointing at a collection + // emitted later in the file arrives here unresolved. + if (candidate.startsWith("$ref:")) { + console.warn( + `content.${collectionSlug}: reference "${candidate}" in field "${key}" did not resolve (not yet created or missing). Skipping.`, + ); + continue; + } + childIds.push(candidate); + } + edges.push({ fieldSlug: key, childIds }); + } + return { columnData, edges }; +} + +/** + * Write reference edges for a content entry, replacing any existing set per + * relation (so re-applying a seed is idempotent). Throws to abort the enclosing + * transaction if a child entry cannot be resolved — a half-written entry is + * worse than a failed apply. + */ +async function applyContentReferences( + trx: Kysely<Database>, + collectionSlug: string, + contentId: string, + edges: Array<{ fieldSlug: string; childIds: string[] }>, +): Promise<void> { + for (const { fieldSlug, childIds } of edges) { + const result = await setReferenceSelection(trx, collectionSlug, contentId, fieldSlug, childIds); + if (!result.success) { + throw new Error( + `content.${collectionSlug}: failed to write references for "${contentId}": ${result.error.message}`, + ); + } + } +} + async function applyContentTaxonomies( db: Kysely<Database>, collectionSlug: string, diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index 449970a9e0..6b1a860df4 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -41,6 +41,9 @@ export interface SeedFile { /** Collection definitions */ collections?: SeedCollection[]; + /** Relations joining two collections, which reference fields bind to */ + relations?: SeedRelation[]; + /** Taxonomy definitions */ taxonomies?: SeedTaxonomy[]; @@ -98,6 +101,34 @@ export interface SeedCollection { fields: SeedField[]; } +/** + * Relation definition in seed. + * + * A relation joins two collections and owns the link set a reference field + * views. Declaring it here names it — the slug is how a field addresses it — and + * lets a field on either collection bind to it. A reference field that names + * only a `targetCollection` gets a relation created for it instead, which stays + * the shorter path for a one-sided link. + * + * Labels are single-valued: a relation is schema, like a collection or a field, + * and carries no locale. + */ +export interface SeedRelation { + slug: string; + parentCollection: string; + childCollection: string; + /** Names the parent's role, as seen from the child. */ + parentLabel: string; + parentLabelSingular?: string; + /** Names the child's role, as seen from the parent. */ + childLabel: string; + childLabelSingular?: string; + /** How many children one parent may link. Omitted or `null` is unlimited. */ + maxChildrenPerParent?: number | null; + /** How many parents one child may link. Omitted or `null` is unlimited. */ + maxParentsPerChild?: number | null; +} + /** * Field definition in seed */ @@ -347,6 +378,7 @@ export interface SeedApplyOptions { export interface SeedApplyResult { collections: { created: number; skipped: number; updated: number }; fields: { created: number; skipped: number; updated: number }; + relations: { created: number; skipped: number; updated: number }; taxonomies: { created: number; terms: number }; bylines: { created: number; skipped: number; updated: number }; menus: { created: number; items: number }; diff --git a/packages/core/src/seed/validate.ts b/packages/core/src/seed/validate.ts index 71642692fa..1f38b724a2 100644 --- a/packages/core/src/seed/validate.ts +++ b/packages/core/src/seed/validate.ts @@ -9,6 +9,8 @@ import { FIELD_TYPES, isIndexableFieldType, MAX_COLLECTION_LIST_COLUMNS } from " import type { SeedFile, SeedMenuItem, ValidationResult } from "./types.js"; const COLLECTION_FIELD_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/; +/** Matches `SchemaRegistry.validateSlug`, which collection and field slugs go through. */ +const MAX_SLUG_LENGTH = 63; const SLUG_PATTERN = /^[a-z0-9-]+$/; const REDIRECT_TYPES = new Set([301, 302, 307, 308]); const CRLF_PATTERN = /[\r\n]/; @@ -191,6 +193,17 @@ export function validateSeed(data: unknown): ValidationResult { errors.push(`${fieldPrefix}.type: unsupported field type "${field.type}"`); } else if (field.indexed === true && !isIndexableFieldType(field.type)) { errors.push(`${fieldPrefix}.indexed: type "${field.type}" cannot be indexed`); + } else if ( + field.indexed === true && + field.type === "reference" && + typeof field.validation?.targetCollection === "string" + ) { + // A targetCollection makes this field storage-less on apply: its + // selection becomes relation edges, leaving no column to index. + // Without one it stays a plain entry-id column, which can be. + errors.push( + `${fieldPrefix}.indexed: a reference field with a targetCollection stores no column to index`, + ); } } } @@ -198,6 +211,65 @@ export function validateSeed(data: unknown): ValidationResult { } } + // Validate relations + if (seed.relations) { + if (!Array.isArray(seed.relations)) { + errors.push("relations must be an array"); + } else { + const relationSlugs = new Set<string>(); + + for (let i = 0; i < seed.relations.length; i++) { + const relation = seed.relations[i]; + const prefix = `relations[${i}]`; + if (!relation) continue; + + if (!relation.slug) { + errors.push(`${prefix}: slug is required`); + } else { + if (!COLLECTION_FIELD_SLUG_PATTERN.test(relation.slug)) { + errors.push( + `${prefix}.slug: must start with a letter and contain only lowercase letters, numbers, and underscores`, + ); + } + if (relation.slug.length > MAX_SLUG_LENGTH) { + errors.push(`${prefix}.slug: must be ${MAX_SLUG_LENGTH} characters or fewer`); + } + if (relationSlugs.has(relation.slug)) { + errors.push(`${prefix}.slug: duplicate relation slug "${relation.slug}"`); + } + relationSlugs.add(relation.slug); + } + + for (const end of ["parentCollection", "childCollection"] as const) { + const value = relation[end]; + if (!value) { + errors.push(`${prefix}: ${end} is required`); + } else if (!COLLECTION_FIELD_SLUG_PATTERN.test(value)) { + errors.push(`${prefix}.${end}: "${value}" is not a valid collection slug`); + } + } + + for (const label of ["parentLabel", "childLabel"] as const) { + if (!relation[label]) errors.push(`${prefix}: ${label} is required`); + } + for (const label of ["parentLabelSingular", "childLabelSingular"] as const) { + const value = relation[label]; + if (value !== undefined && typeof value !== "string") { + errors.push(`${prefix}.${label}: must be a string`); + } + } + + for (const limit of ["maxChildrenPerParent", "maxParentsPerChild"] as const) { + const value = relation[limit]; + if (value === undefined || value === null) continue; + if (!Number.isInteger(value) || value < 1) { + errors.push(`${prefix}.${limit}: must be a positive integer, or null for unlimited`); + } + } + } + } + } + // Validate taxonomies if (seed.taxonomies) { if (!Array.isArray(seed.taxonomies)) { diff --git a/packages/core/tests/fields/reference.test.ts b/packages/core/tests/fields/reference.test.ts index 78095ffb83..1672b3c208 100644 --- a/packages/core/tests/fields/reference.test.ts +++ b/packages/core/tests/fields/reference.test.ts @@ -1,6 +1,20 @@ -import { describe, it, expect } from "vitest"; +import { sql } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { reference } from "../../src/fields/reference.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; +import { + STORAGELESS_FIELD_TYPES, + FIELD_TYPE_TO_COLUMN, + isStoragelessField, + isStoragelessFieldRow, +} from "../../src/schema/types.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../utils/test-db.js"; describe("reference field", () => { it("should create field definition", () => { @@ -38,3 +52,162 @@ describe("reference field", () => { expect(() => optional.schema.parse(undefined)).not.toThrow(); }); }); + +describe("storage-less field types", () => { + it("marks reference as storage-less but keeps its column-type guard entry", () => { + expect(STORAGELESS_FIELD_TYPES.has("reference")).toBe(true); + expect(STORAGELESS_FIELD_TYPES.has("string")).toBe(false); + // The map still contains reference so isFieldType() keeps recognizing it. + expect(FIELD_TYPE_TO_COLUMN.reference).toBe("TEXT"); + }); + + it("treats a reference field as storage-less only once it is bound to a relation", () => { + const field = { type: "reference" }; + expect(isStoragelessField(field)).toBe(false); + expect(isStoragelessField({ ...field, validation: {} })).toBe(false); + expect(isStoragelessField({ ...field, validation: { relation: "posts_author" } })).toBe(true); + expect(isStoragelessField({ type: "string", validation: { relation: "x" } })).toBe(false); + }); + + it("reads a raw field row's unparsed validation, treating malformed JSON as unbound", () => { + expect(isStoragelessFieldRow({ type: "reference", validation: null })).toBe(false); + expect(isStoragelessFieldRow({ type: "reference", validation: "{oops" })).toBe(false); + expect( + isStoragelessFieldRow({ type: "reference", validation: '{"relation":"posts_author"}' }), + ).toBe(true); + }); +}); + +describeEachDialect("reference field is storage-less in the registry", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("creates the field row without adding a column, and deletes without dropping one", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }); + + // The field row exists... + const field = await registry.getField("posts", "related"); + expect(field?.type).toBe("reference"); + + // ...but no column was added to ec_posts. (pragma_table_info is SQLite-only.) + if (dialect === "sqlite") { + const cols = await sql<{ name: string }>` + SELECT name FROM pragma_table_info('ec_posts') + `.execute(ctx.db); + expect(cols.rows.map((c) => c.name)).not.toContain("related"); + } + + // Deleting the field succeeds and drops nothing. + await expect(registry.deleteField("posts", "related")).resolves.not.toThrow(); + expect(await registry.getField("posts", "related")).toBeNull(); + }); + + it("creates seeded reference fields without adding columns", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createSeedCollection( + { slug: "seeded_posts", label: "Seeded posts", labelSingular: "Seeded post" }, + [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }, + ], + ); + + const table = (await ctx.db.introspection.getTables()).find( + (candidate) => candidate.name === "ec_seeded_posts", + ); + expect(table?.columns.map((column) => column.name)).toContain("title"); + expect(table?.columns.map((column) => column.name)).not.toContain("related"); + }); + + it("rejects index metadata for storage-less reference fields", async () => { + const registry = new SchemaRegistry(ctx.db); + const input = { + slug: "related", + label: "Related", + type: "reference" as const, + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }; + + await expect(registry.createField("posts", { ...input, indexed: true })).rejects.toMatchObject({ + code: "FIELD_NOT_INDEXABLE", + }); + expect(await registry.getField("posts", "related")).toBeNull(); + + await registry.createField("posts", input); + await expect(registry.updateField("posts", "related", { indexed: true })).rejects.toMatchObject( + { code: "FIELD_NOT_INDEXABLE" }, + ); + expect(await registry.getField("posts", "related")).toMatchObject({ indexed: false }); + }); + + it("rejects indexed references before a seed collection mutates the schema", async () => { + const registry = new SchemaRegistry(ctx.db); + + await expect( + registry.createSeedCollection( + { slug: "seeded_indexed", label: "Seeded indexed", labelSingular: "Seeded indexed" }, + [ + { + slug: "related", + label: "Related", + type: "reference", + indexed: true, + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }, + ], + ), + ).rejects.toMatchObject({ code: "FIELD_NOT_INDEXABLE" }); + expect(await registry.getCollection("seeded_indexed")).toBeNull(); + }); + + it("rejects changing a field to or from reference", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("posts", { slug: "title2", label: "Title2", type: "string" }); + // Nothing migrates a column of entry ids into a picker, so the target type + // is refused whether or not the result would be storage-less. + await expect( + registry.updateField("posts", "title2", { type: "reference" }), + ).rejects.toMatchObject({ code: "FIELD_TYPE_CHANGE_REQUIRES_MIGRATION" }); + await expect( + registry.updateField("posts", "title2", { + type: "reference", + validation: { relation: "posts_title2" }, + }), + ).rejects.toMatchObject({ code: "FIELD_TYPE_COLUMN_CHANGE" }); + }); + + it("rejects turning a bound reference field back into a column-backed type", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }); + + await expect( + registry.updateField("posts", "related", { type: "string" }), + ).rejects.toMatchObject({ code: "FIELD_TYPE_COLUMN_CHANGE" }); + }); +}); diff --git a/packages/core/tests/integration/api/references-edges.test.ts b/packages/core/tests/integration/api/references-edges.test.ts index 41b6bc0644..799d7c5214 100644 --- a/packages/core/tests/integration/api/references-edges.test.ts +++ b/packages/core/tests/integration/api/references-edges.test.ts @@ -13,6 +13,7 @@ import { } from "../../../src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; import { describeEachDialect, setupForDialectWithCollections, @@ -52,7 +53,7 @@ describeEachDialect("reference children handlers", (dialect) => { // post (parent) -> page (child) const repo = new RelationRepository(ctx.db); return repo.create({ - name: "related_pages", + slug: "related_pages", parentCollection: "post", childCollection: "page", parentLabel: "Post", @@ -79,6 +80,44 @@ describeEachDialect("reference children handlers", (dialect) => { expect(get.data.children.map((c) => c.slug)).toEqual(["a", "b"]); }); + it("resolved children take their title from the collection's titleField", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("page", { slug: "headline", label: "Headline", type: "string" }); + await registry.updateCollection("page", { titleField: "headline" }); + + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ + type: "page", + slug: "a", + data: { title: "A", headline: "Headline A" }, + }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]); + if (!set.success) return; + expect(set.data.children[0]?.title).toBe("Headline A"); + + const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + if (!get.success) return; + expect(get.data.children[0]?.title).toBe("Headline A"); + }); + + it("falls back to title when the titleField is empty on the entry", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("page", { slug: "headline", label: "Headline", type: "string" }); + await registry.updateCollection("page", { titleField: "headline" }); + + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]); + if (!set.success) return; + expect(set.data.children[0]?.title).toBe("A"); + }); + it("resolved children carry their actual locale", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); @@ -90,6 +129,33 @@ describeEachDialect("reference children handlers", (dialect) => { expect(set.data.children[0]?.locale).toBe("en"); }); + it("a resolved child carries the translation group of the variant it resolved to", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + locale: "fr", + }); + const en = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + const fr = await content.create({ + type: "page", + slug: "a-fr", + data: { title: "A (fr)" }, + locale: "fr", + translationOf: en.id, + }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [en.id]); + if (!set.success) return; + // The edge is keyed by group, so a `fr` parent resolves the `fr` variant — + // a different row id than the one that was linked. The group is what stays + // stable across those variants, so it rides along on the ref. + expect(set.data.children[0]?.id).toBe(fr.id); + expect(set.data.children[0]?.translationGroup).toBe(en.id); + }); + it("children GET paginates with a cursor", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); @@ -185,6 +251,36 @@ describeEachDialect("reference children handlers", (dialect) => { expect(result.error.code).toBe("NOT_FOUND"); }); + it("parents resolves a relation by id or by slug", async () => { + // A reference field stores the relation's slug while the edges are keyed + // by its id, so both have to resolve — the backlinks sidebar reaches this + // handler with whichever it holds. + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const child = await content.create({ type: "page", slug: "c", data: { title: "C" } }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [child.id]); + + for (const identifier of [rel.id, rel.slug]) { + const result = await handleReferenceParentsGet( + ctx.db, + "page", + child.id, + identifier, + {}, + true, + ); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.parents.map((p) => p.slug)).toEqual(["p"]); + } + + const unknown = await handleReferenceParentsGet(ctx.db, "page", child.id, "nope", {}, true); + expect(unknown.success).toBe(false); + if (unknown.success) return; + expect(unknown.error.code).toBe("NOT_FOUND"); + }); + it("entry on the wrong side (child collection) is VALIDATION_ERROR", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); @@ -224,6 +320,30 @@ describeEachDialect("reference children handlers", (dialect) => { expect(result.data.parents.every((p) => p.collection === "post")).toBe(true); }); + it("resolved refs carry a display title from the entry's title field", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "Parent Title" }, + }); + const titled = await content.create({ type: "page", slug: "t", data: { title: "Titled" } }); + // No title -> null, leaving the client to fall back to slug/id. + const untitled = await content.create({ type: "page", slug: "u", data: {} }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [titled.id, untitled.id]); + + const children = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + expect(children.success).toBe(true); + if (!children.success) return; + expect(children.data.children.map((c) => c.title)).toEqual(["Titled", null]); + + const parents = await handleReferenceParentsGet(ctx.db, "page", titled.id, rel.id, {}, true); + expect(parents.success).toBe(true); + if (!parents.success) return; + expect(parents.data.parents.map((p) => p.title)).toEqual(["Parent Title"]); + }); + it("parents rejects an entry on the parent side", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); @@ -414,7 +534,7 @@ describeEachDialect("reference reads: draft visibility", (dialect) => { async function makeRelation() { return new RelationRepository(ctx.db).create({ - name: "related_pages", + slug: "related_pages", parentCollection: "post", childCollection: "page", parentLabel: "Post", @@ -583,7 +703,7 @@ describeEachDialect("reference children route (auth + ownership)", (dialect) => it("GET requires content:read; POST gates on parent ownership", async () => { const repo = new RelationRepository(ctx.db); const rel = await repo.create({ - name: "related_pages", + slug: "related_pages", parentCollection: "post", childCollection: "page", parentLabel: "Post", @@ -638,7 +758,7 @@ describeEachDialect("reference children route (auth + ownership)", (dialect) => it("POST gates the edit permission before the existence lookup (no oracle)", async () => { const repo = new RelationRepository(ctx.db); const rel = await repo.create({ - name: "related_pages", + slug: "related_pages", parentCollection: "post", childCollection: "page", parentLabel: "Post", diff --git a/packages/core/tests/integration/api/relations-handlers.test.ts b/packages/core/tests/integration/api/relations-handlers.test.ts index 6c7f323e97..1bb690103a 100644 --- a/packages/core/tests/integration/api/relations-handlers.test.ts +++ b/packages/core/tests/integration/api/relations-handlers.test.ts @@ -8,14 +8,16 @@ import { handleRelationList, handleRelationUpdate, handleRelationDelete, - handleRelationTranslations, } from "../../../src/api/handlers/relations.js"; +import { handleSchemaCollectionDelete } from "../../../src/api/handlers/schema.js"; import { PATCH as patchRelation } from "../../../src/astro/routes/api/relations/[id]/index.js"; import { GET as listRelations, POST as createRelation, } from "../../../src/astro/routes/api/relations/index.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; import { setI18nConfig } from "../../../src/i18n/config.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; import { describeEachDialect, setupForDialectWithCollections, @@ -27,7 +29,7 @@ import { // Relation create validates that both collections exist, so the handler tests // use those real slugs rather than fabricated names. const baseInput = { - name: "manages", + slug: "manages", parentCollection: "post", childCollection: "post", parentLabel: "Manager", @@ -45,25 +47,22 @@ describeEachDialect("relations definition handlers", (dialect) => { await teardownForDialect(ctx); }); - it("stores relation locales with the configured casing", async () => { - setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] }); - const result = await handleRelationCreate(ctx.db, { ...baseInput, locale: "zh-tw" }); - - expect(result.success).toBe(true); - if (result.success) expect(result.data.relation.locale).toBe("zh-TW"); - }); - it("create returns the new relation; get fetches it by id", async () => { const created = await handleRelationCreate(ctx.db, { ...baseInput }); expect(created.success).toBe(true); if (!created.success) return; - expect(created.data.relation.name).toBe("manages"); - expect(created.data.relation.translationGroup).toBe(created.data.relation.id); + expect(created.data.relation.slug).toBe("manages"); const fetched = await handleRelationGet(ctx.db, created.data.relation.id); expect(fetched.success).toBe(true); if (!fetched.success) return; - expect(fetched.data.relation).toEqual(created.data.relation); + // The read carries what deleting it would take; a fresh relation has + // nothing bound and no links. + expect(fetched.data.relation).toEqual({ + ...created.data.relation, + boundFields: [], + linkCount: 0, + }); }); it("get returns NOT_FOUND for an unknown id", async () => { @@ -73,22 +72,24 @@ describeEachDialect("relations definition handlers", (dialect) => { expect(result.error.code).toBe("NOT_FOUND"); }); - it("list returns relations ordered by name, filtered by locale", async () => { - await handleRelationCreate(ctx.db, { ...baseInput, name: "writes", childCollection: "page" }); - await handleRelationCreate(ctx.db, { ...baseInput, name: "manages" }); - await handleRelationCreate(ctx.db, { ...baseInput, name: "supervises", locale: "fr" }); + it("list returns relations ordered by slug, optionally scoped to a collection", async () => { + await handleRelationCreate(ctx.db, { + ...baseInput, + slug: "writes", + parentCollection: "page", + childCollection: "page", + }); + await handleRelationCreate(ctx.db, { ...baseInput, slug: "manages" }); - const all = await handleRelationList(ctx.db, {}); + const all = await handleRelationList(ctx.db); expect(all.success).toBe(true); if (!all.success) return; - expect(all.data.relations.map((r) => r.name)).toEqual(["manages", "supervises", "writes"]); - expect(all.data.relations.some((r) => r.locale === "fr")).toBe(true); + expect(all.data.relations.map((r) => r.slug)).toEqual(["manages", "writes"]); - const en = await handleRelationList(ctx.db, { locale: "en" }); - if (!en.success) return; - expect(en.data.relations.map((r) => r.name)).toEqual(["manages", "writes"]); - expect(en.data.relations.every((r) => r.locale === "en")).toBe(true); - expect(en.data.relations.some((r) => r.name === "supervises")).toBe(false); + // The picker only offers relations this collection is on an end of. + const forPost = await handleRelationList(ctx.db, { collection: "post" }); + if (!forPost.success) return; + expect(forPost.data.relations.map((r) => r.slug)).toEqual(["manages"]); }); it("update changes only labels; unknown id is NOT_FOUND", async () => { @@ -100,7 +101,7 @@ describeEachDialect("relations definition handlers", (dialect) => { expect(updated.success).toBe(true); if (!updated.success) return; expect(updated.data.relation.parentLabel).toBe("Lead"); - expect(updated.data.relation.name).toBe("manages"); + expect(updated.data.relation.slug).toBe("manages"); const missing = await handleRelationUpdate(ctx.db, "nope", { parentLabel: "x" }); expect(missing.success).toBe(false); @@ -128,7 +129,7 @@ describeEachDialect("relations definition handlers", (dialect) => { expect(result.error.code).toBe("COLLECTION_NOT_FOUND"); }); - it("duplicate name+locale is CONFLICT, not a 500-shaped *_ERROR", async () => { + it("a duplicate slug is CONFLICT, not a 500-shaped *_ERROR", async () => { const first = await handleRelationCreate(ctx.db, { ...baseInput }); expect(first.success).toBe(true); const second = await handleRelationCreate(ctx.db, { ...baseInput }); @@ -137,52 +138,98 @@ describeEachDialect("relations definition handlers", (dialect) => { expect(second.error.code).toBe("CONFLICT"); }); - it("a bogus translationOf is NOT_FOUND", async () => { - const result = await handleRelationCreate(ctx.db, { - ...baseInput, - locale: "fr", - translationOf: "does-not-exist", + it("delete takes the fields that view the relation with it", async () => { + const created = await handleRelationCreate(ctx.db, { ...baseInput }); + if (!created.success) return; + + // Built through the registry rather than the field handler: binding a + // field to a relation is the next PR's work, and the cascade under test + // only cares that the field row names the relation. + await new SchemaRegistry(ctx.db).createField("post", { + slug: "manager", + label: "Manager", + type: "reference", + validation: { relation: "manages", relationSide: "parent", targetCollection: "post" }, }); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error.code).toBe("NOT_FOUND"); + + const del = await handleRelationDelete(ctx.db, created.data.relation.id); + expect(del.success, JSON.stringify(del)).toBe(true); + if (!del.success) return; + // A field left pointing at a deleted relation could never be written + // through, so the relation cannot outlive its views. + expect(del.data.deletedFields).toEqual(["post.manager"]); + + const fields = await ctx.db + .selectFrom("_emdash_fields") + .select("slug") + .where("slug", "=", "manager") + .execute(); + expect(fields).toHaveLength(0); }); - it("a second translation for an existing locale is CONFLICT", async () => { - const en = await handleRelationCreate(ctx.db, { ...baseInput }); - if (!en.success) return; - const fr = await handleRelationCreate(ctx.db, { - ...baseInput, - locale: "fr", - translationOf: en.data.relation.id, + it("reports the fields and links a delete would take", async () => { + const created = await handleRelationCreate(ctx.db, { ...baseInput }); + if (!created.success) return; + const relation = created.data.relation; + + await new SchemaRegistry(ctx.db).createField("post", { + slug: "manager", + label: "Manager", + type: "reference", + validation: { relation: "manages", relationSide: "child", targetCollection: "post" }, }); - expect(fr.success).toBe(true); - // A second fr translation collides on (translation_group, locale). - const dup = await handleRelationCreate(ctx.db, { - ...baseInput, - locale: "fr", - translationOf: en.data.relation.id, + await new RelationRepository(ctx.db).addReference(relation.id, "parent-a", "child-b"); + + const fetched = await handleRelationGet(ctx.db, relation.id); + expect(fetched.success).toBe(true); + if (!fetched.success) return; + expect(fetched.data.relation.linkCount).toBe(1); + expect(fetched.data.relation.boundFields).toEqual([ + { collectionSlug: "post", fieldSlug: "manager", side: "child" }, + ]); + + // The list carries the same figures, so the relations page can show them + // per row without a read each. + const listed = await handleRelationList(ctx.db); + expect(listed.success).toBe(true); + if (!listed.success) return; + expect(listed.data.relations.find((r) => r.slug === "manages")).toMatchObject({ + linkCount: 1, + boundFields: [{ collectionSlug: "post", fieldSlug: "manager", side: "child" }], }); - expect(dup.success).toBe(false); - if (dup.success) return; - expect(dup.error.code).toBe("CONFLICT"); }); - it("translations returns every locale sibling for the group", async () => { - const en = await handleRelationCreate(ctx.db, { ...baseInput }); - if (!en.success) return; - await handleRelationCreate(ctx.db, { - ...baseInput, - locale: "fr", - parentLabel: "Responsable", - childLabel: "Subordonné", - translationOf: en.data.relation.id, + it("deleting a collection takes its relations and the fields on the other end", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "author", label: "Authors", labelSingular: "Author" }); + + const created = await handleRelationCreate(ctx.db, { + slug: "post_author", + parentCollection: "post", + childCollection: "author", + parentLabel: "Posts", + childLabel: "Author", }); + if (!created.success) return; + await new RelationRepository(ctx.db).addReference(created.data.relation.id, "pg", "cg"); + + // The field lives on `post`, but the collection being deleted is `author` + // — the far end. It has to go too, or it addresses a collection that no + // longer exists. + await registry.createField("post", { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "post_author", relationSide: "parent", targetCollection: "author" }, + }); + + const deleted = await handleSchemaCollectionDelete(ctx.db, "author", { force: true }); + expect(deleted.success, JSON.stringify(deleted)).toBe(true); - const result = await handleRelationTranslations(ctx.db, en.data.relation.id); - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.data.translations.map((t) => t.locale)).toEqual(["en", "fr"]); + expect(await new RelationRepository(ctx.db).findBySlug("post_author")).toBeNull(); + expect(await registry.getField("post", "author")).toBeNull(); + const edges = await ctx.db.selectFrom("_emdash_content_references").selectAll().execute(); + expect(edges).toHaveLength(0); }); }); diff --git a/packages/core/tests/integration/content/content-references-write.test.ts b/packages/core/tests/integration/content/content-references-write.test.ts new file mode 100644 index 0000000000..b9070f8c1f --- /dev/null +++ b/packages/core/tests/integration/content/content-references-write.test.ts @@ -0,0 +1,634 @@ +import { expect, it } from "vitest"; + +import { + handleContentCreate, + handleContentDelete, + handleContentDuplicate, + handleContentGet, + handleContentPermanentDelete, + handleContentUpdate, +} from "../../../src/api/handlers/content.js"; +import { setReferenceChildren } from "../../../src/api/handlers/relations.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js"; +import type { DialectTestContext } from "../../utils/test-db.js"; + +describeEachDialect("content write strips storage-less data keys", (dialect) => { + let ctx: DialectTestContext; + + it("does not error and does not persist a reference key placed in data", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }); + + const res = await handleContentCreate(ctx.db, "posts", { + data: { title: "A", related: ["should-be-ignored"] }, + }); + + expect(res.success).toBe(true); + if (res.success) { + // The reference key must not have been written as a column value. + expect(res.data.item.data).not.toHaveProperty("related"); + } + } finally { + await teardownForDialect(ctx); + } + }); +}); + +describeEachDialect("setReferenceChildren", (dialect) => { + let ctx: DialectTestContext; + + it("sets children on a successful call; a child outside the child collection is NOT_FOUND with no partial write", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(parent.success).toBe(true); + expect(childA.success).toBe(true); + expect(childB.success).toBe(true); + if (!parent.success || !childA.success || !childB.success) return; + + const result = await setReferenceChildren( + ctx.db, + "posts", + parent.data.item.id, + relation.slug, + [childA.data.item.id, childB.data.item.id], + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.relationId).toBe(relation.id); + + const page = await relationRepo.getChildrenPage( + result.data.relationId, + result.data.entryGroup, + ); + expect(page.items.map((i) => i.childGroup).toSorted()).toEqual( + [childA.data.item.id, childB.data.item.id].toSorted(), + ); + } + + // A child id outside the relation's child collection fails NOT_FOUND — + // and must not partially overwrite the set above. + const bad = await setReferenceChildren(ctx.db, "posts", parent.data.item.id, relation.slug, [ + "nope", + ]); + expect(bad.success).toBe(false); + if (!bad.success) expect(bad.error.code).toBe("NOT_FOUND"); + + const pageAfterBad = await relationRepo.getChildrenPage(relation.slug, parent.data.item.id); + expect(pageAfterBad.items.map((i) => i.childGroup).toSorted()).toEqual( + [childA.data.item.id, childB.data.item.id].toSorted(), + ); + } finally { + await teardownForDialect(ctx); + } + }); +}); + +describeEachDialect("content create with a `references` key", (dialect) => { + let ctx: DialectTestContext; + + it("writes reference edges atomically with the entry on create", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + // A selection is addressed by field slug, so the field that views the + // relation has to exist. + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: relation.slug, targetCollection: "posts", multiple: true }, + }); + + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(childA.success).toBe(true); + expect(childB.success).toBe(true); + if (!childA.success || !childB.success) return; + + const res = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { related: [childA.data.item.id, childB.data.item.id] }, + }); + expect(res.success).toBe(true); + if (!res.success) return; + + // Read back through the same edge read the REST endpoint uses — + // order must match the input array (sort_order is positional). + const page = await relationRepo.getChildrenPage(relation.slug, res.data.item.id); + expect(page.items.map((i) => i.childGroup)).toEqual([ + childA.data.item.id, + childB.data.item.id, + ]); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects the whole save when a reference child is invalid", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: relation.slug, targetCollection: "posts", multiple: true }, + }); + + const contentRepo = new ContentRepository(ctx.db); + const countBefore = await contentRepo.count("posts"); + + const res = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { related: ["does-not-exist"] }, + }); + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("NOT_FOUND"); + + // The entry must NOT be persisted — a bad reference aborts the whole + // transaction, not just the reference write, so no half-written entry. + const countAfter = await contentRepo.count("posts"); + expect(countAfter).toBe(countBefore); + } finally { + await teardownForDialect(ctx); + } + }); +}); + +describeEachDialect("a reference field bound to the child side of its relation", (dialect) => { + let ctx: DialectTestContext; + + /** `posts.author` picks an author; `authors.posts` views the same links back. */ + async function setupBothSides() { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "authors", label: "Authors", labelSingular: "Author" }); + await registry.createField("authors", { slug: "name", label: "Name", type: "string" }); + + const relation = await new RelationRepository(ctx.db).create({ + slug: "post_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + childLabel: "Author", + maxChildrenPerParent: 1, + }); + await registry.createField("posts", { + slug: "author", + label: "Author", + type: "reference", + validation: { + relation: relation.slug, + relationSide: "parent", + targetCollection: "authors", + }, + }); + await registry.createField("authors", { + slug: "posts", + label: "Posts", + type: "reference", + validation: { + relation: relation.slug, + relationSide: "child", + targetCollection: "posts", + }, + }); + return relation; + } + + it("writes the links from the child end and reads them back on both fields", async () => { + ctx = await setupForDialect(dialect); + try { + const relation = await setupBothSides(); + + const first = await handleContentCreate(ctx.db, "posts", { data: { title: "First" } }); + const second = await handleContentCreate(ctx.db, "posts", { data: { title: "Second" } }); + const author = await handleContentCreate(ctx.db, "authors", { data: { name: "Jane" } }); + if (!first.success || !second.success || !author.success) throw new Error("setup failed"); + + // Selecting from the child end names the parents pointing at this entry. + const updated = await handleContentUpdate(ctx.db, "authors", author.data.item.id, { + references: { posts: [first.data.item.id, second.data.item.id] }, + }); + expect(updated, JSON.stringify(updated)).toMatchObject({ success: true }); + + const hydratedAuthor = await handleContentGet( + ctx.db, + "authors", + author.data.item.id, + undefined, + { includeDrafts: true }, + ); + if (!hydratedAuthor.success) throw new Error("author read failed"); + // The child side is unordered by design: `sort_order` positions children + // within one parent and has no symmetric counterpart, so this list comes + // back by link id. Two links written in the same millisecond carry ULIDs + // whose order is not the write order, so assert the set, not a sequence. + expect( + hydratedAuthor.data.item.references?.posts?.children.map((c) => c.id).toSorted(), + ).toEqual([first.data.item.id, second.data.item.id].toSorted()); + + // The same links seen from the parent end, through the other field. + const hydratedPost = await handleContentGet(ctx.db, "posts", first.data.item.id, undefined, { + includeDrafts: true, + }); + if (!hydratedPost.success) throw new Error("post read failed"); + expect(hydratedPost.data.item.references?.author?.children.map((c) => c.id)).toEqual([ + author.data.item.id, + ]); + + const repo = new RelationRepository(ctx.db); + const edges = await repo.getParents(relation.id, author.data.item.translationGroup!); + expect(edges).toHaveLength(2); + } finally { + await teardownForDialect(ctx); + } + }); + + it("enforces the child side's own limit, not the parent side's", async () => { + ctx = await setupForDialect(dialect); + try { + const relation = await setupBothSides(); + // One author per post, but a post may be linked from one author only too. + await new RelationRepository(ctx.db).update(relation.id, { maxParentsPerChild: 1 }); + + const first = await handleContentCreate(ctx.db, "posts", { data: { title: "First" } }); + const second = await handleContentCreate(ctx.db, "posts", { data: { title: "Second" } }); + const author = await handleContentCreate(ctx.db, "authors", { data: { name: "Jane" } }); + if (!first.success || !second.success || !author.success) throw new Error("setup failed"); + + const rejected = await handleContentUpdate(ctx.db, "authors", author.data.item.id, { + references: { posts: [first.data.item.id, second.data.item.id] }, + }); + expect(rejected).toMatchObject({ + success: false, + error: { code: "VALIDATION_ERROR" }, + }); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects a references key that is not a reference field", async () => { + ctx = await setupForDialect(dialect); + try { + await setupBothSides(); + const post = await handleContentCreate(ctx.db, "posts", { data: { title: "First" } }); + if (!post.success) throw new Error("setup failed"); + + const rejected = await handleContentUpdate(ctx.db, "posts", post.data.item.id, { + references: { title: ["whatever"] }, + }); + expect(rejected).toMatchObject({ + success: false, + error: { code: "VALIDATION_ERROR" }, + }); + } finally { + await teardownForDialect(ctx); + } + }); +}); + +describeEachDialect("handleContentGet reference hydration (opt-in)", (dialect) => { + let ctx: DialectTestContext; + + it("hydrates the first page of references when referenceOptions is passed", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + // The reference field must carry validation.relation + targetCollection + // so hydration can discover it and its child collection. + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { + relation: relation.slug, + targetCollection: "posts", + multiple: true, + }, + }); + + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(childA.success && childB.success).toBe(true); + if (!childA.success || !childB.success) return; + + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { related: [childA.data.item.id, childB.data.item.id] }, + }); + expect(parent.success).toBe(true); + if (!parent.success) return; + + const got = await handleContentGet(ctx.db, "posts", parent.data.item.id, undefined, { + includeDrafts: true, + }); + expect(got.success).toBe(true); + if (got.success) { + const refs = got.data.item.references?.related; + expect(refs?.children.map((c) => c.id)).toEqual([childA.data.item.id, childB.data.item.id]); + } + } finally { + await teardownForDialect(ctx); + } + }); + + it("hydrates nothing for a legacy reference field with no validation.relation", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + // Legacy reference field: validation without `relation`/`targetCollection`. + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { multiple: true }, + }); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + expect(parent.success).toBe(true); + if (!parent.success) return; + + const got = await handleContentGet(ctx.db, "posts", parent.data.item.id, undefined, { + includeDrafts: true, + }); + expect(got.success).toBe(true); + if (got.success) { + // No crash; the legacy field contributes no reference group. + expect(got.data.item.references).toEqual({}); + } + } finally { + await teardownForDialect(ctx); + } + }); + + it("does not hydrate references when referenceOptions is omitted (opt-in)", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { + relation: relation.slug, + targetCollection: "posts", + multiple: true, + }, + }); + + const child = await handleContentCreate(ctx.db, "posts", { data: { title: "Child" } }); + expect(child.success).toBe(true); + if (!child.success) return; + + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { related: [child.data.item.id] }, + }); + expect(parent.success).toBe(true); + if (!parent.success) return; + + // Omit the 5th arg → no hydration, no extra queries. + const got = await handleContentGet(ctx.db, "posts", parent.data.item.id); + expect(got.success).toBe(true); + if (got.success) { + expect(got.data.item.references).toBeUndefined(); + } + } finally { + await teardownForDialect(ctx); + } + }); +}); + +describeEachDialect("handleContentDuplicate copies reference edges", (dialect) => { + let ctx: DialectTestContext; + + it("carries the original's outgoing references onto the duplicate", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(parent.success && childA.success && childB.success).toBe(true); + if (!parent.success || !childA.success || !childB.success) return; + + const set = await setReferenceChildren(ctx.db, "posts", parent.data.item.id, relation.slug, [ + childA.data.item.id, + childB.data.item.id, + ]); + expect(set.success).toBe(true); + + const dup = await handleContentDuplicate(ctx.db, "posts", parent.data.item.id); + expect(dup.success).toBe(true); + if (!dup.success) return; + + // The duplicate is a distinct entry (new translation_group) but must carry + // the same outgoing reference edges, in order. + const content = new ContentRepository(ctx.db); + const dupItem = await content.findById("posts", dup.data.item.id); + expect(dupItem?.translationGroup).toBeTruthy(); + expect(dupItem?.translationGroup).not.toBe(parent.data.item.id); + if (!dupItem?.translationGroup) return; + + const page = await relationRepo.getChildrenPage(relation.slug, dupItem.translationGroup); + expect(page.items.map((i) => i.childGroup)).toEqual([ + childA.data.item.id, + childB.data.item.id, + ]); + } finally { + await teardownForDialect(ctx); + } + }); +}); + +describeEachDialect("handleContentPermanentDelete clears reference edges", (dialect) => { + let ctx: DialectTestContext; + + async function setupPostsWithRelation(db: DialectTestContext["db"]) { + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(db); + const relation = await relationRepo.create({ + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + return { relationRepo, relation }; + } + + it("removes edges on both sides when the last row of a translation group is purged", async () => { + ctx = await setupForDialect(dialect); + try { + const { relationRepo, relation } = await setupPostsWithRelation(ctx.db); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const middle = await handleContentCreate(ctx.db, "posts", { data: { title: "Middle" } }); + const child = await handleContentCreate(ctx.db, "posts", { data: { title: "Child" } }); + expect(parent.success && middle.success && child.success).toBe(true); + if (!parent.success || !middle.success || !child.success) return; + + // The purged entry sits in the middle of a chain: parent → middle → child, + // so both its outgoing and incoming edges must go. + expect( + ( + await setReferenceChildren(ctx.db, "posts", parent.data.item.id, relation.slug, [ + middle.data.item.id, + ]) + ).success, + ).toBe(true); + expect( + ( + await setReferenceChildren(ctx.db, "posts", middle.data.item.id, relation.slug, [ + child.data.item.id, + ]) + ).success, + ).toBe(true); + + expect((await handleContentDelete(ctx.db, "posts", middle.data.item.id)).success).toBe(true); + const purged = await handleContentPermanentDelete(ctx.db, "posts", middle.data.item.id); + expect(purged.success).toBe(true); + + const outgoing = await relationRepo.getChildrenPage(relation.slug, middle.data.item.id); + expect(outgoing.items).toEqual([]); + const incoming = await relationRepo.getParentsPage(relation.slug, middle.data.item.id); + expect(incoming.items).toEqual([]); + } finally { + await teardownForDialect(ctx); + } + }); + + it("keeps the group's edges when a translation sibling survives the purge", async () => { + ctx = await setupForDialect(dialect); + try { + const { relationRepo, relation } = await setupPostsWithRelation(ctx.db); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const child = await handleContentCreate(ctx.db, "posts", { data: { title: "Child" } }); + expect(parent.success && child.success).toBe(true); + if (!parent.success || !child.success) return; + + const translation = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent (fr)" }, + locale: "fr", + translationOf: parent.data.item.id, + }); + expect(translation.success).toBe(true); + if (!translation.success) return; + + expect( + ( + await setReferenceChildren(ctx.db, "posts", parent.data.item.id, relation.slug, [ + child.data.item.id, + ]) + ).success, + ).toBe(true); + + // Edges are keyed by translation_group, so purging one locale row must not + // strip references still owned by its surviving sibling. + expect((await handleContentDelete(ctx.db, "posts", translation.data.item.id)).success).toBe( + true, + ); + const purged = await handleContentPermanentDelete(ctx.db, "posts", translation.data.item.id); + expect(purged.success).toBe(true); + + const page = await relationRepo.getChildrenPage(relation.slug, parent.data.item.id); + expect(page.items.map((i) => i.childGroup)).toEqual([child.data.item.id]); + } finally { + await teardownForDialect(ctx); + } + }); +}); diff --git a/packages/core/tests/integration/content/reference-constraints.test.ts b/packages/core/tests/integration/content/reference-constraints.test.ts new file mode 100644 index 0000000000..521c7e0227 --- /dev/null +++ b/packages/core/tests/integration/content/reference-constraints.test.ts @@ -0,0 +1,392 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleContentCreate, + handleContentGet, + handleContentUpdate, +} from "../../../src/api/handlers/content.js"; +import { handleReferenceChildrenSet } from "../../../src/api/handlers/relations.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import type { ContentItem } from "../../../src/database/repositories/types.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { createTestRuntime } from "../../utils/mcp-runtime.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +function referenceTranslationGroup( + child: NonNullable<ContentItem["references"]>[string]["children"][number], +): string | null { + return child.translationGroup; +} + +describeEachDialect("reference field constraints", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function setupConstrainedFields() { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + await registry.createField("pages", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + required: true, + }); + + const relationRepo = new RelationRepository(ctx.db); + // Cardinality is the relation's, not the field's — a post has one featured + // page whichever end you bind. + const requiredSingle = await relationRepo.create({ + slug: "posts_featured_page", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Featured page", + maxChildrenPerParent: 1, + }); + const optionalMultiple = await relationRepo.create({ + slug: "posts_related_pages", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Related pages", + }); + + await registry.createField("posts", { + slug: "featured_page", + label: "Featured page", + type: "reference", + required: true, + validation: { + relation: requiredSingle.slug, + relationSide: "parent", + targetCollection: "pages", + }, + }); + await registry.createField("posts", { + slug: "related_pages", + label: "Related pages", + type: "reference", + validation: { + relation: optionalMultiple.slug, + relationSide: "parent", + targetCollection: "pages", + }, + }); + + return { relationRepo, requiredSingle, optionalMultiple }; + } + + async function createPage(title: string) { + const result = await handleContentCreate(ctx.db, "pages", { data: { title } }); + if (!result.success) throw new Error("Page setup failed"); + return result.data.item; + } + + it("rejects an ordinary create that omits a required reference", async () => { + await setupConstrainedFields(); + const countBefore = await new ContentRepository(ctx.db).count("posts"); + + const result = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("VALIDATION_ERROR"); + expect(result.error.message).toContain("featured_page"); + } + expect(await new ContentRepository(ctx.db).count("posts")).toBe(countBefore); + }); + + it("accepts a create with one required reference while the optional field is omitted", async () => { + await setupConstrainedFields(); + const child = await createPage("Child"); + + const result = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [child.id] }, + }); + + expect(result.success).toBe(true); + }); + + it("accepts required reference selections through runtime validation", async () => { + await setupConstrainedFields(); + const child = await createPage("Child"); + const runtime = createTestRuntime(ctx.db); + + const missingStoredField = await runtime.handleContentCreate("posts", { + data: {}, + references: { featured_page: [child.id] }, + }); + expect(missingStoredField.success).toBe(false); + if (!missingStoredField.success) { + expect(missingStoredField.error.message).toContain("title"); + expect(missingStoredField.error.message).not.toContain("featured_page"); + } + + const result = await runtime.handleContentCreate("posts", { + data: { title: "Parent" }, + references: { featured_page: [child.id] }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.item.data).toEqual({ title: "Parent" }); + }); + + it("rejects multiple children on a single-reference field through content create", async () => { + await setupConstrainedFields(); + const first = await createPage("First"); + const second = await createPage("Second"); + + const result = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [first.id, second.id] }, + }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("rejects clearing a required reference through content update", async () => { + await setupConstrainedFields(); + const child = await createPage("Child"); + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [child.id] }, + }); + if (!parent.success) throw new Error("Parent setup failed"); + + const result = await handleContentUpdate(ctx.db, "posts", parent.data.item.id, { + references: { featured_page: [] }, + }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("allows a partial update that does not mention the required reference", async () => { + const { relationRepo, requiredSingle } = await setupConstrainedFields(); + const child = await createPage("Child"); + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [child.id] }, + }); + if (!parent.success) throw new Error("Parent setup failed"); + + const result = await handleContentUpdate(ctx.db, "posts", parent.data.item.id, { + data: { title: "Renamed" }, + }); + + expect(result.success).toBe(true); + const references = await relationRepo.getChildrenPage( + requiredSingle.slug, + parent.data.item.translationGroup ?? parent.data.item.id, + ); + expect(references.items.map((item) => item.childGroup)).toEqual([child.translationGroup]); + }); + + it("rejects multiple children on a single-reference field through the edge endpoint", async () => { + const { requiredSingle } = await setupConstrainedFields(); + const first = await createPage("First"); + const second = await createPage("Second"); + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [first.id] }, + }); + if (!parent.success) throw new Error("Parent setup failed"); + + const result = await handleReferenceChildrenSet( + ctx.db, + "posts", + parent.data.item.id, + requiredSingle.slug, + [first.id, second.id], + ); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("accepts one child on a single-reference field through the edge endpoint", async () => { + const { requiredSingle } = await setupConstrainedFields(); + const first = await createPage("First"); + const second = await createPage("Second"); + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [first.id] }, + }); + if (!parent.success) throw new Error("Parent setup failed"); + + const result = await handleReferenceChildrenSet( + ctx.db, + "posts", + parent.data.item.id, + requiredSingle.slug, + [second.id], + ); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.children.map((child) => child.id)).toEqual([second.id]); + }); + + it("rejects clearing a required reference through the edge endpoint", async () => { + const { requiredSingle } = await setupConstrainedFields(); + const child = await createPage("Child"); + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [child.id] }, + }); + if (!parent.success) throw new Error("Parent setup failed"); + + const result = await handleReferenceChildrenSet( + ctx.db, + "posts", + parent.data.item.id, + requiredSingle.slug, + [], + ); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("enforces a relation's limit rather than a per-field flag", async () => { + // The limit is the relation's, so raising it lets an existing field hold + // more without touching the field row. + const { relationRepo, requiredSingle } = await setupConstrainedFields(); + const [first, second] = [await createPage("One"), await createPage("Two")]; + + const rejected = await handleContentCreate(ctx.db, "posts", { + data: { title: "Post" }, + references: { featured_page: [first.id, second.id] }, + }); + expect(rejected.success).toBe(false); + + await relationRepo.update(requiredSingle.id, { maxChildrenPerParent: null }); + + const accepted = await handleContentCreate(ctx.db, "posts", { + data: { title: "Post" }, + references: { featured_page: [first.id, second.id] }, + }); + expect(accepted.success, JSON.stringify(accepted)).toBe(true); + }); + + it("leaves a relation without a backing reference field unconstrained", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + const relation = await new RelationRepository(ctx.db).create({ + slug: "loose_related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Posts", + childLabel: "Related posts", + }); + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const first = await handleContentCreate(ctx.db, "posts", { data: { title: "First" } }); + const second = await handleContentCreate(ctx.db, "posts", { data: { title: "Second" } }); + if (!parent.success || !first.success || !second.success) throw new Error("Setup failed"); + + const result = await handleReferenceChildrenSet( + ctx.db, + "posts", + parent.data.item.id, + relation.slug, + [first.data.item.id, second.data.item.id], + ); + + expect(result.success).toBe(true); + }); + + it("rejects a selection that exceeds the other end's cardinality", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + await registry.createField("pages", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + // One page belongs to at most one post: the limit lives on the child end, + // but only parents ever select. + const relation = await new RelationRepository(ctx.db).create({ + slug: "posts_owned_page", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Post", + childLabel: "Owned page", + maxParentsPerChild: 1, + }); + await registry.createField("posts", { + slug: "owned_page", + label: "Owned page", + type: "reference", + validation: { + relation: relation.slug, + relationSide: "parent", + targetCollection: "pages", + }, + }); + + const page = await createPage("Shared"); + const first = await handleContentCreate(ctx.db, "posts", { + data: { title: "First" }, + references: { owned_page: [page.id] }, + }); + expect(first.success).toBe(true); + + const second = await handleContentCreate(ctx.db, "posts", { + data: { title: "Second" }, + references: { owned_page: [page.id] }, + }); + + expect(second.success).toBe(false); + if (!second.success) expect(second.error.code).toBe("VALIDATION_ERROR"); + + const parents = await new RelationRepository(ctx.db).getParentsPage( + relation.slug, + page.translationGroup ?? page.id, + ); + expect(parents.items).toHaveLength(1); + }); + + it("inherits a source group's references when creating a translation", async () => { + await setupConstrainedFields(); + const child = await createPage("Child"); + const source = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { featured_page: [child.id] }, + }); + if (!source.success) throw new Error("Source setup failed"); + + const translation = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent in French" }, + locale: "fr", + translationOf: source.data.item.id, + }); + + expect(translation.success).toBe(true); + if (!translation.success) return; + const hydrated = await handleContentGet(ctx.db, "posts", translation.data.item.id, "fr", { + includeDrafts: true, + }); + expect(hydrated.success).toBe(true); + if (!hydrated.success) return; + const selected = hydrated.data.item.references?.featured_page?.children[0]; + expect(selected?.id).toBe(child.id); + expect(selected && referenceTranslationGroup(selected)).toBe(child.translationGroup); + }); +}); diff --git a/packages/core/tests/integration/content/reference-draft-lifecycle.test.ts b/packages/core/tests/integration/content/reference-draft-lifecycle.test.ts new file mode 100644 index 0000000000..37b349ac85 --- /dev/null +++ b/packages/core/tests/integration/content/reference-draft-lifecycle.test.ts @@ -0,0 +1,418 @@ +/** + * A reference selection follows the entry's draft: staged on save, promoted on + * publish, dropped with a discarded draft, and restored with an older revision — + * the same lifecycle any other field value has. + */ + +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleContentCompare, + handleContentCreate, + handleContentDiscardDraft, + handleContentDuplicate, +} from "../../../src/api/handlers/content.js"; +import { handleRevisionRestore } from "../../../src/api/handlers/revision.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { RevisionRepository } from "../../../src/database/repositories/revision.js"; +import type { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { createTestRuntime } from "../../utils/mcp-runtime.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("versioned reference selections", (dialect) => { + let ctx: DialectTestContext; + let runtime: EmDashRuntime; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + await registry.createField("pages", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relations = new RelationRepository(ctx.db); + await relations.create({ + slug: "posts_related_pages", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Related pages", + }); + await registry.createField("posts", { + slug: "related_pages", + label: "Related pages", + type: "reference", + validation: { + relation: "posts_related_pages", + relationSide: "parent", + targetCollection: "pages", + }, + }); + + runtime = createTestRuntime(ctx.db); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + /** Published, so a read that excludes drafts still resolves the selection. */ + async function createPage(title: string) { + const result = await handleContentCreate(ctx.db, "pages", { + data: { title }, + slug: title.toLowerCase().replaceAll(" ", "-"), + }); + if (!result.success) throw new Error(`Page setup failed: ${result.error.message}`); + const published = await runtime.handleContentPublish("pages", result.data.item.id); + if (!published.success) throw new Error("Page publish failed"); + return result.data.item; + } + + /** The live selection, as translation groups in link order. */ + async function liveGroups(postId: string): Promise<string[]> { + const post = await new ContentRepository(ctx.db).findById("posts", postId); + if (!post?.translationGroup) throw new Error("Post has no translation group"); + const page = await new RelationRepository(ctx.db).getChildrenPage( + "posts_related_pages", + post.translationGroup, + ); + return page.items.map((edge) => edge.childGroup); + } + + /** The selection staged in the entry's draft revision, if it has one. */ + async function stagedGroups(postId: string): Promise<unknown> { + const post = await new ContentRepository(ctx.db).findById("posts", postId); + if (!post?.draftRevisionId) return undefined; + const revision = await new RevisionRepository(ctx.db).findById(post.draftRevisionId); + const staged = revision?.data._references; + if (typeof staged !== "object" || staged === null) return undefined; + return Reflect.get(staged, "related_pages"); + } + + async function publishedPost(title: string, pageIds: string[]) { + const created = await runtime.handleContentCreate("posts", { + data: { title }, + slug: title.toLowerCase().replaceAll(" ", "-"), + references: { related_pages: pageIds }, + }); + if (!created.success || !created.data) throw new Error("Post setup failed"); + const id = created.data.item.id; + const published = await runtime.handleContentPublish("posts", id); + if (!published.success) throw new Error("Post publish failed"); + return id; + } + + it("stages a picker change instead of writing links, then promotes it on publish", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Staged", [a.id]); + + const saved = await runtime.handleContentUpdate("posts", id, { + references: { related_pages: [b.id] }, + }); + expect(saved.success).toBe(true); + + expect(await liveGroups(id)).toEqual([a.translationGroup]); + expect(await stagedGroups(id)).toEqual([b.translationGroup]); + + const published = await runtime.handleContentPublish("posts", id); + expect(published.success).toBe(true); + expect(await liveGroups(id)).toEqual([b.translationGroup]); + }); + + it("reports a staged selection to a drafts-aware read and the live one otherwise", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Overlay", [a.id]); + + await runtime.handleContentUpdate("posts", id, { references: { related_pages: [b.id] } }); + + const withDrafts = await runtime.handleContentGet("posts", id, undefined, { + includeDrafts: true, + }); + expect(withDrafts.success).toBe(true); + expect(withDrafts.data?.item.references?.related_pages?.children.map((c) => c.id)).toEqual([ + b.id, + ]); + + const withoutDrafts = await runtime.handleContentGet("posts", id, undefined, { + includeDrafts: false, + }); + expect(withoutDrafts.data?.item.references?.related_pages?.children.map((c) => c.id)).toEqual([ + a.id, + ]); + }); + + it("does not touch a picker-only save's live row", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Untouched", [a.id]); + const before = await new ContentRepository(ctx.db).findById("posts", id); + + const saved = await runtime.handleContentUpdate("posts", id, { + references: { related_pages: [b.id] }, + }); + + expect(saved.success && saved.liveContentChanged).toBe(false); + const after = await new ContentRepository(ctx.db).findById("posts", id); + expect(after?.status).toBe(before?.status); + expect(after?.liveRevisionId).toBe(before?.liveRevisionId); + }); + + it("keeps a field the save did not name staged alongside the one it did", async () => { + const registry = new SchemaRegistry(ctx.db); + await new RelationRepository(ctx.db).create({ + slug: "posts_featured_page", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Featured page", + }); + await registry.createField("posts", { + slug: "featured_page", + label: "Featured page", + type: "reference", + validation: { + relation: "posts_featured_page", + relationSide: "parent", + targetCollection: "pages", + }, + }); + + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Two fields", []); + + await runtime.handleContentUpdate("posts", id, { references: { featured_page: [a.id] } }); + await runtime.handleContentUpdate("posts", id, { references: { related_pages: [b.id] } }); + + const post = await new ContentRepository(ctx.db).findById("posts", id); + const revision = await new RevisionRepository(ctx.db).findById(post!.draftRevisionId!); + expect(revision?.data._references).toEqual({ + featured_page: [a.translationGroup], + related_pages: [b.translationGroup], + }); + }); + + it("compares a staged selection against the published one, filling both from the links", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Compared", [a.id]); + + const published = await handleContentCompare(ctx.db, "posts", id); + expect(published.success).toBe(true); + if (!published.success) return; + expect(published.data.live?._references).toEqual({ related_pages: [a.translationGroup] }); + + // A draft that changed only the title stages no selection, so its side has + // to show the published one rather than read as a field it removed. + await runtime.handleContentUpdate("posts", id, { data: { title: "Retitled" } }); + const titleOnly = await handleContentCompare(ctx.db, "posts", id); + expect(titleOnly.success).toBe(true); + if (!titleOnly.success) return; + expect(titleOnly.data.draft?._references).toEqual({ related_pages: [a.translationGroup] }); + + await runtime.handleContentUpdate("posts", id, { references: { related_pages: [b.id] } }); + + const changed = await handleContentCompare(ctx.db, "posts", id); + expect(changed.success).toBe(true); + if (!changed.success) return; + expect(changed.data.live?._references).toEqual({ related_pages: [a.translationGroup] }); + expect(changed.data.draft?._references).toEqual({ related_pages: [b.translationGroup] }); + }); + + it("drops a staged selection with the discarded draft, leaving links alone", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Discarded", [a.id]); + + await runtime.handleContentUpdate("posts", id, { references: { related_pages: [b.id] } }); + const discarded = await handleContentDiscardDraft(ctx.db, "posts", id); + expect(discarded.success).toBe(true); + + expect(await liveGroups(id)).toEqual([a.translationGroup]); + expect(await stagedGroups(id)).toBeUndefined(); + }); + + it("publishes the latest of two autosaves from a single draft revision", async () => { + const [a, b, c] = [await createPage("A"), await createPage("B"), await createPage("C")]; + const id = await publishedPost("Autosaved", [a.id]); + + await runtime.handleContentUpdate("posts", id, { + references: { related_pages: [b.id] }, + skipRevision: true, + }); + await runtime.handleContentUpdate("posts", id, { + references: { related_pages: [c.id] }, + skipRevision: true, + }); + + expect(await stagedGroups(id)).toEqual([c.translationGroup]); + + await runtime.handleContentPublish("posts", id); + expect(await liveGroups(id)).toEqual([c.translationGroup]); + }); + + it("restores an older revision's selection as live links", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Restored", [a.id]); + + await runtime.handleContentUpdate("posts", id, { references: { related_pages: [a.id] } }); + const olderRevisionId = (await new ContentRepository(ctx.db).findById("posts", id))! + .draftRevisionId!; + await runtime.handleContentPublish("posts", id); + + await runtime.handleContentUpdate("posts", id, { references: { related_pages: [b.id] } }); + await runtime.handleContentPublish("posts", id); + expect(await liveGroups(id)).toEqual([b.translationGroup]); + + const restored = await handleRevisionRestore(ctx.db, olderRevisionId, "user-1"); + expect(restored.success).toBe(true); + expect(await liveGroups(id)).toEqual([a.translationGroup]); + }); + + it("copies live links to a duplicate, not the source's staged selection", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const id = await publishedPost("Duplicated", [a.id]); + await runtime.handleContentUpdate("posts", id, { references: { related_pages: [b.id] } }); + + const copy = await handleContentDuplicate(ctx.db, "posts", id); + expect(copy.success).toBe(true); + if (!copy.success) return; + + const relations = new RelationRepository(ctx.db); + const copyItem = await new ContentRepository(ctx.db).findById("posts", copy.data.item.id); + const links = await relations.getChildrenPage( + "posts_related_pages", + copyItem!.translationGroup!, + ); + expect(links.items.map((edge) => edge.childGroup)).toEqual([a.translationGroup]); + }); + + it("refuses to publish a staged selection the relation has since outgrown", async () => { + const [a, b] = [await createPage("A"), await createPage("B")]; + const relations = new RelationRepository(ctx.db); + const relation = await relations.findBySlug("posts_related_pages"); + + const id = await publishedPost("Tightened", [a.id]); + await runtime.handleContentUpdate("posts", id, { + references: { related_pages: [a.id, b.id] }, + }); + + await relations.update(relation!.id, { maxChildrenPerParent: 1 }); + + const published = await runtime.handleContentPublish("posts", id); + expect(published.success).toBe(false); + if (!published.success) { + expect(published.error.code).toBe("VALIDATION_ERROR"); + expect(published.error.message).toContain("related_pages"); + } + expect(await liveGroups(id)).toEqual([a.translationGroup]); + }); + + it("rejects a save that empties a required reference field outright", async () => { + const registry = new SchemaRegistry(ctx.db); + await new RelationRepository(ctx.db).create({ + slug: "posts_hero_page", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Hero page", + }); + await registry.createField("posts", { + slug: "hero_page", + label: "Hero page", + type: "reference", + required: true, + validation: { + relation: "posts_hero_page", + relationSide: "parent", + targetCollection: "pages", + }, + }); + + const a = await createPage("A"); + const created = await runtime.handleContentCreate("posts", { + data: { title: "Required" }, + slug: "required-now", + references: { related_pages: [], hero_page: [a.id] }, + }); + expect(created.success).toBe(true); + const id = created.data!.item.id; + + const saved = await runtime.handleContentUpdate("posts", id, { + references: { hero_page: [] }, + }); + expect(saved.success).toBe(false); + if (!saved.success) { + expect(saved.error.code).toBe("VALIDATION_ERROR"); + expect(saved.error.message).toContain("hero_page"); + } + }); + + it("refuses to publish a draft that does not satisfy a required field added later", async () => { + const id = await publishedPost("Predates the field", []); + + // A required reference field added to a collection that already holds + // entries: nothing they have staged mentions it. + const registry = new SchemaRegistry(ctx.db); + await new RelationRepository(ctx.db).create({ + slug: "posts_hero_page", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Hero page", + maxChildrenPerParent: 1, + }); + await registry.createField("posts", { + slug: "hero_page", + label: "Hero page", + type: "reference", + required: true, + validation: { + relation: "posts_hero_page", + relationSide: "parent", + targetCollection: "pages", + }, + }); + + const saved = await runtime.handleContentUpdate("posts", id, { data: { title: "Renamed" } }); + expect(saved.success).toBe(true); + + const published = await runtime.handleContentPublish("posts", id); + + expect(published.success).toBe(false); + if (!published.success) expect(published.error.code).toBe("VALIDATION_ERROR"); + }); + + it("carries a child-side field's selection onto a duplicate", async () => { + // `pages` views the same relation from the child end, so the page's own + // field selection is the set of posts pointing at it. + await new SchemaRegistry(ctx.db).createField("pages", { + slug: "linking_posts", + label: "Linking posts", + type: "reference", + validation: { + relation: "posts_related_pages", + relationSide: "child", + targetCollection: "posts", + }, + }); + const page = await createPage("Linked"); + await publishedPost("Links to it", [page.id]); + + const copy = await handleContentDuplicate(ctx.db, "pages", page.id); + expect(copy.success).toBe(true); + if (!copy.success) return; + + const copyItem = await new ContentRepository(ctx.db).findById("pages", copy.data.item.id); + const parents = await new RelationRepository(ctx.db).getParentsPage( + "posts_related_pages", + copyItem!.translationGroup!, + ); + expect(parents.items).toHaveLength(1); + }); +}); diff --git a/packages/core/tests/integration/content/reference-public-query.test.ts b/packages/core/tests/integration/content/reference-public-query.test.ts new file mode 100644 index 0000000000..f09d9b4675 --- /dev/null +++ b/packages/core/tests/integration/content/reference-public-query.test.ts @@ -0,0 +1,433 @@ +/** + * Site code reads an entry's references. + * + * `getEmDashEntry(..., { references })` resolves each selected field to real + * entries — the same shape a direct read of the target collection returns — and + * `getEmDashReferences` walks past the first page. What these tests pin is the + * behaviour a template depends on: link order, the locale variant chosen, what + * an anonymous render is allowed to see, and that a caller who asks for nothing + * pays for nothing. + */ + +import type { + Kysely, + KyselyPlugin, + PluginTransformQueryArgs, + PluginTransformResultArgs, + QueryResult, + RootOperationNode, + UnknownRow, +} from "kysely"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { handleContentCreate } from "../../../src/api/handlers/content.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import type { Database } from "../../../src/database/types.js"; +import type { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import { getEmDashEntry, getEmDashReferences } from "../../../src/query.js"; +import { resolveReferencePages } from "../../../src/references/resolve.js"; +import { runWithContext } from "../../../src/request-context.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { createTestRuntime } from "../../utils/mcp-runtime.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +vi.mock("astro:content", () => ({ + getLiveCollection: vi.fn(), + getLiveEntry: vi.fn(), +})); + +import { getLiveEntry } from "astro:content"; + +/** Records the SQL a render issues, so reference resolution's cost is pinned. */ +class QueryRecorder implements KyselyPlugin { + statements: string[] = []; + + transformQuery(args: PluginTransformQueryArgs): RootOperationNode { + this.statements.push(args.node.kind); + return args.node; + } + + transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> { + return Promise.resolve(args.result); + } +} + +describeEachDialect("public reference queries", (dialect) => { + let ctx: DialectTestContext; + let db: Kysely<Database>; + let runtime: EmDashRuntime; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + db = ctx.db; + + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + await registry.createField("pages", { slug: "title", label: "Title", type: "string" }); + await registry.createField("pages", { slug: "featured", label: "Featured", type: "boolean" }); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relations = new RelationRepository(db); + await relations.create({ + slug: "posts_related_pages", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Related pages", + }); + await registry.createField("posts", { + slug: "related_pages", + label: "Related pages", + type: "reference", + validation: { + relation: "posts_related_pages", + relationSide: "parent", + targetCollection: "pages", + }, + }); + // The inverse: a page lists the posts that point at it. + await registry.createField("pages", { + slug: "linking_posts", + label: "Linking posts", + type: "reference", + validation: { + relation: "posts_related_pages", + relationSide: "child", + targetCollection: "posts", + }, + }); + + runtime = createTestRuntime(db); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + vi.mocked(getLiveEntry).mockReset(); + }); + + async function createPage( + title: string, + options: { publish?: boolean; featured?: boolean } = {}, + ) { + const slug = title.toLowerCase().replaceAll(" ", "-"); + const result = await handleContentCreate(db, "pages", { + data: { title, featured: options.featured ?? false }, + slug, + }); + if (!result.success) throw new Error(`Page setup failed: ${result.error.message}`); + if (options.publish !== false) { + const published = await runtime.handleContentPublish("pages", result.data.item.id); + if (!published.success) throw new Error("Page publish failed"); + } + return result.data.item; + } + + async function createPost(title: string, pageIds: string[]) { + const created = await runtime.handleContentCreate("posts", { + data: { title }, + slug: title.toLowerCase().replaceAll(" ", "-"), + references: { related_pages: pageIds }, + }); + if (!created.success || !created.data) throw new Error("Post setup failed"); + const published = await runtime.handleContentPublish("posts", created.data.item.id); + if (!published.success) throw new Error("Post publish failed"); + return created.data.item; + } + + async function groupOf(collection: string, id: string): Promise<string> { + const item = await new ContentRepository(db).findById(collection, id); + if (!item?.translationGroup) throw new Error(`${collection}/${id} has no translation group`); + return item.translationGroup; + } + + /** Resolve as an anonymous render would, inside a request context bound to the test db. */ + function resolvePublic( + collection: string, + entryGroup: string, + selection: Record<string, true | { limit?: number; cursor?: string }>, + overrides: { serveDrafts?: boolean; draftRevisionId?: string; locale?: string | null } = {}, + ) { + return runWithContext({ editMode: false, db }, () => + resolveReferencePages({ + collection, + entryGroup, + locale: overrides.locale === undefined ? "en" : overrides.locale, + draftRevisionId: overrides.draftRevisionId, + serveDrafts: overrides.serveDrafts ?? false, + selection, + }), + ); + } + + it("resolves a parent-side field in link order", async () => { + const first = await createPage("Page One"); + const second = await createPage("Page Two"); + const post = await createPost("Hello", [second.id, first.id]); + + const pages = await resolvePublic("posts", await groupOf("posts", post.id), { + related_pages: true, + }); + + expect(pages.related_pages?.collection).toBe("pages"); + expect(pages.related_pages?.entries.map((entry) => entry.slug)).toEqual([ + "page-two", + "page-one", + ]); + }); + + it("gives a referenced entry the same data shape as a direct read", async () => { + const page = await createPage("Page One", { featured: true }); + const post = await createPost("Hello", [page.id]); + + const pages = await resolvePublic("posts", await groupOf("posts", post.id), { + related_pages: true, + }); + + const child = pages.related_pages?.entries[0]; + expect(child?.id).toBe("page-one"); + expect(child?.data.slug).toBe("page-one"); + expect(child?.data.title).toBe("Page One"); + // Booleans and dates are mapped, not handed back as raw column values — + // a referenced entry renders through the same template as a direct one. + expect(child?.data.featured).toBe(true); + expect(child?.data.createdAt).toBeInstanceOf(Date); + }); + + it("hides an unpublished target from a public render and shows it to a draft render", async () => { + const draftPage = await createPage("Page One", { publish: false }); + const post = await createPost("Hello", [draftPage.id]); + const group = await groupOf("posts", post.id); + + const anonymous = await resolvePublic("posts", group, { related_pages: true }); + expect(anonymous.related_pages?.entries).toEqual([]); + + const preview = await resolvePublic( + "posts", + group, + { related_pages: true }, + { + serveDrafts: true, + }, + ); + expect(preview.related_pages?.entries.map((entry) => entry.slug)).toEqual(["page-one"]); + }); + + it("resolves a child-side field to the entries pointing at it", async () => { + const page = await createPage("Page One"); + const post = await createPost("Hello", [page.id]); + + const pages = await resolvePublic("pages", await groupOf("pages", page.id), { + linking_posts: true, + }); + + expect(pages.linking_posts?.collection).toBe("posts"); + expect(pages.linking_posts?.entries.map((entry) => entry.slug)).toEqual([post.slug]); + }); + + it("prefers a staged selection only when the render may see drafts", async () => { + const published = await createPage("Page One"); + const staged = await createPage("Page Two"); + const post = await createPost("Hello", [published.id]); + + const updated = await runtime.handleContentUpdate("posts", post.id, { + references: { related_pages: [staged.id] }, + }); + if (!updated.success) throw new Error("Post update failed"); + + const row = await new ContentRepository(db).findById("posts", post.id); + const draftRevisionId = row?.draftRevisionId ?? undefined; + expect(draftRevisionId).toBeTruthy(); + const group = await groupOf("posts", post.id); + + const anonymous = await resolvePublic( + "posts", + group, + { related_pages: true }, + { + draftRevisionId, + }, + ); + expect(anonymous.related_pages?.entries.map((entry) => entry.slug)).toEqual(["page-one"]); + + const preview = await resolvePublic( + "posts", + group, + { related_pages: true }, + { + draftRevisionId, + serveDrafts: true, + }, + ); + expect(preview.related_pages?.entries.map((entry) => entry.slug)).toEqual(["page-two"]); + }); + + it("pages a selection and walks it with the cursor it returns", async () => { + const pageIds: string[] = []; + for (const title of ["Page One", "Page Two", "Page Three"]) { + pageIds.push((await createPage(title)).id); + } + const post = await createPost("Hello", pageIds); + const group = await groupOf("posts", post.id); + + const first = await resolvePublic("posts", group, { related_pages: { limit: 2 } }); + expect(first.related_pages?.entries.map((entry) => entry.slug)).toEqual([ + "page-one", + "page-two", + ]); + expect(first.related_pages?.nextCursor).toBeTruthy(); + + const second = await runWithContext({ editMode: false, db }, () => + getEmDashReferences("posts", post.id, "related_pages", { + limit: 2, + cursor: first.related_pages!.nextCursor, + }), + ); + expect(second.entries.map((entry) => entry.id)).toEqual(["page-three"]); + expect(second.nextCursor).toBeUndefined(); + }); + + it("costs one field-map read, one link read per field, and one read per target collection", async () => { + const page = await createPage("Page One"); + const post = await createPost("Hello", [page.id]); + const postGroup = await groupOf("posts", post.id); + + // A second parent-side field on the same collection, pointing at the same + // target, so the extra cost of a second field is isolated from entry reads. + await new RelationRepository(db).create({ + slug: "posts_further_pages", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Further pages", + }); + await new SchemaRegistry(db).createField("posts", { + slug: "further_pages", + label: "Further pages", + type: "reference", + validation: { + relation: "posts_further_pages", + relationSide: "parent", + targetCollection: "pages", + }, + }); + + const recorder = new QueryRecorder(); + const counted = db.withPlugin(recorder); + + await runWithContext({ editMode: false, db: counted }, () => + resolveReferencePages({ + collection: "posts", + entryGroup: postGroup, + locale: "en", + serveDrafts: false, + selection: { related_pages: true }, + }), + ); + const oneField = recorder.statements.length; + + recorder.statements = []; + await runWithContext({ editMode: false, db: counted }, () => + resolveReferencePages({ + collection: "posts", + entryGroup: postGroup, + locale: "en", + serveDrafts: false, + selection: { related_pages: true, further_pages: true }, + }), + ); + const twoFields = recorder.statements.length; + + // Field map + one link read + one entry read; the second field adds only + // its own link read, since both fields share the map and the target read. + expect({ oneField, twoFields }).toEqual({ oneField: 3, twoFields: 4 }); + }); + + it("survives a cursor issued by the other side of the preview boundary", async () => { + const pages = [await createPage("Page One"), await createPage("Page Two")]; + const third = await createPage("Page Three"); + const post = await createPost( + "Hello", + pages.map((page) => page.id), + ); + const group = await groupOf("posts", post.id); + + // Stage a change so a preview render pages the staged selection. + await runtime.handleContentUpdate("posts", post.id, { + references: { related_pages: [pages[0]!.id, pages[1]!.id, third.id] }, + }); + const draftRevisionId = (await new ContentRepository(db).findById("posts", post.id)) + ?.draftRevisionId; + expect(draftRevisionId).toBeTruthy(); + + const preview = await resolvePublic( + "posts", + group, + { related_pages: { limit: 2 } }, + { serveDrafts: true, draftRevisionId }, + ); + const stagedCursor = preview.related_pages?.nextCursor; + expect(stagedCursor).toBeTruthy(); + + // The draft publishes (or the preview session ends) and the same cursor + // comes back on a public render, which reads links rather than the draft. + const promoted = await runtime.handleContentPublish("posts", post.id); + expect(promoted.success).toBe(true); + + const published = await resolvePublic( + "posts", + group, + { related_pages: { limit: 2, cursor: stagedCursor } }, + { serveDrafts: false }, + ); + expect(published.related_pages?.entries.map((entry) => entry.slug)).toEqual(["page-three"]); + }); + + it("returns an empty page for an unknown field", async () => { + const post = await createPost("Hello", []); + const result = await runWithContext({ editMode: false, db }, () => + getEmDashReferences("posts", post.id, "not_a_field"), + ); + expect(result.entries).toEqual([]); + expect(result.error).toBeUndefined(); + }); + + it("attaches the selected fields to a loaded entry and nothing otherwise", async () => { + const page = await createPage("Page One"); + const post = await createPost("Hello", [page.id]); + const group = await groupOf("posts", post.id); + + vi.mocked(getLiveEntry).mockResolvedValue({ + entry: { + id: post.slug, + data: { + id: post.id, + slug: post.slug, + title: "Hello", + status: "published", + locale: "en", + translationGroup: group, + }, + }, + cacheHint: {}, + }); + + const withRefs = await runWithContext({ editMode: false, db }, () => + getEmDashEntry("posts", post.slug, { references: { related_pages: true } }), + ); + expect( + withRefs.entry?.references?.related_pages?.entries.map((entry) => entry.data.title), + ).toEqual(["Page One"]); + + const without = await runWithContext({ editMode: false, db }, () => + getEmDashEntry("posts", post.slug), + ); + expect(without.entry?.references).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/integration/content/reference-query-caching.test.ts b/packages/core/tests/integration/content/reference-query-caching.test.ts new file mode 100644 index 0000000000..2fb799e602 --- /dev/null +++ b/packages/core/tests/integration/content/reference-query-caching.test.ts @@ -0,0 +1,256 @@ +/** + * Reference pages inside the entry's cached snapshot. + * + * `getEmDashEntry(..., { references })` resolves its pages while the entry's + * object-cache snapshot is being built, so a warm hit serves the children + * without re-reading them. That only holds together if three things are true at + * once: the selection is part of the cache key, the targets' namespaces are part + * of the snapshot's dependencies, and the route cache hint names every child row + * the render read. Each test below pins one of them. + * + * The object cache is dialect-independent, so these run against sqlite only; + * `reference-public-query.test.ts` covers the resolution itself on both. + */ + +import { sql, type Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { handleContentCreate } from "../../../src/api/handlers/content.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import type { Database } from "../../../src/database/types.js"; +import type { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import { + __setObjectCacheBackendForTests, + type ObjectCacheBackend, +} from "../../../src/object-cache/index.js"; +import { getEmDashEntry } from "../../../src/query.js"; +import { getReferenceFieldMap } from "../../../src/references/field-map.js"; +import { runWithContext } from "../../../src/request-context.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { createTestRuntime } from "../../utils/mcp-runtime.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); +vi.mock("astro:content", () => ({ + getLiveCollection: vi.fn(), + getLiveEntry: vi.fn(), +})); + +import { getLiveEntry } from "astro:content"; + +function spyBackend(): ObjectCacheBackend { + const store = new Map<string, string>(); + return { + get: (key) => Promise.resolve(store.get(key) ?? null), + set: (key, value) => { + store.set(key, value); + return Promise.resolve(); + }, + delete: (key) => { + store.delete(key); + return Promise.resolve(); + }, + }; +} + +/** Let the cache's fire-and-forget write land before the next read. */ +async function flush(): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("reference pages in the entry cache", () => { + let db: Kysely<Database>; + let runtime: EmDashRuntime; + + beforeEach(async () => { + db = await setupTestDatabase(); + + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + await registry.createField("pages", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + await new RelationRepository(db).create({ + slug: "posts_related_pages", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Posts", + childLabel: "Related pages", + }); + await registry.createField("posts", { + slug: "related_pages", + label: "Related pages", + type: "reference", + validation: { + relation: "posts_related_pages", + relationSide: "parent", + targetCollection: "pages", + }, + }); + + runtime = createTestRuntime(db); + __setObjectCacheBackendForTests(spyBackend(), { revalidate: 1000, defaultTtl: 3600 }); + vi.mocked(getLiveEntry).mockReset(); + }); + + afterEach(async () => { + __setObjectCacheBackendForTests(null); + await teardownTestDatabase(db); + vi.mocked(getLiveEntry).mockReset(); + }); + + async function createPage(title: string) { + const slug = title.toLowerCase().replaceAll(" ", "-"); + const result = await handleContentCreate(db, "pages", { data: { title }, slug }); + if (!result.success) throw new Error(`Page setup failed: ${result.error.message}`); + const published = await runtime.handleContentPublish("pages", result.data.item.id); + if (!published.success) throw new Error("Page publish failed"); + return result.data.item; + } + + async function createPost(title: string, pageIds: string[]) { + const created = await runtime.handleContentCreate("posts", { + data: { title }, + slug: title.toLowerCase().replaceAll(" ", "-"), + references: { related_pages: pageIds }, + }); + if (!created.success || !created.data) throw new Error("Post setup failed"); + const published = await runtime.handleContentPublish("posts", created.data.item.id); + if (!published.success) throw new Error("Post publish failed"); + return created.data.item; + } + + /** Point the mocked loader at a published post, the way an anonymous render resolves one. */ + async function mockPost(post: { id: string; slug: string }): Promise<void> { + const row = await new ContentRepository(db).findById("posts", post.id); + vi.mocked(getLiveEntry).mockResolvedValue({ + entry: { + id: post.slug, + data: { + id: post.id, + slug: post.slug, + title: "Hello", + status: "published", + locale: "en", + translationGroup: row?.translationGroup, + }, + }, + cacheHint: { tags: [post.id] }, + }); + } + + /** Rename a row behind the cache's back, so a stale read is visible as the old title. */ + async function renameBehindTheCache(collection: string, id: string, title: string) { + await sql`UPDATE ${sql.ref(`ec_${collection}`)} SET title = ${title} WHERE id = ${id}`.execute( + db, + ); + } + + function read(slug: string, references?: Record<string, true | { limit?: number }>) { + return runWithContext({ editMode: false, db }, () => + getEmDashEntry("posts", slug, references ? { references } : undefined), + ); + } + + it("serves the reference pages from the warm snapshot", async () => { + const page = await createPage("Page One"); + const post = await createPost("Hello", [page.id]); + await mockPost(post); + + await read(post.slug, { related_pages: true }); + await flush(); + await renameBehindTheCache("pages", page.id, "Renamed"); + + const warm = await read(post.slug, { related_pages: true }); + const child = warm.entry?.references?.related_pages?.entries[0]; + expect(child?.data.title).toBe("Page One"); + // The snapshot round-trips a child's dates as Dates, not ISO strings. + expect(child?.data.createdAt).toBeInstanceOf(Date); + }); + + it("does not serve a reference-less snapshot to a caller that asked for references", async () => { + const page = await createPage("Page One"); + const post = await createPost("Hello", [page.id]); + await mockPost(post); + + await read(post.slug); + await flush(); + + const withRefs = await read(post.slug, { related_pages: true }); + expect( + withRefs.entry?.references?.related_pages?.entries.map((entry) => entry.data.title), + ).toEqual(["Page One"]); + }); + + it("keys the snapshot on the selection, so a wider page is not served a narrower one", async () => { + const first = await createPage("Page One"); + const second = await createPage("Page Two"); + const post = await createPost("Hello", [first.id, second.id]); + await mockPost(post); + + const narrow = await read(post.slug, { related_pages: { limit: 1 } }); + expect(narrow.entry?.references?.related_pages?.entries).toHaveLength(1); + await flush(); + + const wide = await read(post.slug, { related_pages: { limit: 5 } }); + expect(wide.entry?.references?.related_pages?.entries).toHaveLength(2); + }); + + it("drops the cached snapshot when a referenced entry is written", async () => { + const page = await createPage("Page One"); + const post = await createPost("Hello", [page.id]); + await mockPost(post); + + await read(post.slug, { related_pages: true }); + await flush(); + + const updated = await runtime.handleContentUpdate("pages", page.id, { + data: { title: "Renamed" }, + }); + if (!updated.success) throw new Error("Page update failed"); + const republished = await runtime.handleContentPublish("pages", page.id); + if (!republished.success) throw new Error("Page republish failed"); + await flush(); + + const after = await read(post.slug, { related_pages: true }); + expect(after.entry?.references?.related_pages?.entries[0]?.data.title).toBe("Renamed"); + }); + + it("names every child row in the cache hint", async () => { + const first = await createPage("Page One"); + const second = await createPage("Page Two"); + const post = await createPost("Hello", [first.id, second.id]); + await mockPost(post); + + const result = await read(post.slug, { related_pages: true }); + + expect(result.cacheHint.tags).toEqual(expect.arrayContaining([post.id, first.id, second.id])); + // The newest of parent and children, so a child write moves the header. + const repo = new ContentRepository(db); + const stamps = await Promise.all( + [first.id, second.id].map(async (id) => { + const row = await repo.findById("pages", id); + return new Date(row!.updatedAt!).getTime(); + }), + ); + expect(result.cacheHint.lastModified?.getTime()).toBe(Math.max(...stamps)); + }); + + it("stops serving a deleted reference field from the cached field map", async () => { + const { handleSchemaFieldDelete } = await import("../../../src/api/handlers/schema.js"); + + const warm = await runWithContext({ editMode: false, db }, () => getReferenceFieldMap("posts")); + expect(warm.has("related_pages")).toBe(true); + await flush(); + + const deleted = await handleSchemaFieldDelete(db, "posts", "related_pages"); + expect(deleted.success).toBe(true); + + const after = await runWithContext({ editMode: false, db }, () => + getReferenceFieldMap("posts"), + ); + expect(after.has("related_pages")).toBe(false); + }); +}); diff --git a/packages/core/tests/integration/database/content-references.test.ts b/packages/core/tests/integration/database/content-references.test.ts index 7e30599919..671a9e3912 100644 --- a/packages/core/tests/integration/database/content-references.test.ts +++ b/packages/core/tests/integration/database/content-references.test.ts @@ -35,12 +35,11 @@ describeEachDialect("Content references schema", (dialect) => { .insertInto("_emdash_relations") .values({ id: "rel_manages", - name: "manages", + slug: "manages", parent_collection: "employees", child_collection: "employees", parent_label: "Manager", child_label: "Direct report", - translation_group: "rel_manages", }) .execute(); @@ -48,7 +47,7 @@ describeEachDialect("Content references schema", (dialect) => { .insertInto("_emdash_content_references") .values({ id: "ref_1", - relation_group: "rel_manages", + relation_id: "rel_manages", parent_group: "grp_alice", child_group: "grp_bob", }) @@ -57,130 +56,69 @@ describeEachDialect("Content references schema", (dialect) => { const rel = await ctx.db .selectFrom("_emdash_relations") .selectAll() - .where("name", "=", "manages") + .where("slug", "=", "manages") .executeTakeFirstOrThrow(); - expect(rel.locale).toBe("en"); // default locale backfill expect(rel.child_collection).toBe("employees"); + expect(rel.parent_label).toBe("Manager"); const edge = await ctx.db .selectFrom("_emdash_content_references") .selectAll() .where("id", "=", "ref_1") .executeTakeFirstOrThrow(); - expect(edge.relation_group).toBe("rel_manages"); + expect(edge.relation_id).toBe("rel_manages"); expect(edge.sort_order).toBe(0); // default }); it("rejects a duplicate edge (same relation, parent, child)", async () => { await ctx.db .insertInto("_emdash_content_references") - .values({ id: "e1", relation_group: "r1", parent_group: "p1", child_group: "c1" }) + .values({ id: "e1", relation_id: "r1", parent_group: "p1", child_group: "c1" }) .execute(); await expect( ctx.db .insertInto("_emdash_content_references") - .values({ id: "e2", relation_group: "r1", parent_group: "p1", child_group: "c1" }) + .values({ id: "e2", relation_id: "r1", parent_group: "p1", child_group: "c1" }) .execute(), ).rejects.toThrow(); }); - it("rejects a duplicate relation name within one locale, allows across locales", async () => { + it("rejects a duplicate relation slug outright", async () => { const base = { - name: "manages", parent_collection: "employees", child_collection: "employees", parent_label: "Manager", child_label: "Report", - translation_group: "tg1", }; await ctx.db .insertInto("_emdash_relations") - .values({ id: "r_en", locale: "en", ...base }) + .values({ id: "r_1", slug: "manages", ...base }) .execute(); - // Same (name, locale) -> rejected + // A slug names exactly one relation — that is what lets a reference + // selection be addressed by slug from any locale without ambiguity. await expect( ctx.db .insertInto("_emdash_relations") - .values({ id: "r_en2", locale: "en", ...base }) + .values({ id: "r_2", slug: "manages", ...base, parent_collection: "posts" }) .execute(), ).rejects.toThrow(); - - // Same name, different locale -> allowed - await ctx.db - .insertInto("_emdash_relations") - .values({ - id: "r_fr", - locale: "fr", - ...base, - parent_label: "Responsable", - child_label: "Subordonné", - }) - .execute(); - - const rows = await ctx.db - .selectFrom("_emdash_relations") - .select(["id", "locale"]) - .where("name", "=", "manages") - .execute(); - expect(rows).toHaveLength(2); }); - it("rejects a duplicate (translation_group, locale), allows across locales", async () => { - const base = { - parent_collection: "employees", - child_collection: "employees", - parent_label: "Manager", - child_label: "Report", - translation_group: "shared_tg", - }; - - await ctx.db - .insertInto("_emdash_relations") - .values({ id: "g_en", name: "manages", locale: "en", ...base }) - .execute(); - - // Same (translation_group, locale) -> rejected by the partial unique, - // even though `name` differs. - await expect( - ctx.db - .insertInto("_emdash_relations") - .values({ id: "g_en2", name: "leads", locale: "en", ...base }) - .execute(), - ).rejects.toThrow(); - - // Same translation_group, different locale -> allowed. - await ctx.db - .insertInto("_emdash_relations") - .values({ id: "g_fr", name: "gere", locale: "fr", ...base }) - .execute(); - - const rows = await ctx.db - .selectFrom("_emdash_relations") - .select(["id", "locale"]) - .where("translation_group", "=", "shared_tg") - .execute(); - expect(rows).toHaveLength(2); - }); - - it("rejects a relation with a null translation_group", async () => { - // translation_group is NOT NULL: a relation must be addressable by edges - // (`_emdash_content_references.relation_group` is NOT NULL), so a null - // group would be an unreferenceable, dead row. + it("rejects a relation with a null slug", async () => { + // A relation with no slug could not be addressed by any surface. await expect( ctx.db .insertInto("_emdash_relations") .values({ id: "n1", - name: "manages", + slug: null as unknown as string, parent_collection: "employees", child_collection: "employees", parent_label: "Manager", child_label: "Report", - locale: "en", - translation_group: null as unknown as string, }) .execute(), ).rejects.toThrow(); @@ -191,9 +129,9 @@ describeEachDialect("Content references schema", (dialect) => { await ctx.db .insertInto("_emdash_content_references") .values([ - { id: "e1", relation_group: "r1", parent_group: "p1", child_group: "c1", sort_order: 0 }, - { id: "e2", relation_group: "r1", parent_group: "p1", child_group: "c2", sort_order: 1 }, - { id: "e3", relation_group: "r1", parent_group: "p2", child_group: "c1", sort_order: 0 }, + { id: "e1", relation_id: "r1", parent_group: "p1", child_group: "c1", sort_order: 0 }, + { id: "e2", relation_id: "r1", parent_group: "p1", child_group: "c2", sort_order: 1 }, + { id: "e3", relation_id: "r1", parent_group: "p2", child_group: "c1", sort_order: 0 }, ]) .execute(); @@ -202,7 +140,7 @@ describeEachDialect("Content references schema", (dialect) => { .selectFrom("_emdash_content_references") .select("child_group") .where("parent_group", "=", "p1") - .where("relation_group", "=", "r1") + .where("relation_id", "=", "r1") .orderBy("sort_order") .execute(); expect(children.map((r) => r.child_group)).toEqual(["c1", "c2"]); @@ -212,7 +150,7 @@ describeEachDialect("Content references schema", (dialect) => { .selectFrom("_emdash_content_references") .select("parent_group") .where("child_group", "=", "c1") - .where("relation_group", "=", "r1") + .where("relation_id", "=", "r1") .orderBy("parent_group") .execute(); expect(parents.map((r) => r.parent_group)).toEqual(["p1", "p2"]); @@ -222,7 +160,7 @@ describeEachDialect("Content references schema", (dialect) => { // Self reference: parent_group === child_group is permitted. await ctx.db .insertInto("_emdash_content_references") - .values({ id: "self1", relation_group: "r1", parent_group: "x1", child_group: "x1" }) + .values({ id: "self1", relation_id: "r1", parent_group: "x1", child_group: "x1" }) .execute(); const row = await ctx.db @@ -242,31 +180,22 @@ describeEachDialect("Content references schema", (dialect) => { const names = new Set(result.rows.map((r) => r.name)); for (const idx of [ - "idx__emdash_relations_locale", - "idx__emdash_relations_translation_group", "idx__emdash_relations_parent_collection", "idx__emdash_relations_child_collection", - "idx__emdash_relations_group_locale_unique", "idx__emdash_content_references_parent", "idx__emdash_content_references_child", "idx__emdash_content_references_relation", ]) { expect(names.has(idx), `missing index ${idx}`).toBe(true); } - }); - - it("down() drops both tables and up() can recreate them", async () => { - const { down, up } = await import("../../../src/database/migrations/043_content_references.js"); - await down(ctx.db); - - // Tables gone: a raw query against them should reject. - await expect(sql`SELECT 1 FROM _emdash_content_references`.execute(ctx.db)).rejects.toThrow(); - await expect(sql`SELECT 1 FROM _emdash_relations`.execute(ctx.db)).rejects.toThrow(); - - // Re-applying up() restores them. - await up(ctx.db); - const rows = await ctx.db.selectFrom("_emdash_relations").selectAll().execute(); - expect(Array.isArray(rows)).toBe(true); + // 076 dropped the locale-shaped indexes with the columns behind them. + for (const idx of [ + "idx__emdash_relations_locale", + "idx__emdash_relations_translation_group", + "idx__emdash_relations_group_locale_unique", + ]) { + expect(names.has(idx), `index ${idx} should be gone`).toBe(false); + } }); }); diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 021ffc99e6..774b584141 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -146,17 +146,15 @@ describe("Database Migrations (Integration)", () => { db = await setupTestDatabaseWithCollections(); // Kysely only re-runs trailing entries; include the latest migrations. + // + // Starts after 043: migration 076 restructures the table 043 creates, so + // replaying 043 against a database that has already reached 076 would try + // to index `locale` and `translation_group`, which 076 removes. Migrations + // are forward-only — 043 is shipped history and is not edited to + // accommodate a later one. The window has to stay contiguous, since a + // recorded migration sitting before a pending one reads as corrupted + // history, so excluding 043 also excludes everything before it. const trailing = [ - "034_published_at_index", - "035_bounded_404_log", - "036_i18n_menus_and_taxonomies", - "037_credential_algorithm", - "038_registry_plugin_state", - "039_fix_fts5_triggers", - "040_byline_i18n", - "041_content_locale_list_index", - "042_byline_fields", - "043_content_references", "044_comment_reactions", "045_taxonomy_parent_group", "046_media_usage_index", @@ -189,6 +187,8 @@ describe("Database Migrations (Integration)", () => { "073_media_focal_point", "074_content_deleted_scheduled_index", "075_entry_edit_locks", + "076_relations_structural", + "077_reference_field_relations", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/integration/database/reference-field-relations-migration.test.ts b/packages/core/tests/integration/database/reference-field-relations-migration.test.ts new file mode 100644 index 0000000000..1eb76d7a39 --- /dev/null +++ b/packages/core/tests/integration/database/reference-field-relations-migration.test.ts @@ -0,0 +1,335 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { columnExists } from "../../../src/database/dialect-helpers.js"; +import * as migration077 from "../../../src/database/migrations/077_reference_field_relations.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { createLegacyReferenceField } from "../../utils/legacy-reference-field.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +interface FieldValidation { + relation?: string; + relationSide?: string; + targetCollection?: string; + multiple?: boolean; +} + +describeEachDialect("reference field relations migration (077)", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "authors", label: "Authors", labelSingular: "Author" }); + await registry.createField("authors", { slug: "name", label: "Name", type: "string" }); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + /** Write a value straight to the legacy column, as the pre-relations writer did. */ + async function writeColumn( + collection: string, + entryId: string, + column: string, + value: string, + ): Promise<void> { + await sql` + UPDATE ${sql.ref(`ec_${collection}`)} + SET ${sql.ref(column)} = ${value} + WHERE id = ${entryId} + `.execute(ctx.db); + } + + async function readValidation(collection: string, field: string): Promise<FieldValidation> { + const row = await sql<{ validation: string | null }>` + SELECT f.validation + FROM ${sql.ref("_emdash_fields")} AS f + INNER JOIN ${sql.ref("_emdash_collections")} AS c ON c.id = f.collection_id + WHERE c.slug = ${collection} AND f.slug = ${field} + `.execute(ctx.db); + const validation = row.rows[0]?.validation; + return validation ? (JSON.parse(validation) as FieldValidation) : {}; + } + + function readRelations() { + return sql<{ + id: string; + slug: string; + parent_collection: string; + child_collection: string; + parent_label: string; + parent_label_singular: string | null; + child_label: string; + max_children_per_parent: number | null; + }>`SELECT * FROM ${sql.ref("_emdash_relations")} ORDER BY slug ASC`.execute(ctx.db); + } + + function readEdges() { + return sql<{ + relation_id: string; + parent_group: string; + child_group: string; + sort_order: number; + }>` + SELECT relation_id, parent_group, child_group, sort_order + FROM ${sql.ref("_emdash_content_references")} + ORDER BY parent_group ASC, sort_order ASC + `.execute(ctx.db); + } + + it("binds a field whose target is named in options.collection and copies its id in", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + + const content = new ContentRepository(ctx.db); + const author = await content.create({ type: "authors", slug: "jane", data: { name: "Jane" } }); + const post = await content.create({ type: "posts", slug: "hello", data: { title: "Hello" } }); + await writeColumn("posts", post.id, "author", author.id); + + await migration077.up(ctx.db); + + const relations = await readRelations(); + expect(relations.rows).toHaveLength(1); + expect(relations.rows[0]).toMatchObject({ + slug: "posts_author", + parent_collection: "posts", + child_collection: "authors", + parent_label: "Posts", + parent_label_singular: "Post", + child_label: "Author", + // No `options.allowMultiple` means one reference, as the field helper documented. + max_children_per_parent: 1, + }); + + expect(await readValidation("posts", "author")).toEqual({ + relation: "posts_author", + relationSide: "parent", + targetCollection: "authors", + multiple: false, + }); + + const edges = await readEdges(); + expect(edges.rows).toEqual([ + { + relation_id: relations.rows[0]!.id, + parent_group: post.translationGroup, + child_group: author.translationGroup, + sort_order: 0, + }, + ]); + + // The column is the only copy of anything that did not resolve, so it stays. + expect(await columnExists(ctx.db, "ec_posts", "author")).toBe(true); + }); + + it("copies a multiple-reference array in selection order and drops ids that no longer resolve", async () => { + await createLegacyReferenceField(ctx.db, "posts", "related", { + targetCollection: "authors", + allowMultiple: true, + }); + + const content = new ContentRepository(ctx.db); + const first = await content.create({ type: "authors", slug: "first", data: { name: "First" } }); + const second = await content.create({ + type: "authors", + slug: "second", + data: { name: "Second" }, + }); + const post = await content.create({ type: "posts", slug: "hello", data: { title: "Hello" } }); + await writeColumn( + "posts", + post.id, + "related", + JSON.stringify([second.id, "gone-entry-id", first.id]), + ); + + await migration077.up(ctx.db); + + const relations = await readRelations(); + expect(relations.rows[0]?.max_children_per_parent).toBeNull(); + + const edges = await readEdges(); + expect(edges.rows.map((edge) => edge.child_group)).toEqual([ + second.translationGroup, + first.translationGroup, + ]); + expect(edges.rows.map((edge) => edge.sort_order)).toEqual([0, 1]); + }); + + it("changes nothing on a rerun", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + const content = new ContentRepository(ctx.db); + const author = await content.create({ type: "authors", slug: "jane", data: { name: "Jane" } }); + const post = await content.create({ type: "posts", slug: "hello", data: { title: "Hello" } }); + await writeColumn("posts", post.id, "author", author.id); + + await migration077.up(ctx.db); + const relationsAfterFirst = (await readRelations()).rows; + const edgesAfterFirst = (await readEdges()).rows; + + await migration077.up(ctx.db); + + expect((await readRelations()).rows).toEqual(relationsAfterFirst); + expect((await readEdges()).rows).toEqual(edgesAfterFirst); + }); + + it("finishes a run that was interrupted before the field row was written", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + const content = new ContentRepository(ctx.db); + const author = await content.create({ type: "authors", slug: "jane", data: { name: "Jane" } }); + const post = await content.create({ type: "posts", slug: "hello", data: { title: "Hello" } }); + await writeColumn("posts", post.id, "author", author.id); + + await migration077.up(ctx.db); + const relationId = (await readRelations()).rows[0]!.id; + + // Roll the completion fence back: the relation and its edges landed, the + // field row update did not. + await sql` + UPDATE ${sql.ref("_emdash_fields")} SET validation = NULL WHERE slug = 'author' + `.execute(ctx.db); + + await migration077.up(ctx.db); + + const relations = await readRelations(); + expect(relations.rows).toHaveLength(1); + expect(relations.rows[0]?.id).toBe(relationId); + expect((await readEdges()).rows).toHaveLength(1); + expect(await readValidation("posts", "author")).toMatchObject({ relation: "posts_author" }); + }); + + it("leaves a field alone when its slug is held by a relation of another shape", async () => { + // The slug has no collision suffix, so that a rerun can find the relation it + // would have created by name. A slug already in use is left alone. + await sql` + INSERT INTO ${sql.ref("_emdash_relations")} + (id, slug, parent_collection, child_collection, parent_label, child_label) + VALUES ('rel-existing', 'posts_author', 'posts', 'posts', 'Posts', 'Related') + `.execute(ctx.db); + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + + await migration077.up(ctx.db); + + expect(await readValidation("posts", "author")).toEqual({}); + expect((await readRelations()).rows).toHaveLength(1); + expect((await readRelations()).rows[0]?.id).toBe("rel-existing"); + }); + + it("leaves a field alone when its slug names a relation another field already binds", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("posts", { + slug: "writer", + label: "Writer", + type: "reference", + validation: { relation: "posts_author", relationSide: "parent", targetCollection: "authors" }, + }); + await sql` + INSERT INTO ${sql.ref("_emdash_relations")} + (id, slug, parent_collection, child_collection, parent_label, child_label, + max_children_per_parent) + VALUES ('rel-writer', 'posts_author', 'posts', 'authors', 'Posts', 'Writer', 1) + `.execute(ctx.db); + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + + await migration077.up(ctx.db); + + // Two fields over one relation and side have no defined merge, so the + // unbound field stays unbound even though the relation's shape matches. + expect(await readValidation("posts", "author")).toEqual({}); + }); + + it("leaves the second of two fields whose relation slugs truncate alike unbound", async () => { + // `{collection}_{field}` is cut to 63 chars, so two long field slugs on one + // collection can name the same relation. Merging their selections into one + // edge set would make each field show the other's entries. + const first = `author_${"x".repeat(60)}`; + const second = `author_${"x".repeat(59)}y`; + await createLegacyReferenceField(ctx.db, "posts", first, { targetCollection: "authors" }); + await createLegacyReferenceField(ctx.db, "posts", second, { targetCollection: "authors" }); + + await migration077.up(ctx.db); + + const firstValidation = await readValidation("posts", first); + const secondValidation = await readValidation("posts", second); + expect(firstValidation.relation).toBe(`posts_${first}`.slice(0, 63)); + expect(secondValidation).toEqual({}); + }); + + it("accepts a target named in validation.targetCollection", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { + validationTargetCollection: "authors", + }); + + await migration077.up(ctx.db); + + expect(await readValidation("posts", "author")).toMatchObject({ + relation: "posts_author", + targetCollection: "authors", + }); + }); + + it("leaves a field alone when no target collection can be resolved", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", {}); + await createLegacyReferenceField(ctx.db, "posts", "editor", { targetCollection: "gone" }); + + await migration077.up(ctx.db); + + expect((await readRelations()).rows).toEqual([]); + expect(await readValidation("posts", "author")).toEqual({}); + expect(await readValidation("posts", "editor")).toEqual({}); + }); + + it("leaves an indexed or searchable field alone, so its column keeps serving queries", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { + targetCollection: "authors", + indexed: true, + }); + await createLegacyReferenceField(ctx.db, "posts", "editor", { + targetCollection: "authors", + searchable: true, + }); + + await migration077.up(ctx.db); + + expect((await readRelations()).rows).toEqual([]); + expect(await readValidation("posts", "author")).toEqual({}); + expect(await readValidation("posts", "editor")).toEqual({}); + }); + + it("keeps one child for a single-reference field whose locale rows disagree", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + + const content = new ContentRepository(ctx.db); + const jane = await content.create({ type: "authors", slug: "jane", data: { name: "Jane" } }); + const rosa = await content.create({ type: "authors", slug: "rosa", data: { name: "Rosa" } }); + const english = await content.create({ + type: "posts", + slug: "hello", + data: { title: "Hello" }, + }); + const french = await content.create({ + type: "posts", + slug: "bonjour", + data: { title: "Bonjour" }, + locale: "fr", + translationOf: english.id, + }); + await writeColumn("posts", english.id, "author", jane.id); + await writeColumn("posts", french.id, "author", rosa.id); + + await migration077.up(ctx.db); + + const edges = await readEdges(); + expect(edges.rows).toHaveLength(1); + expect(edges.rows[0]?.child_group).toBe(jane.translationGroup); + }); +}); diff --git a/packages/core/tests/integration/database/relation-repository.test.ts b/packages/core/tests/integration/database/relation-repository.test.ts index 6d35be21a0..2befa6a0af 100644 --- a/packages/core/tests/integration/database/relation-repository.test.ts +++ b/packages/core/tests/integration/database/relation-repository.test.ts @@ -23,226 +23,143 @@ describeEachDialect("RelationRepository", (dialect) => { }); const baseInput = { - name: "manages", + slug: "manages", parentCollection: "employees", childCollection: "employees", parentLabel: "Manager", childLabel: "Direct report", }; - it("create mints an anchor row (translation_group = id, default locale)", async () => { + it("create stores the relation and reads it back by id", async () => { const rel = await repo.create({ ...baseInput }); expect(rel.id).toBeTruthy(); - expect(rel.translationGroup).toBe(rel.id); - expect(rel.locale).toBe("en"); - expect(rel.name).toBe("manages"); + expect(rel.slug).toBe("manages"); expect(rel.parentCollection).toBe("employees"); expect(rel.childCollection).toBe("employees"); + expect(rel.parentLabel).toBe("Manager"); const fetched = await repo.findById(rel.id); expect(fetched).toEqual(rel); }); - it("create with translationOf joins the group and inherits structural fields", async () => { - const anchor = await repo.create({ ...baseInput }); - const fr = await repo.create({ - name: "ignored-name", - parentCollection: "ignored", - childCollection: "ignored", - parentLabel: "Responsable", - childLabel: "Subordonné", - locale: "fr", - translationOf: anchor.id, - }); - - expect(fr.translationGroup).toBe(anchor.translationGroup); - expect(fr.locale).toBe("fr"); - expect(fr.name).toBe("manages"); - expect(fr.parentCollection).toBe("employees"); - expect(fr.childCollection).toBe("employees"); - expect(fr.parentLabel).toBe("Responsable"); - expect(fr.childLabel).toBe("Subordonné"); - }); - - it("create with translationOf omits collections and inherits them from the source", async () => { - const anchor = await repo.create({ ...baseInput }); - const fr = await repo.create({ - name: "ignored-name", - parentLabel: "Responsable", - childLabel: "Subordonné", - locale: "fr", - translationOf: anchor.id, - }); - - expect(fr.translationGroup).toBe(anchor.translationGroup); - expect(fr.parentCollection).toBe("employees"); - expect(fr.childCollection).toBe("employees"); - }); - - it("create without translationOf and without collections throws", async () => { - await expect( - repo.create({ - name: "manages", - parentLabel: "Manager", - childLabel: "Direct report", - }), - ).rejects.toThrow( - "parentCollection and childCollection are required unless translationOf is set", - ); - }); - - it("create with a missing translationOf source throws", async () => { - await expect( - repo.create({ ...baseInput, locale: "fr", translationOf: "does-not-exist" }), - ).rejects.toThrow("Source relation for translation not found"); + it("rejects a second relation with the same slug", async () => { + await repo.create({ ...baseInput }); + // A slug identifies one relation outright — that is what lets an entry in + // any locale resolve it without a locale to scope by. + await expect(repo.create({ ...baseInput, parentCollection: "posts" })).rejects.toThrow(); }); it("findById returns null for an unknown id", async () => { expect(await repo.findById("nope")).toBeNull(); }); - it("findByName filters by locale, and resolves deterministically without one", async () => { - const anchor = await repo.create({ ...baseInput }); - await repo.create({ - ...baseInput, - locale: "fr", - parentLabel: "Responsable", - childLabel: "Subordonné", - translationOf: anchor.id, - }); - - const fr = await repo.findByName("manages", "fr"); - expect(fr?.locale).toBe("fr"); - - const any = await repo.findByName("manages"); - expect(any?.locale).toBe("en"); // lowest locale code wins deterministically - - expect(await repo.findByName("missing")).toBeNull(); - }); - - it("findTranslations returns every locale sibling, ordered by locale", async () => { - const anchor = await repo.create({ ...baseInput }); - await repo.create({ - ...baseInput, - locale: "fr", - parentLabel: "Responsable", - childLabel: "Subordonné", - translationOf: anchor.id, - }); + it("findBySlug resolves without a locale", async () => { + const rel = await repo.create({ ...baseInput }); - const sibs = await repo.findTranslations(anchor.translationGroup); - expect(sibs.map((r) => r.locale)).toEqual(["en", "fr"]); + expect((await repo.findBySlug("manages"))?.id).toBe(rel.id); + expect(await repo.findBySlug("missing")).toBeNull(); }); - it("list returns relations ordered by name then id, optionally filtered by locale", async () => { - await repo.create({ ...baseInput, name: "writes", childCollection: "posts" }); - const manages = await repo.create({ ...baseInput, name: "manages" }); - await repo.create({ - ...baseInput, - locale: "fr", - parentLabel: "Responsable", - childLabel: "Subordonné", - translationOf: manages.id, - }); + it("list returns relations ordered by slug", async () => { + await repo.create({ ...baseInput, slug: "writes", childCollection: "posts" }); + await repo.create({ ...baseInput, slug: "manages" }); - const all = await repo.list(); - expect(all.map((r) => r.name)).toEqual(["manages", "manages", "writes"]); - - const enOnly = await repo.list("en"); - // The 'fr' row must be filtered out — assert the filter actually removes it. - expect(enOnly.length).toBeLessThan(all.length); - expect(enOnly.every((r) => r.locale === "en")).toBe(true); + expect((await repo.list()).map((r) => r.slug)).toEqual(["manages", "writes"]); }); it("findForCollection matches parent OR child collection", async () => { await repo.create({ ...baseInput, - name: "writes", + slug: "writes", parentCollection: "authors", childCollection: "posts", }); await repo.create({ ...baseInput, - name: "tags_rel", + slug: "tags_rel", parentCollection: "posts", childCollection: "tags", }); const forPosts = await repo.findForCollection("posts"); - // Asserted in returned order to also verify the (name, id) ORDER BY. - expect(forPosts.map((r) => r.name)).toEqual(["tags_rel", "writes"]); + // Asserted in returned order to also verify the slug ORDER BY. + expect(forPosts.map((r) => r.slug)).toEqual(["tags_rel", "writes"]); const forTags = await repo.findForCollection("tags"); - expect(forTags.map((r) => r.name)).toEqual(["tags_rel"]); + expect(forTags.map((r) => r.slug)).toEqual(["tags_rel"]); }); - it("update changes only the localized labels (no-op on missing id)", async () => { + it("update changes only the labels (no-op on missing id)", async () => { const rel = await repo.create({ ...baseInput }); const updated = await repo.update(rel.id, { parentLabel: "Lead", childLabel: "Report" }); expect(updated?.parentLabel).toBe("Lead"); expect(updated?.childLabel).toBe("Report"); // Structural fields untouched. - expect(updated?.name).toBe("manages"); + expect(updated?.slug).toBe("manages"); expect(updated?.parentCollection).toBe("employees"); expect(await repo.update("missing", { parentLabel: "x" })).toBeNull(); }); - it("delete of a non-last translation leaves edges intact", async () => { - const anchor = await repo.create({ ...baseInput }); - const fr = await repo.create({ + it("defaults both role limits and both singular labels to unset", async () => { + const rel = await repo.create({ ...baseInput }); + + expect(rel.maxChildrenPerParent).toBeNull(); + expect(rel.maxParentsPerChild).toBeNull(); + expect(rel.parentLabelSingular).toBeNull(); + expect(rel.childLabelSingular).toBeNull(); + }); + + it("stores each role's limit and singular label independently", async () => { + const rel = await repo.create({ ...baseInput, - locale: "fr", - parentLabel: "Responsable", - childLabel: "Subordonné", - translationOf: anchor.id, + parentLabelSingular: "Manager", + childLabelSingular: "Direct report", + maxChildrenPerParent: 5, }); - // Seed an edge directly (addReference arrives in Task 4). - await ctx.db - .insertInto("_emdash_content_references") - .values({ - id: ulid(), - relation_group: anchor.translationGroup, - parent_group: "parentG", - child_group: "childG", - sort_order: 0, - }) - .execute(); - expect(await repo.delete(fr.id)).toBe(true); - // The 'en' anchor row must survive (only the 'fr' translation was deleted)... - expect(await repo.findById(anchor.id)).not.toBeNull(); - // ...and so must its edges. - const edges = await ctx.db - .selectFrom("_emdash_content_references") - .selectAll() - .where("relation_group", "=", anchor.translationGroup) - .execute(); - expect(edges).toHaveLength(1); + expect(rel.maxChildrenPerParent).toBe(5); + expect(rel.maxParentsPerChild).toBeNull(); + expect(rel.parentLabelSingular).toBe("Manager"); + + // One side's limit is settable without disturbing the other's. + const updated = await repo.update(rel.id, { maxParentsPerChild: 1 }); + expect(updated?.maxParentsPerChild).toBe(1); + expect(updated?.maxChildrenPerParent).toBe(5); + }); + + it("clears a role limit when set back to null", async () => { + const rel = await repo.create({ ...baseInput, maxChildrenPerParent: 5 }); + + // `undefined` means "leave alone", so `null` has to be the way to lift a + // limit — otherwise a one-to-many relation could never become many-to-many. + expect((await repo.update(rel.id, { maxChildrenPerParent: null }))?.maxChildrenPerParent).toBe( + null, + ); }); - it("delete of the last translation purges edges for that relation group", async () => { - const anchor = await repo.create({ ...baseInput }); + it("delete purges the relation's edges", async () => { + const rel = await repo.create({ ...baseInput }); await ctx.db .insertInto("_emdash_content_references") .values({ id: ulid(), - relation_group: anchor.translationGroup, + relation_id: rel.id, parent_group: "parentG", child_group: "childG", sort_order: 0, }) .execute(); - expect(await repo.delete(anchor.id)).toBe(true); + expect(await repo.delete(rel.id)).toBe(true); const edges = await ctx.db .selectFrom("_emdash_content_references") .selectAll() - .where("relation_group", "=", anchor.translationGroup) + .where("relation_id", "=", rel.id) .execute(); expect(edges).toHaveLength(0); - expect(await repo.findById(anchor.id)).toBeNull(); + expect(await repo.findById(rel.id)).toBeNull(); }); it("addReference appends by sort_order and dedupes on conflict", async () => { @@ -251,19 +168,19 @@ describeEachDialect("RelationRepository", (dialect) => { await repo.addReference(rel.id, "p1", "cB"); await repo.addReference(rel.id, "p1", "cA"); // duplicate — no-op - const children = await repo.getChildren(rel.translationGroup, "p1"); + const children = await repo.getChildren(rel.id, "p1"); expect(children.map((c) => c.childGroup)).toEqual(["cA", "cB"]); expect(children.map((c) => c.sortOrder)).toEqual([0, 1]); }); - it("addReference accepts a relation id OR its group, and an explicit sortOrder", async () => { + it("addReference accepts a relation id OR its slug, and an explicit sortOrder", async () => { const rel = await repo.create({ ...baseInput }); - await repo.addReference(rel.translationGroup, "p1", "cA", 5); - const children = await repo.getChildren(rel.translationGroup, "p1"); + await repo.addReference(rel.id, "p1", "cA", 5); + const children = await repo.getChildren(rel.id, "p1"); expect(children).toEqual([ { id: expect.any(String), - relationGroup: rel.translationGroup, + relationId: rel.id, parentGroup: "p1", childGroup: "cA", sortOrder: 5, @@ -276,18 +193,18 @@ describeEachDialect("RelationRepository", (dialect) => { await repo.addReference(rel.id, "p1", "shared"); await repo.addReference(rel.id, "p2", "shared"); - const parents = await repo.getParents(rel.translationGroup, "shared"); + const parents = await repo.getParents(rel.id, "shared"); expect(parents.map((p) => p.parentGroup).toSorted()).toEqual(["p1", "p2"]); await repo.removeReference(rel.id, "p1", "shared"); - const after = await repo.getParents(rel.translationGroup, "shared"); + const after = await repo.getParents(rel.id, "shared"); expect(after.map((p) => p.parentGroup)).toEqual(["p2"]); }); it("self-reference (same group as parent and child) is allowed", async () => { const rel = await repo.create({ ...baseInput }); await repo.addReference(rel.id, "self", "self"); - const children = await repo.getChildren(rel.translationGroup, "self"); + const children = await repo.getChildren(rel.id, "self"); expect(children.map((c) => c.childGroup)).toEqual(["self"]); }); @@ -305,13 +222,13 @@ describeEachDialect("RelationRepository", (dialect) => { const rel = await repo.create({ ...baseInput }); await repo.setChildren(rel.id, "p1", ["a", "b", "c"]); - let children = await repo.getChildren(rel.translationGroup, "p1"); + let children = await repo.getChildren(rel.id, "p1"); expect(children.map((c) => c.childGroup)).toEqual(["a", "b", "c"]); expect(children.map((c) => c.sortOrder)).toEqual([0, 1, 2]); // Reorder + drop 'a' + add 'd'. await repo.setChildren(rel.id, "p1", ["c", "b", "d"]); - children = await repo.getChildren(rel.translationGroup, "p1"); + children = await repo.getChildren(rel.id, "p1"); expect(children.map((c) => c.childGroup)).toEqual(["c", "b", "d"]); expect(children.map((c) => c.sortOrder)).toEqual([0, 1, 2]); }); @@ -320,13 +237,13 @@ describeEachDialect("RelationRepository", (dialect) => { const rel = await repo.create({ ...baseInput }); await repo.setChildren(rel.id, "p1", ["a", "b"]); await repo.setChildren(rel.id, "p1", []); - expect(await repo.getChildren(rel.translationGroup, "p1")).toEqual([]); + expect(await repo.getChildren(rel.id, "p1")).toEqual([]); }); it("setChildren collapses duplicate childGroups (one edge per child)", async () => { const rel = await repo.create({ ...baseInput }); await repo.setChildren(rel.id, "p1", ["a", "b", "a"]); - const children = await repo.getChildren(rel.translationGroup, "p1"); + const children = await repo.getChildren(rel.id, "p1"); expect(children.map((c) => c.childGroup)).toEqual(["a", "b"]); expect(children.map((c) => c.sortOrder)).toEqual([0, 1]); }); @@ -336,6 +253,53 @@ describeEachDialect("RelationRepository", (dialect) => { expect(await repo.getChildren("unknown-relation", "p1")).toEqual([]); }); + it("setParents replaces the parents pointing at one child", async () => { + const rel = await repo.create({ ...baseInput }); + await repo.setParents(rel.id, "c1", ["p1", "p2"]); + + // A child's parents have no order: `sort_order` positions children within + // one parent and has no counterpart here, so `getParents` falls back to + // link id, and two links written in the same millisecond carry ULIDs whose + // order is not the write order. Assert the set. + expect( + (await repo.getParents(rel.id, "c1")).map((edge) => edge.parentGroup).toSorted(), + ).toEqual(["p1", "p2"]); + + await repo.setParents(rel.id, "c1", ["p2", "p3"]); + expect( + (await repo.getParents(rel.id, "c1")).map((edge) => edge.parentGroup).toSorted(), + ).toEqual(["p2", "p3"]); + }); + + it("setParents leaves the other children of a parent it drops", async () => { + const rel = await repo.create({ ...baseInput }); + await repo.setChildren(rel.id, "p1", ["c1", "c2"]); + + await repo.setParents(rel.id, "c1", []); + + // Replace-all is scoped to (relation, child), so p1 keeps c2. + expect((await repo.getChildren(rel.id, "p1")).map((edge) => edge.childGroup)).toEqual(["c2"]); + }); + + it("setParents appends at the end of each parent's existing children", async () => { + const rel = await repo.create({ ...baseInput }); + await repo.setChildren(rel.id, "p1", ["c1", "c2"]); + + await repo.setParents(rel.id, "c3", ["p1"]); + + const children = await repo.getChildren(rel.id, "p1"); + expect(children.map((edge) => edge.childGroup)).toEqual(["c1", "c2", "c3"]); + expect(children.map((edge) => edge.sortOrder)).toEqual([0, 1, 2]); + }); + + it("setParents collapses duplicates and no-ops for an unknown relation", async () => { + const rel = await repo.create({ ...baseInput }); + await repo.setParents(rel.id, "c1", ["p1", "p1"]); + expect(await repo.getParents(rel.id, "c1")).toHaveLength(1); + + await expect(repo.setParents("unknown-relation", "c1", ["p1"])).resolves.toBeUndefined(); + }); + it("clearReferencesForGroup removes edges where the group is parent OR child", async () => { const rel = await repo.create({ ...baseInput }); await repo.addReference(rel.id, "X", "a"); // X as parent @@ -345,22 +309,22 @@ describeEachDialect("RelationRepository", (dialect) => { const removed = await repo.clearReferencesForGroup("X"); expect(removed).toBe(2); - expect(await repo.getChildren(rel.translationGroup, "X")).toHaveLength(0); - expect(await repo.getParents(rel.translationGroup, "X")).toHaveLength(0); - expect(await repo.getChildren(rel.translationGroup, "b")).toHaveLength(1); + expect(await repo.getChildren(rel.id, "X")).toHaveLength(0); + expect(await repo.getParents(rel.id, "X")).toHaveLength(0); + expect(await repo.getChildren(rel.id, "b")).toHaveLength(1); }); it("clearReferencesForGroup purges the group's edges across every relation", async () => { - const relA = await repo.create({ ...baseInput, name: "rel_a" }); - const relB = await repo.create({ ...baseInput, name: "rel_b" }); + const relA = await repo.create({ ...baseInput, slug: "rel_a" }); + const relB = await repo.create({ ...baseInput, slug: "rel_b" }); // The same content group "X" participates in edges under two relations. await repo.addReference(relA.id, "X", "a"); await repo.addReference(relB.id, "b", "X"); const removed = await repo.clearReferencesForGroup("X"); expect(removed).toBe(2); - expect(await repo.getChildren(relA.translationGroup, "X")).toHaveLength(0); - expect(await repo.getParents(relB.translationGroup, "X")).toHaveLength(0); + expect(await repo.getChildren(relA.id, "X")).toHaveLength(0); + expect(await repo.getParents(relB.id, "X")).toHaveLength(0); }); it("countChildren and countParents count edges", async () => { diff --git a/packages/core/tests/integration/database/relation-set-children-writes.test.ts b/packages/core/tests/integration/database/relation-set-children-writes.test.ts new file mode 100644 index 0000000000..c803126213 --- /dev/null +++ b/packages/core/tests/integration/database/relation-set-children-writes.test.ts @@ -0,0 +1,94 @@ +/** + * SQL-shape coverage for replacing reference children. + * + * Local SQLite accepts far more bound parameters than Cloudflare D1, so a + * large single INSERT succeeds locally while failing in production. Capture + * the emitted statements and enforce D1's 100-parameter ceiling directly. + */ + +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { openNodeSqliteDatabase } from "../../../src/db/node-sqlite-compat.js"; + +interface CapturedQuery { + sql: string; + parameters: readonly unknown[]; +} + +let db: Kysely<DatabaseSchema>; +let repo: RelationRepository; +let captured: CapturedQuery[]; + +beforeEach(async () => { + captured = []; + db = new Kysely<DatabaseSchema>({ + dialect: new SqliteDialect({ database: openNodeSqliteDatabase(":memory:") }), + log(event) { + if (event.level === "query") { + captured.push({ sql: event.query.sql, parameters: event.query.parameters }); + } + }, + }); + await runMigrations(db); + repo = new RelationRepository(db); +}); + +afterEach(async () => { + await db.destroy(); +}); + +function referenceInserts(): CapturedQuery[] { + return captured.filter((query) => /insert into ["`]?_emdash_content_references/i.test(query.sql)); +} + +it("chunks large child replacements within D1's bound-parameter ceiling", async () => { + const relation = await repo.create({ + slug: "related_pages", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Post", + childLabel: "Related page", + }); + const childGroups = Array.from({ length: 40 }, (_, index) => `child-${index}`); + + captured = []; + await repo.setChildren(relation.id, "parent-1", childGroups); + + const inserts = referenceInserts(); + expect(inserts.length).toBeGreaterThan(1); + for (const insert of inserts) { + expect(insert.parameters.length).toBeLessThanOrEqual(100); + } + + const stored = await repo.getChildren(relation.slug, "parent-1"); + expect(stored.map((edge) => edge.childGroup)).toEqual(childGroups); + expect(stored.map((edge) => edge.sortOrder)).toEqual(childGroups.map((_, index) => index)); +}); + +it("chunks copied parent edges within D1's bound-parameter ceiling", async () => { + const relation = await repo.create({ + slug: "copy_related_pages", + parentCollection: "posts", + childCollection: "pages", + parentLabel: "Post", + childLabel: "Related page", + }); + const childGroups = Array.from({ length: 40 }, (_, index) => `copy-child-${index}`); + await repo.setChildren(relation.id, "source-parent", childGroups); + + captured = []; + await repo.copyParentEdges("source-parent", "copy-parent"); + + const inserts = referenceInserts(); + expect(inserts.length).toBeGreaterThan(1); + for (const insert of inserts) { + expect(insert.parameters.length).toBeLessThanOrEqual(100); + } + + const stored = await repo.getChildren(relation.slug, "copy-parent"); + expect(stored.map((edge) => edge.childGroup)).toEqual(childGroups); +}); diff --git a/packages/core/tests/integration/database/relations-structural-migration.test.ts b/packages/core/tests/integration/database/relations-structural-migration.test.ts new file mode 100644 index 0000000000..3335b79af7 --- /dev/null +++ b/packages/core/tests/integration/database/relations-structural-migration.test.ts @@ -0,0 +1,263 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { columnExists, tableExists } from "../../../src/database/dialect-helpers.js"; +import * as migration076 from "../../../src/database/migrations/076_relations_structural.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +interface OldRelation { + id: string; + name: string; + parentCollection?: string; + childCollection?: string; + parentLabel?: string; + childLabel?: string; + locale: string; + translationGroup: string; +} + +/** + * Rebuild `_emdash_relations` in its migration-043 shape and rename the edge + * table's relation column back, so `up` runs against the state it was written + * for. A fresh test database has already migrated past 076. + */ +async function revertToPre076(ctx: DialectTestContext, relations: OldRelation[]): Promise<void> { + const db = ctx.db; + await db.schema.dropTable("_emdash_relations").ifExists().execute(); + await db.schema + .createTable("_emdash_relations") + .addColumn("id", "text", (c) => c.primaryKey()) + .addColumn("name", "text", (c) => c.notNull()) + .addColumn("parent_collection", "text", (c) => c.notNull()) + .addColumn("child_collection", "text", (c) => c.notNull()) + .addColumn("parent_label", "text", (c) => c.notNull()) + .addColumn("child_label", "text", (c) => c.notNull()) + .addColumn("locale", "text", (c) => c.notNull().defaultTo("en")) + .addColumn("translation_group", "text", (c) => c.notNull()) + .addColumn("created_at", "text") + .addColumn("updated_at", "text") + .addUniqueConstraint("_emdash_relations_name_locale_unique", ["name", "locale"]) + .execute(); + + for (const r of relations) { + await sql` + INSERT INTO ${sql.ref("_emdash_relations")} + (id, name, parent_collection, child_collection, parent_label, child_label, + locale, translation_group) + VALUES (${r.id}, ${r.name}, ${r.parentCollection ?? "post"}, ${r.childCollection ?? "page"}, + ${r.parentLabel ?? "Posts"}, ${r.childLabel ?? "Pages"}, ${r.locale}, + ${r.translationGroup}) + `.execute(db); + } + + if (await columnExists(db, "_emdash_content_references", "relation_id")) { + await sql + .raw( + `ALTER TABLE "_emdash_content_references" RENAME COLUMN "relation_id" TO "relation_group"`, + ) + .execute(db); + } +} + +async function insertEdge( + ctx: DialectTestContext, + relationColumn: string, + values: { id: string; relation: string; parent: string; child: string }, +): Promise<void> { + await sql` + INSERT INTO ${sql.ref("_emdash_content_references")} + (id, ${sql.ref(relationColumn)}, parent_group, child_group, sort_order) + VALUES (${values.id}, ${values.relation}, ${values.parent}, ${values.child}, 0) + `.execute(ctx.db); +} + +async function readRelations(ctx: DialectTestContext): Promise<Array<Record<string, unknown>>> { + const result = await sql<Record<string, unknown>>` + SELECT * FROM ${sql.ref("_emdash_relations")} ORDER BY slug ASC + `.execute(ctx.db); + return result.rows; +} + +describeEachDialect("relations structural migration (076)", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("collapses a translation group to one slugged row and keeps its edges", async () => { + await revertToPre076(ctx, [ + { + id: "grp1", + name: "post_author", + locale: "en", + translationGroup: "grp1", + parentLabel: "Posts", + childLabel: "Author", + }, + { + id: "rel-fr", + name: "post_author", + locale: "fr", + translationGroup: "grp1", + parentLabel: "Articles", + childLabel: "Auteur", + }, + ]); + await insertEdge(ctx, "relation_group", { + id: "edge1", + relation: "grp1", + parent: "pg1", + child: "cg1", + }); + + await migration076.up(ctx.db); + + const rows = await readRelations(ctx); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: "grp1", + slug: "post_author", + parent_label: "Posts", + child_label: "Author", + }); + + const edges = await sql<{ relation_id: string; parent_group: string }>` + SELECT relation_id, parent_group FROM ${sql.ref("_emdash_content_references")} + `.execute(ctx.db); + expect(edges.rows).toEqual([{ relation_id: "grp1", parent_group: "pg1" }]); + }); + + it("takes the lowest locale code's labels", async () => { + await revertToPre076(ctx, [ + { + id: "rel-fr", + name: "post_author", + locale: "fr", + translationGroup: "grp1", + parentLabel: "Articles", + childLabel: "Auteur", + }, + { + id: "grp1", + name: "post_author", + locale: "de", + translationGroup: "grp1", + parentLabel: "Beitraege", + childLabel: "Autor", + }, + ]); + + await migration076.up(ctx.db); + + const rows = await readRelations(ctx); + expect(rows[0]).toMatchObject({ parent_label: "Beitraege", child_label: "Autor" }); + }); + + it("suffixes a slug when two groups shared a name across locales", async () => { + await revertToPre076(ctx, [ + { id: "aaa", name: "post_author", locale: "en", translationGroup: "aaa" }, + { id: "bbb", name: "post_author", locale: "fr", translationGroup: "bbb" }, + ]); + + await migration076.up(ctx.db); + + const rows = await readRelations(ctx); + expect(rows.map((r) => r.slug)).toEqual(["post_author", "post_author_2"]); + }); + + it("adds the cardinality and singular-label columns, defaulting to null", async () => { + await revertToPre076(ctx, [ + { id: "grp1", name: "post_author", locale: "en", translationGroup: "grp1" }, + ]); + + await migration076.up(ctx.db); + + expect(await readRelations(ctx)).toEqual([ + expect.objectContaining({ + parent_label_singular: null, + child_label_singular: null, + max_children_per_parent: null, + max_parents_per_child: null, + }), + ]); + }); + + it("is a no-op on a second run", async () => { + await revertToPre076(ctx, [ + { id: "grp1", name: "post_author", locale: "en", translationGroup: "grp1" }, + ]); + await insertEdge(ctx, "relation_group", { + id: "edge1", + relation: "grp1", + parent: "pg1", + child: "cg1", + }); + + await migration076.up(ctx.db); + const first = await readRelations(ctx); + + await migration076.up(ctx.db); + + expect(await readRelations(ctx)).toEqual(first); + const edges = await sql<{ relation_id: string }>` + SELECT relation_id FROM ${sql.ref("_emdash_content_references")} + `.execute(ctx.db); + expect(edges.rows).toEqual([{ relation_id: "grp1" }]); + }); + + it("recovers when a previous run stopped between the drop and the rename", async () => { + await revertToPre076(ctx, [ + { id: "grp1", name: "post_author", locale: "en", translationGroup: "grp1" }, + ]); + + // Replay the rebuild up to the point the old table is gone and the new one + // has not been renamed into place yet. + await migration076.up(ctx.db); + await sql + .raw(`ALTER TABLE "_emdash_relations" RENAME TO "_emdash_relations_new"`) + .execute(ctx.db); + expect(await tableExists(ctx.db, "_emdash_relations")).toBe(false); + + await migration076.up(ctx.db); + + expect(await tableExists(ctx.db, "_emdash_relations")).toBe(true); + expect(await tableExists(ctx.db, "_emdash_relations_new")).toBe(false); + expect(await readRelations(ctx)).toEqual([expect.objectContaining({ slug: "post_author" })]); + }); + + it("completes the edge-column rename when a previous run stopped before it", async () => { + await revertToPre076(ctx, [ + { id: "grp1", name: "post_author", locale: "en", translationGroup: "grp1" }, + ]); + await insertEdge(ctx, "relation_group", { + id: "edge1", + relation: "grp1", + parent: "pg1", + child: "cg1", + }); + + await migration076.up(ctx.db); + + // The table rebuild landed but the edge-column rename did not. + await sql + .raw( + `ALTER TABLE "_emdash_content_references" RENAME COLUMN "relation_id" TO "relation_group"`, + ) + .execute(ctx.db); + + await migration076.up(ctx.db); + + expect(await columnExists(ctx.db, "_emdash_content_references", "relation_id")).toBe(true); + expect(await columnExists(ctx.db, "_emdash_content_references", "relation_group")).toBe(false); + }); +}); diff --git a/packages/core/tests/integration/database/restore-content-bylines-table-migration.test.ts b/packages/core/tests/integration/database/restore-content-bylines-table-migration.test.ts index a5649caf93..36168141bb 100644 --- a/packages/core/tests/integration/database/restore-content-bylines-table-migration.test.ts +++ b/packages/core/tests/integration/database/restore-content-bylines-table-migration.test.ts @@ -90,14 +90,19 @@ describeEachDialect("restore content bylines table migration", (dialect) => { it("recovers through the runner when the rebuild is retried after the drop", async () => { const { post, author } = await createCreditedPost(); await leaveStagedCopyBehind(); + // Starts after 043: replaying it once 076 has restructured + // `_emdash_relations` would index columns 076 removed. A recorded + // migration sitting before a pending one reads as corrupted history, so + // the window has to stay contiguous and simply begins past that + // boundary. 071 is what this test is about, and it is still inside. await ctx.db .deleteFrom("_emdash_migrations") - .where("name", ">=", "040_byline_i18n") + .where("name", ">=", "044_comment_reactions") .execute(); const { applied } = await runMigrationsForDialect(ctx); - expect(applied).toContain("040_byline_i18n"); + expect(applied).toContain("044_comment_reactions"); expect(applied).toContain("071_restore_content_bylines_table"); expect(await tableExists(ctx.db, "_emdash_content_bylines")).toBe(true); expect(await tableExists(ctx.db, "_emdash_content_bylines_new")).toBe(false); diff --git a/packages/core/tests/integration/manifest-reference.test.ts b/packages/core/tests/integration/manifest-reference.test.ts new file mode 100644 index 0000000000..0ed8e60888 --- /dev/null +++ b/packages/core/tests/integration/manifest-reference.test.ts @@ -0,0 +1,99 @@ +/** + * `_buildManifest` copies `field.validation` into the manifest descriptor + * for reference fields so the admin editor receives the relation, + * target collection, and cardinality needed by the reference picker. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import type { EmDashConfig } from "../../src/astro/integration/runtime.js"; +import type { Database } from "../../src/database/types.js"; +import { EmDashRuntime } from "../../src/emdash-runtime.js"; +import { createHookPipeline } from "../../src/plugins/hooks.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../utils/test-db.js"; + +function buildRuntime(db: Kysely<Database>): EmDashRuntime { + const config: EmDashConfig = {}; + const pipelineFactoryOptions = { db } as const; + const hooks = createHookPipeline([], pipelineFactoryOptions); + const pipelineRef = { current: hooks }; + const runtimeDeps = { + config, + plugins: [], + // eslint-disable-next-line typescript/no-explicit-any -- match RuntimeDependencies signature + createDialect: (() => { + throw new Error("createDialect not used in this test"); + }) as any, + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; + + return new EmDashRuntime({ + db, + storage: null, + configuredPlugins: [], + sandboxedPlugins: new Map(), + sandboxedPluginEntries: [], + hooks, + enabledPlugins: new Set(), + pluginStates: new Map(), + config, + mediaProviders: new Map(), + mediaProviderEntries: [], + cronExecutor: null, + cronScheduler: null, + emailPipeline: null, + allPipelinePlugins: [], + pipelineFactoryOptions, + runtimeDeps, + pipelineRef, + }); +} + +describeEachDialect("manifest reference field validation", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("carries kind and validation for a reference field", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ + slug: "posts", + label: "Posts", + labelSingular: "Post", + source: "test", + }); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }); + + const runtime = buildRuntime(ctx.db); + const manifest = await runtime.getManifest(); + + const entry = manifest.collections.posts?.fields.related; + expect(entry?.kind).toBe("reference"); + expect(entry?.validation).toMatchObject({ + relation: "grp_x", + targetCollection: "posts", + multiple: true, + }); + }); +}); diff --git a/packages/core/tests/integration/schema/legacy-reference-fields.test.ts b/packages/core/tests/integration/schema/legacy-reference-fields.test.ts new file mode 100644 index 0000000000..9b20f79f26 --- /dev/null +++ b/packages/core/tests/integration/schema/legacy-reference-fields.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleContentCreate, + handleContentGet, + handleContentUpdate, +} from "../../../src/api/handlers/content.js"; +import { handleSchemaFieldUpdate } from "../../../src/api/handlers/schema.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { createLegacyReferenceField } from "../../utils/legacy-reference-field.js"; +import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js"; +import type { DialectTestContext } from "../../utils/test-db.js"; + +describeEachDialect("reference fields that predate relations", (dialect) => { + let ctx: DialectTestContext; + + async function setup() { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + return registry; + } + + it("round-trips its value through create and get", async () => { + await setup(); + try { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "posts" }); + + const created = await handleContentCreate(ctx.db, "posts", { + data: { title: "A", author: "author-entry-id" }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + expect(created.data.item.data).toMatchObject({ author: "author-entry-id" }); + + const fetched = await handleContentGet(ctx.db, "posts", created.data.item.id); + expect(fetched.success).toBe(true); + if (!fetched.success) return; + expect(fetched.data.item.data).toMatchObject({ author: "author-entry-id" }); + } finally { + await teardownForDialect(ctx); + } + }); + + it("keeps its value when the entry is updated", async () => { + await setup(); + try { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "posts" }); + + const created = await handleContentCreate(ctx.db, "posts", { + data: { title: "A", author: "first" }, + }); + if (!created.success) throw new Error("create failed"); + + const updated = await handleContentUpdate(ctx.db, "posts", created.data.item.id, { + data: { author: "second" }, + }); + expect(updated).toMatchObject({ success: true }); + + const fetched = await handleContentGet(ctx.db, "posts", created.data.item.id); + if (!fetched.success) throw new Error("get failed"); + expect(fetched.data.item.data).toMatchObject({ author: "second" }); + } finally { + await teardownForDialect(ctx); + } + }); + + it("stays editable and filterable when it was indexed before the upgrade", async () => { + await setup(); + try { + await createLegacyReferenceField(ctx.db, "posts", "author", { + targetCollection: "posts", + indexed: true, + }); + + // An unrelated edit must not be refused because the type left the + // indexable set. + const renamed = await handleSchemaFieldUpdate(ctx.db, "posts", "author", { + label: "Written by", + }); + expect(renamed).toMatchObject({ success: true }); + + const created = await handleContentCreate(ctx.db, "posts", { + data: { title: "A", author: "author-entry-id" }, + }); + if (!created.success) throw new Error("create failed"); + + const content = new ContentRepository(ctx.db); + const matches = await content.findMany("posts", { + where: { fieldFilters: { author: "author-entry-id" } }, + }); + expect(matches.items.map((item) => item.id)).toEqual([created.data.item.id]); + } finally { + await teardownForDialect(ctx); + } + }); +}); + +describeEachDialect("binding a reference field that predates relations", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "authors", label: "Authors", labelSingular: "Author" }); + await registry.createField("authors", { slug: "name", label: "Name", type: "string" }); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("creates the relation and copies the column's ids in as edges", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + + const content = new ContentRepository(ctx.db); + const author = await content.create({ type: "authors", slug: "jane", data: { name: "Jane" } }); + const post = await content.create({ + type: "posts", + slug: "hello", + data: { title: "Hello", author: author.id }, + }); + + const res = await handleSchemaFieldUpdate(ctx.db, "posts", "author", { + validation: { targetCollection: "authors", multiple: false }, + }); + + expect(res.success).toBe(true); + if (!res.success) return; + expect(res.data.item.validation).toMatchObject({ + relation: "posts_author", + relationSide: "parent", + targetCollection: "authors", + }); + + const relation = await new RelationRepository(ctx.db).findBySlug("posts_author"); + expect(relation).toMatchObject({ + parentCollection: "posts", + childCollection: "authors", + maxChildrenPerParent: 1, + }); + + const edges = await new RelationRepository(ctx.db).getChildrenPage( + relation!.id, + post.translationGroup!, + ); + expect(edges.items.map((edge) => edge.childGroup)).toEqual([author.translationGroup]); + }); + + it("stops the field being indexed, since nothing writes its column any more", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { + targetCollection: "authors", + indexed: true, + }); + + const res = await handleSchemaFieldUpdate(ctx.db, "posts", "author", { + validation: { targetCollection: "authors" }, + }); + + expect(res).toMatchObject({ success: true }); + const field = await new SchemaRegistry(ctx.db).getField("posts", "author"); + expect(field).toMatchObject({ indexed: false, searchable: false }); + }); + + it("serves the picker from the edges while the column keeps its pre-binding value", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + + const content = new ContentRepository(ctx.db); + const author = await content.create({ type: "authors", slug: "jane", data: { name: "Jane" } }); + const post = await content.create({ + type: "posts", + slug: "hello", + data: { title: "Hello", author: author.id }, + }); + + await handleSchemaFieldUpdate(ctx.db, "posts", "author", { + validation: { targetCollection: "authors" }, + }); + + const fetched = await handleContentGet(ctx.db, "posts", post.id, undefined, { + includeDrafts: true, + }); + expect(fetched.success).toBe(true); + if (!fetched.success) return; + expect(fetched.data.item.references?.author?.children.map((child) => child.id)).toEqual([ + author.id, + ]); + // The column is frozen, not cleared: on a site that predates pickers it can + // hold anything an editor typed, and only the ids that resolved to an entry + // became edges. Writes no longer reach it, and typegen stops declaring the + // key, so the edges are the live selection and this is the record of what + // the field held before it was bound. + expect(fetched.data.item.data.author).toBe(author.id); + }); + + it("refuses a target collection that does not exist and leaves the field unbound", async () => { + await createLegacyReferenceField(ctx.db, "posts", "author", { targetCollection: "authors" }); + + const res = await handleSchemaFieldUpdate(ctx.db, "posts", "author", { + validation: { targetCollection: "gone" }, + }); + + expect(res).toMatchObject({ success: false, error: { code: "COLLECTION_NOT_FOUND" } }); + const field = await new SchemaRegistry(ctx.db).getField("posts", "author"); + expect(field?.validation?.relation).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts new file mode 100644 index 0000000000..10bb9a610a --- /dev/null +++ b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts @@ -0,0 +1,640 @@ +import { sql } from "kysely"; +import { expect, it } from "vitest"; + +import { + handleSchemaFieldCreate, + handleSchemaFieldDelete, + handleSchemaFieldUpdate, +} from "../../../src/api/handlers/schema.js"; +import { columnExists } from "../../../src/database/dialect-helpers.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js"; +import type { DialectTestContext } from "../../utils/test-db.js"; + +describeEachDialect("reference field lifecycle", (dialect) => { + let ctx: DialectTestContext; + + it("creates a relation def when a reference field is created and stores its slug on the field", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + + expect(res.success).toBe(true); + + const repo = new RelationRepository(ctx.db); + const relations = await repo.list(); + const rel = relations.find((r) => r.slug === "posts_related"); + expect(rel).toBeTruthy(); + expect(rel?.parentCollection).toBe("posts"); + expect(rel?.childCollection).toBe("posts"); + if (res.success) { + expect(res.data.item.validation?.relation).toBe(rel?.slug); + expect(res.data.item.validation?.targetCollection).toBe("posts"); + } + } finally { + await teardownForDialect(ctx); + } + }); + + /** A collection with one reference field, plus one edge under its relation. */ + async function seedFieldWithEdge() { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + if (!created.success) throw new Error("field create failed"); + + const relRepo = new RelationRepository(ctx.db); + const relation = await relRepo.findBySlug("posts_related"); + if (!relation) throw new Error("relation not created"); + await relRepo.addReference(relation.id, "parent-group-x", "child-group-y"); + + return { registry, relRepo, relation }; + } + + function edgesFor(relationId: string) { + return ctx.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("relation_id", "=", relationId) + .execute(); + } + + it("keeps the relation and its edges when a reference field is deleted on its own", async () => { + ctx = await setupForDialect(dialect); + try { + const { registry, relRepo, relation } = await seedFieldWithEdge(); + + const del = await handleSchemaFieldDelete(ctx.db, "posts", "related"); + expect(del.success).toBe(true); + + // The edges are content. Losing the field must not take them, so the + // relation survives with no bound field until someone deletes it. + expect(await relRepo.findBySlug("posts_related")).toBeTruthy(); + expect(await edgesFor(relation.id)).toHaveLength(1); + expect(await registry.getField("posts", "related")).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("deletes the relation and its edges when deleteRelation is set", async () => { + ctx = await setupForDialect(dialect); + try { + const { registry, relRepo, relation } = await seedFieldWithEdge(); + + const del = await handleSchemaFieldDelete(ctx.db, "posts", "related", { + deleteRelation: true, + }); + expect(del.success).toBe(true); + + expect(await relRepo.findBySlug("posts_related")).toBeNull(); + expect(await edgesFor(relation.id)).toHaveLength(0); + expect(await registry.getField("posts", "related")).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("deleteRelation takes the field bound to the relation's other side", async () => { + ctx = await setupForDialect(dialect); + try { + const { registry, relRepo } = await seedFieldWithEdge(); + const relation = await relRepo.findBySlug("posts_related"); + if (!relation) return; + + // A second field views the same relation from the child end. Deleting + // either one with the relation has to take the other with it, or it + // would be left pointing at a relation that no longer exists. + await registry.createField("posts", { + slug: "referenced_by", + label: "Referenced by", + type: "reference", + validation: { + relation: relation.slug, + relationSide: "child", + targetCollection: "posts", + }, + }); + + const del = await handleSchemaFieldDelete(ctx.db, "posts", "related", { + deleteRelation: true, + }); + expect(del.success).toBe(true); + + expect(await registry.getField("posts", "related")).toBeNull(); + expect(await registry.getField("posts", "referenced_by")).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("drops the column of a reference field that predates storage-less references", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + // Reference fields used to be column-backed. Create one as `string` so + // the column DDL runs, then relabel it to reproduce that row exactly. + await registry.createField("posts", { slug: "related", label: "Related", type: "string" }); + await sql`UPDATE _emdash_fields SET type = 'reference' WHERE slug = 'related'`.execute( + ctx.db, + ); + + await registry.deleteField("posts", "related"); + + expect(await columnExists(ctx.db, "ec_posts", "related")).toBe(false); + } finally { + await teardownForDialect(ctx); + } + }); + + it("leaves no column behind when a storage-less reference field is deleted", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + + await registry.deleteField("posts", "related"); + + expect(await columnExists(ctx.db, "ec_posts", "related")).toBe(false); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects creating a reference field with no target collection", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { multiple: true }, + }); + + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("VALIDATION_ERROR"); + + const field = await registry.getField("posts", "related"); + expect(field).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects creating a reference field whose target collection does not exist", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "ghosts", multiple: true }, + }); + + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("COLLECTION_NOT_FOUND"); + + // The transaction rolls back, so neither the field nor its relation persists. + const field = await registry.getField("posts", "related"); + expect(field).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("PATCHes the relation's childLabel when the field's label is updated", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationSlug = created.data.item.validation?.relation; + expect(relationSlug).toBeTruthy(); + if (!relationSlug) return; + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + label: "Related posts", + }); + expect(updated.success).toBe(true); + + const relRepo = new RelationRepository(ctx.db); + expect((await relRepo.findBySlug(relationSlug))?.childLabel).toBe("Related posts"); + } finally { + await teardownForDialect(ctx); + } + }); + + it("preserves relation and targetCollection when validation is explicitly null", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationSlug = created.data.item.validation?.relation; + expect(relationSlug).toBeTruthy(); + if (!relationSlug) return; + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + label: "Related posts", + validation: null, + }); + expect(updated.success).toBe(true); + + const field = await registry.getField("posts", "related"); + expect(field?.validation?.relation).toBe(relationSlug); + expect(field?.validation?.targetCollection).toBe("posts"); + + const relRepo = new RelationRepository(ctx.db); + const relations = await relRepo.list(); + expect(relations.find((r) => r.slug === "posts_related")).toBeTruthy(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("preserves relation and targetCollection when validation omits them", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationSlug = created.data.item.validation?.relation; + expect(relationSlug).toBeTruthy(); + if (!relationSlug) return; + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + validation: { multiple: false }, + }); + expect(updated.success).toBe(true); + + const field = await registry.getField("posts", "related"); + expect(field?.validation?.relation).toBe(relationSlug); + expect(field?.validation?.targetCollection).toBe("posts"); + expect(field?.validation?.multiple).toBe(false); + + const relRepo = new RelationRepository(ctx.db); + const relations = await relRepo.list(); + expect(relations.find((r) => r.slug === "posts_related")).toBeTruthy(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects changing the target collection of an existing reference field", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + validation: { targetCollection: "pages", multiple: true }, + }); + expect(updated.success).toBe(false); + if (!updated.success) expect(updated.error.code).toBe("VALIDATION_ERROR"); + + // The stored field must be unaffected by the rejected update. + const field = await registry.getField("posts", "related"); + expect(field?.validation?.targetCollection).toBe("posts"); + } finally { + await teardownForDialect(ctx); + } + }); + + it("leaves no orphan field row when the relation slug cannot be allocated", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + // Occupy every name the suffix-retry loop would try (base + _2.._5) + // so relation allocation is forced to exhaust and fail. + const relRepo = new RelationRepository(ctx.db); + const slugs = [ + "posts_related", + "posts_related_2", + "posts_related_3", + "posts_related_4", + "posts_related_5", + ]; + for (const slug of slugs) { + await relRepo.create({ + slug, + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Posts", + childLabel: "Occupied", + }); + } + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(res.success).toBe(false); + + // No orphan field row from the failed attempt. + const field = await registry.getField("posts", "related"); + expect(field).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rolls back the just-created relation when field creation fails after it (atomicity)", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + // "id" is a reserved field slug — registry.createField rejects it + // *after* the relation for this attempt has already been created, + // exercising rollback of the relation insert alongside the field. + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "id", + label: "Id", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(res.success).toBe(false); + + const relRepo = new RelationRepository(ctx.db); + const relations = await relRepo.list(); + expect(relations.find((r) => r.slug === "posts_id")).toBeUndefined(); + } finally { + await teardownForDialect(ctx); + } + }); + /** Two collections and one relation between them, bound to no field yet. */ + async function seedUnboundRelation() { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createCollection({ slug: "authors", label: "Authors", labelSingular: "Author" }); + + const relation = await new RelationRepository(ctx.db).create({ + slug: "posts_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + childLabel: "Authors", + maxChildrenPerParent: 1, + }); + return { registry, relation }; + } + + it("binds a new field to an existing relation instead of creating one", async () => { + ctx = await setupForDialect(dialect); + try { + const { relation } = await seedUnboundRelation(); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "posts_authors" }, + }); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.item.validation?.relation).toBe("posts_authors"); + expect(res.data.item.validation?.relationSide).toBe("parent"); + expect(res.data.item.validation?.targetCollection).toBe("authors"); + } + + // The relation it bound to is the only one: binding must not create a + // second relation alongside the one it was given. + const relations = await new RelationRepository(ctx.db).list(); + expect(relations.map((r) => r.id)).toEqual([relation.id]); + } finally { + await teardownForDialect(ctx); + } + }); + + it("derives the child side from the end that matches, and points the field back at the parent", async () => { + ctx = await setupForDialect(dialect); + try { + await seedUnboundRelation(); + + const res = await handleSchemaFieldCreate(ctx.db, "authors", { + slug: "posts", + label: "Posts", + type: "reference", + validation: { relation: "posts_authors" }, + }); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.item.validation?.relationSide).toBe("child"); + expect(res.data.item.validation?.targetCollection).toBe("posts"); + } + } finally { + await teardownForDialect(ctx); + } + }); + + it("takes the requested side on a self-referential relation", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await new RelationRepository(ctx.db).create({ + slug: "posts_related", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Posts", + childLabel: "Related posts", + }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "referenced_by", + label: "Referenced by", + type: "reference", + validation: { relation: "posts_related", relationSide: "child" }, + }); + + expect(res.success).toBe(true); + if (res.success) { + expect(res.data.item.validation?.relationSide).toBe("child"); + expect(res.data.item.validation?.targetCollection).toBe("posts"); + } + } finally { + await teardownForDialect(ctx); + } + }); + + it("refuses a second field on the same end of a relation", async () => { + ctx = await setupForDialect(dialect); + try { + await seedUnboundRelation(); + + const first = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "posts_authors" }, + }); + expect(first.success).toBe(true); + + const second = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "co_author", + label: "Co-author", + type: "reference", + validation: { relation: "posts_authors" }, + }); + + expect(second.success).toBe(false); + if (!second.success) expect(second.error.code).toBe("CONFLICT"); + + // The refused field leaves no row behind. + expect(await new SchemaRegistry(ctx.db).getField("posts", "co_author")).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("allows the opposite end of a relation that is already bound once", async () => { + ctx = await setupForDialect(dialect); + try { + await seedUnboundRelation(); + + await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "posts_authors" }, + }); + const inverse = await handleSchemaFieldCreate(ctx.db, "authors", { + slug: "posts", + label: "Posts", + type: "reference", + validation: { relation: "posts_authors" }, + }); + + expect(inverse.success).toBe(true); + if (inverse.success) expect(inverse.data.item.validation?.relationSide).toBe("child"); + } finally { + await teardownForDialect(ctx); + } + }); + + it("refuses a relation that does not touch the collection", async () => { + ctx = await setupForDialect(dialect); + try { + const { registry } = await seedUnboundRelation(); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + + const res = await handleSchemaFieldCreate(ctx.db, "pages", { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "posts_authors" }, + }); + + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("VALIDATION_ERROR"); + } finally { + await teardownForDialect(ctx); + } + }); + + it("refuses a side that contradicts the matching end", async () => { + ctx = await setupForDialect(dialect); + try { + await seedUnboundRelation(); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "posts_authors", relationSide: "child" }, + }); + + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("VALIDATION_ERROR"); + } finally { + await teardownForDialect(ctx); + } + }); + + it("reports a relation that does not exist", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "nope" }, + }); + + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("NOT_FOUND"); + } finally { + await teardownForDialect(ctx); + } + }); +}); diff --git a/packages/core/tests/types/references.test-d.ts b/packages/core/tests/types/references.test-d.ts new file mode 100644 index 0000000000..9e62040d16 --- /dev/null +++ b/packages/core/tests/types/references.test-d.ts @@ -0,0 +1,54 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import { getEmDashEntry, type ReferencePage } from "../../src/query.js"; + +interface Author { + id: string; + name: string; +} + +interface Post { + id: string; + title: string; +} + +// What `generateTypesFile` emits for a collection with two bound reference +// fields, one pointing at another collection and one at itself. +declare module "../../src/query.js" { + interface EmDashCollections { + posts: Post; + authors: Author; + } + interface EmDashCollectionReferences { + posts: { author: ReferencePage<Author>; related_posts: ReferencePage<Post> }; + } +} + +describe("getEmDashEntry references", () => { + it("resolves a selected field to the target collection's interface", async () => { + const { entry } = await getEmDashEntry("posts", "slug", { references: { author: true } }); + expectTypeOf(entry!.references!.author.entries[0]!.data).toEqualTypeOf<Author>(); + }); + + it("narrows to the fields the caller named", async () => { + const { entry } = await getEmDashEntry("posts", "slug", { references: { author: true } }); + expectTypeOf(entry!.references!).toEqualTypeOf<{ author: ReferencePage<Author> }>(); + }); + + it("rejects a field the collection does not have", async () => { + await getEmDashEntry("posts", "slug", { + // @ts-expect-error - `nope` is not a reference field on posts + references: { nope: true }, + }); + }); + + it("leaves an unregistered collection's pages un-narrowed", async () => { + const { entry } = await getEmDashEntry("widgets", "slug", { references: { anything: true } }); + expectTypeOf(entry!.references!.anything).toEqualTypeOf<ReferencePage>(); + }); + + it("keeps the data parameter second, for resolveEmDashPath's explicit call", async () => { + const { entry } = await getEmDashEntry<string, Post>("posts", "slug"); + expectTypeOf(entry!.data).toEqualTypeOf<Post>(); + }); +}); diff --git a/packages/core/tests/unit/api/field-validation-schema.test.ts b/packages/core/tests/unit/api/field-validation-schema.test.ts index 8caf7c2884..29681d7563 100644 --- a/packages/core/tests/unit/api/field-validation-schema.test.ts +++ b/packages/core/tests/unit/api/field-validation-schema.test.ts @@ -36,3 +36,20 @@ describe("createFieldBody repeater sub-field types", () => { } }); }); + +describe("createFieldBody reference config", () => { + it("preserves targetCollection and multiple on the parsed validation", () => { + const result = createFieldBody.safeParse({ + slug: "author", + label: "Author", + type: "reference", + validation: { targetCollection: "authors", multiple: false }, + }); + + expect(result.success).toBe(true); + expect(result.data?.validation).toMatchObject({ + targetCollection: "authors", + multiple: false, + }); + }); +}); diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index d5e63f5b7e..d70b34000a 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -71,6 +71,19 @@ describe("contentCreateBody schema", () => { const result = contentCreateBody.parse({ data: { title: "Hi" }, publishedAt: null }); expect(result.publishedAt).toBeNull(); }); + + it("preserves references when provided", () => { + const result = contentCreateBody.parse({ + data: {}, + references: { grp_x: ["a", "b"] }, + }); + expect(result.references).toEqual({ grp_x: ["a", "b"] }); + }); + + it("accepts omitted references", () => { + const result = contentCreateBody.parse({ data: {} }); + expect(result.references).toBeUndefined(); + }); }); describe("contentUpdateBody schema", () => { @@ -130,6 +143,19 @@ describe("contentUpdateBody schema", () => { } as Parameters<typeof contentUpdateBody.parse>[0]); expect("createdAt" in result).toBe(false); }); + + it("preserves references when provided", () => { + const result = contentUpdateBody.parse({ + data: { title: "Hi" }, + references: { grp_x: ["a", "b"] }, + }); + expect(result.references).toEqual({ grp_x: ["a", "b"] }); + }); + + it("accepts omitted references", () => { + const result = contentUpdateBody.parse({ data: { title: "Hi" } }); + expect(result.references).toBeUndefined(); + }); }); describe("localeCode validator", () => { diff --git a/packages/core/tests/unit/astro/routes.test.ts b/packages/core/tests/unit/astro/routes.test.ts index 3a5e36fe60..5b90c248ec 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -88,6 +88,22 @@ describe("core media route injection", () => { } }); + it("injects the relation and reference-edge API routes", () => { + // Regression: these route files existed but were never wired into + // injectCoreRoutes, so /_emdash/api/relations 404'd and the admin's + // "Referenced by" backlinks panel silently hid itself. + const routes = collectRoutePatterns(); + + expect(routes).toContain("/_emdash/api/relations"); + expect(routes).toContain("/_emdash/api/relations/[id]"); + expect(routes).toContain( + "/_emdash/api/content/[collection]/[id]/references/[relation]/children", + ); + expect(routes).toContain( + "/_emdash/api/content/[collection]/[id]/references/[relation]/parents", + ); + }); + it("registers the media replacement route with PUT only", () => { const routes: Array<{ pattern: string; entrypoint: string }> = []; injectCoreRoutes((route) => routes.push(route)); diff --git a/packages/core/tests/unit/database/migration-imports.test.ts b/packages/core/tests/unit/database/migration-imports.test.ts new file mode 100644 index 0000000000..2f672360a8 --- /dev/null +++ b/packages/core/tests/unit/database/migration-imports.test.ts @@ -0,0 +1,53 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const MIGRATIONS_DIR = join( + dirname(fileURLToPath(import.meta.url)), + "../../../src/database/migrations", +); + +/** + * What a migration may import. + * + * `runner.ts` statically imports every migration, so anything a migration + * imports is pulled into the runner's bundle chunk. Reaching for a module that + * the rest of the source also imports makes the bundler place the two in chunks + * that import each other, and the shared rolldown runtime helper in one of them + * is then undefined while the other initializes: the published package throws + * `__exportAll is not a function` on import, with no test failing first. + * + * Helpers on this list are imported by migrations and nothing else, or are + * external packages. To use anything else, copy what you need into the migration + * — which a shipped migration wants anyway, since its behaviour is frozen. + */ +const ALLOWED_IMPORTS = new Set([ + "kysely", + "ulidx", + "../dialect-helpers.js", + "../validate.js", + "../types.js", + "../pg-migration-lock.js", + "../../i18n/config.js", +]); + +const IMPORT_SOURCE = /^\s*import\s[^;]*?from\s+"([^"]+)";/gm; + +describe("migration imports", () => { + const files = readdirSync(MIGRATIONS_DIR) + .filter((name) => /^\d{3}_.*\.ts$/.test(name)) + .toSorted(); + + it("covers every migration file", () => { + expect(files.length).toBeGreaterThan(70); + }); + + it.each(files)("%s imports only migration-safe modules", (file) => { + const source = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + const imports = Array.from(source.matchAll(IMPORT_SOURCE), (match) => match[1]!); + + expect(imports.filter((specifier) => !ALLOWED_IMPORTS.has(specifier))).toEqual([]); + }); +}); diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts index 3600935df1..5b08ccb15a 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -992,4 +992,155 @@ describe("Zod Generator", () => { expect(ts).toContain(`specs: { "name": string }[];`); }); }); + + describe("reference fields", () => { + /** A `posts` collection with one reference field carrying `validation`. */ + function makeReferenceCollection(validation: Field["validation"]): CollectionWithFields { + return { + id: "c1", + slug: "posts", + label: "Posts", + supports: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + fields: [ + { + id: "f1", + collectionId: "c1", + slug: "author", + label: "Author", + type: "reference", + columnType: "TEXT", + required: false, + unique: false, + sortOrder: 0, + createdAt: new Date().toISOString(), + validation, + }, + ], + }; + } + + const WIRED = { relation: "posts_author", relationSide: "parent", targetCollection: "authors" }; + + it("validates a field with no relation as the entry id string it stores", () => { + const schema = generateZodSchema(makeReferenceCollection(undefined)); + + expect(schema.parse({ author: "entry-id" })).toEqual({ author: "entry-id" }); + expect(() => schema.parse({ author: 42 })).toThrow(); + }); + + it("leaves a field bound to a relation out of the data schema", () => { + const schema = generateZodSchema(makeReferenceCollection(WIRED)); + + expect(Object.keys(schema.shape)).not.toContain("author"); + }); + + it("types a field with no relation as a string and omits a bound one", () => { + expect(generateTypeScript(makeReferenceCollection(undefined))).toContain("author?: string;"); + expect(generateTypeScript(makeReferenceCollection(WIRED))).not.toContain("author"); + }); + }); + + describe("reference interfaces in the generated file", () => { + function makeAuthors(): CollectionWithFields { + return { + id: "c2", + slug: "authors", + label: "Authors", + supports: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + fields: [ + { + id: "f2", + collectionId: "c2", + slug: "name", + label: "Name", + type: "string", + columnType: "TEXT", + required: true, + unique: false, + sortOrder: 0, + createdAt: new Date().toISOString(), + }, + ], + }; + } + + /** A `posts` collection whose `author` field carries the given validation. */ + function makePosts(validation: Field["validation"]): CollectionWithFields { + return { + id: "c1", + slug: "posts", + label: "Posts", + supports: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + fields: [ + { + id: "f1", + collectionId: "c1", + slug: "author", + label: "Author", + type: "reference", + columnType: "TEXT", + required: false, + unique: false, + sortOrder: 0, + createdAt: new Date().toISOString(), + validation, + }, + ], + }; + } + + const WIRED = { + relation: "posts_author", + relationSide: "parent" as const, + targetCollection: "authors", + }; + + it("emits a page per bound field, typed to the target collection", () => { + const ts = generateTypesFile([makePosts(WIRED), makeAuthors()]); + + expect(ts).toContain("export interface PostReferences {"); + expect(ts).toContain("author: ReferencePage<Author>;"); + }); + + it("registers the references interface under EmDashCollectionReferences", () => { + const ts = generateTypesFile([makePosts(WIRED), makeAuthors()]); + + expect(ts).toContain("interface EmDashCollectionReferences {"); + // Keyed by slug, the way `getEmDashEntry`'s first argument names it. + expect(ts).toContain("posts: PostReferences;"); + }); + + it("imports ReferencePage only when a bound reference field exists", () => { + expect(generateTypesFile([makePosts(WIRED), makeAuthors()])).toContain( + 'import type { ContentBylineCredit, TaxonomyTerm, ReferencePage } from "emdash";', + ); + expect(generateTypesFile([makePosts(undefined), makeAuthors()])).toContain( + 'import type { ContentBylineCredit, TaxonomyTerm } from "emdash";', + ); + }); + + it("emits nothing for a collection whose reference fields are all unbound", () => { + // An unbound field still owns its column, so it stays a string in the + // data interface and has no page to read. + const ts = generateTypesFile([makePosts(undefined), makeAuthors()]); + + expect(ts).not.toContain("PostReferences"); + expect(ts).not.toContain("EmDashCollectionReferences"); + expect(ts).toContain("author?: string;"); + }); + + it("leaves the page un-narrowed when the target is not in the file", () => { + // A relation whose other end was dropped: naming an interface that the + // file never declares would not compile. + const ts = generateTypesFile([makePosts(WIRED)]); + + expect(ts).toContain("author: ReferencePage;"); + }); + }); }); diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 49f05ee784..a27c03df41 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { BylineRepository } from "../../../src/database/repositories/byline.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { RedirectRepository } from "../../../src/database/repositories/redirect.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; import type { Database } from "../../../src/database/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; @@ -116,6 +117,55 @@ describe("applySeed", () => { expect(tableInfo.rows.map((column) => column.name)).toContain("field_73"); }); + it("creates reference fields that target a later seed collection", async () => { + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { + slug: "author", + label: "Author", + type: "reference", + validation: { targetCollection: "authors" }, + }, + ], + }, + { slug: "authors", label: "Authors", fields: [] }, + ], + }; + + await applySeed(db, seed); + + const field = await new SchemaRegistry(db).getField("posts", "author"); + const relation = await new RelationRepository(db).findBySlug("posts_author"); + expect(relation?.childCollection).toBe("authors"); + expect(field?.validation?.relation).toBe(relation?.slug); + }); + + it("creates reference-heavy schemas within the D1 query budget", async () => { + const counter = new QueryCountingPlugin(); + const fields = Array.from({ length: 20 }, (_, index) => ({ + slug: `related_${index}`, + label: `Related ${index}`, + type: "reference" as const, + validation: { targetCollection: "posts" }, + })); + const seed: SeedFile = { + version: "1", + collections: [{ slug: "posts", label: "Posts", fields }], + }; + + const result = await applySeed(db.withPlugin(counter), seed); + + expect(result.fields.created).toBe(20); + expect(counter.count).toBeLessThan(50); + const relations = await new RelationRepository(db).list(); + expect(relations).toHaveLength(20); + }); + it("should create collections and fields", async () => { const seed: SeedFile = { version: "1", @@ -303,6 +353,289 @@ describe("applySeed", () => { }); }); + describe("relations", () => { + /** Two collections, and a relation joining them, declared up front. */ + function seedWithRelation(overrides: Partial<SeedFile> = {}): SeedFile { + return { + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + { + slug: "authors", + label: "Authors", + fields: [{ slug: "name", label: "Name", type: "string" }], + }, + ], + relations: [ + { + slug: "post_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 2, + }, + ], + ...overrides, + }; + } + + it("creates a declared relation with its labels and limits", async () => { + const result = await applySeed(db, seedWithRelation()); + + expect(result.relations).toMatchObject({ created: 1, updated: 0, skipped: 0 }); + const relation = await new RelationRepository(db).findBySlug("post_authors"); + expect(relation).toMatchObject({ + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 2, + maxParentsPerChild: null, + }); + }); + + it("binds a field that names a relation instead of creating a second one", async () => { + const seed = seedWithRelation(); + seed.collections![0]!.fields.push({ + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "post_authors" }, + }); + + await applySeed(db, seed); + + expect(await new RelationRepository(db).list()).toHaveLength(1); + const field = await new SchemaRegistry(db).getField("posts", "author"); + expect(field?.validation).toMatchObject({ + relation: "post_authors", + relationSide: "parent", + targetCollection: "authors", + }); + }); + + it("binds a field on the other collection to the child side of the same relation", async () => { + const seed = seedWithRelation(); + seed.collections![1]!.fields.push({ + slug: "posts", + label: "Posts", + type: "reference", + validation: { relation: "post_authors" }, + }); + + await applySeed(db, seed); + + const field = await new SchemaRegistry(db).getField("authors", "posts"); + expect(field?.validation).toMatchObject({ + relation: "post_authors", + relationSide: "child", + targetCollection: "posts", + }); + }); + + it("keeps the declared side for a relation whose ends are the same collection", async () => { + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "referenced_by", + label: "Referenced by", + type: "reference", + validation: { relation: "related_posts", relationSide: "child" }, + }, + ], + }, + ], + relations: [ + { + slug: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Referenced by", + childLabel: "Related posts", + }, + ], + }; + + await applySeed(db, seed); + + const field = await new SchemaRegistry(db).getField("posts", "referenced_by"); + expect(field?.validation).toMatchObject({ + relationSide: "child", + targetCollection: "posts", + }); + }); + + it("updates labels and limits on re-apply, and leaves them on skip", async () => { + await applySeed(db, seedWithRelation()); + + const changed = seedWithRelation(); + changed.relations![0]!.childLabel = "Bylines"; + changed.relations![0]!.maxChildrenPerParent = null; + + const skipped = await applySeed(db, changed); + expect(skipped.relations).toMatchObject({ created: 0, updated: 0, skipped: 1 }); + expect((await new RelationRepository(db).findBySlug("post_authors"))?.childLabel).toBe( + "Authors", + ); + + const updated = await applySeed(db, changed, { onConflict: "update" }); + expect(updated.relations).toMatchObject({ created: 0, updated: 1, skipped: 0 }); + expect(await new RelationRepository(db).findBySlug("post_authors")).toMatchObject({ + childLabel: "Bylines", + maxChildrenPerParent: null, + }); + }); + + it("refuses to move a relation onto different collections", async () => { + await applySeed(db, seedWithRelation()); + + const moved = seedWithRelation(); + moved.relations![0]!.childCollection = "posts"; + + // The links it already holds point into the collection it is leaving. + await expect(applySeed(db, moved, { onConflict: "update" })).rejects.toThrow( + /collections cannot change/, + ); + }); + + it("refuses a relation naming a collection that does not exist", async () => { + const seed = seedWithRelation(); + seed.relations![0]!.childCollection = "ghosts"; + + await expect(applySeed(db, seed)).rejects.toMatchObject({ code: "COLLECTION_NOT_FOUND" }); + }); + + it("refuses a field naming a relation that does not exist", async () => { + const seed = seedWithRelation(); + seed.collections![0]!.fields.push({ + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "nope" }, + }); + + await expect(applySeed(db, seed)).rejects.toMatchObject({ code: "RELATION_NOT_FOUND" }); + }); + + it("refuses a field naming a relation that does not touch its collection", async () => { + const seed = seedWithRelation(); + seed.collections!.push({ + slug: "pages", + label: "Pages", + fields: [ + { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "post_authors" }, + }, + ], + }); + + await expect(applySeed(db, seed)).rejects.toThrow(/has no child end on collection "pages"/); + }); + + it("drops a forward $ref that names a collection seeded later", async () => { + const seed = seedWithRelation(); + seed.collections![0]!.fields.push({ + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "post_authors" }, + }); + // `posts` is emitted before `authors`, which is what an export produces + // whenever a reference points at a collection later in the file. + seed.content = { + posts: [{ id: "post-1", slug: "hello", data: { title: "Hello", author: "$ref:author-1" } }], + authors: [{ id: "author-1", slug: "ada", data: { name: "Ada" } }], + }; + + const result = await applySeed(db, seed, { includeContent: true }); + + expect(result.content.created).toBe(2); + }); + + it("binds an existing unbound reference field when a re-applied seed names a target", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "posts", label: "Posts" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "authors", label: "Authors" }); + await registry.createField("authors", { slug: "name", label: "Name", type: "string" }); + // A reference field from before relations existed: no relation on it. + await registry.createField("posts", { slug: "author", label: "Author", type: "reference" }); + + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "author", + label: "Author", + type: "reference", + validation: { targetCollection: "authors" }, + }, + ], + }, + { + slug: "authors", + label: "Authors", + fields: [{ slug: "name", label: "Name", type: "string" }], + }, + ], + }; + + await applySeed(db, seed, { onConflict: "update" }); + + const field = await registry.getField("posts", "author"); + expect(field?.validation).toMatchObject({ targetCollection: "authors" }); + expect(field?.validation?.relation).toEqual(expect.any(String)); + }); + + it("seeds content for a reference field bound to the child side", async () => { + const seed = seedWithRelation(); + seed.collections![1]!.fields.push({ + slug: "posts", + label: "Posts", + type: "reference", + validation: { relation: "post_authors" }, + }); + seed.content = { + posts: [{ id: "post-1", slug: "hello", data: { title: "Hello" } }], + authors: [{ id: "author-1", slug: "ada", data: { name: "Ada", posts: ["$ref:post-1"] } }], + }; + + const result = await applySeed(db, seed, { includeContent: true }); + + expect(result.content.created).toBe(2); + const relation = await new RelationRepository(db).findBySlug("post_authors"); + const author = await new ContentRepository(db).findBySlug("authors", "ada"); + const parents = await new RelationRepository(db).getParentsPage( + relation!.id, + author!.translationGroup!, + ); + expect(parents.items).toHaveLength(1); + }); + }); + describe("taxonomies", () => { it("should create taxonomy definitions", async () => { const seed: SeedFile = { @@ -1142,22 +1475,28 @@ describe("applySeed", () => { expect(entry?.data.title).toBe("Existing"); }); - it("should resolve $ref: references between content", async () => { - const registry = new SchemaRegistry(db); - await registry.createCollection({ slug: "posts", label: "Posts" }); - await registry.createField("posts", { - slug: "title", - label: "Title", - type: "string", - }); - await registry.createField("posts", { - slug: "related_post", - label: "Related Post", - type: "reference", - }); - + it("should resolve $ref: references between content into reference edges", async () => { + // Reference fields are storage-less (migration 043): a seed defines the + // field (with its target collection), apply creates the backing relation, + // and a `$ref:` value in the field's data is written as a content-reference + // edge rather than a column value. const seed: SeedFile = { version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "related_post", + label: "Related Post", + type: "reference", + validation: { targetCollection: "posts" }, + }, + ], + }, + ], content: { posts: [ { id: "post-1", slug: "first", data: { title: "First" } }, @@ -1178,8 +1517,57 @@ describe("applySeed", () => { const first = await contentRepo.findBySlug("posts", "first"); const second = await contentRepo.findBySlug("posts", "second"); - // The reference should be resolved to the real ID - expect(second?.data.related_post).toBe(first?.id); + // Storage-less: the reference value is not persisted as a column. + expect(second?.data).not.toHaveProperty("related_post"); + + // It is stored as an edge, keyed at the translation group on both ends. + const relationRepo = new RelationRepository(db); + const relation = await relationRepo.findBySlug("posts_related_post"); + expect(relation).toBeTruthy(); + const edges = await relationRepo.getChildrenPage(relation!.id, second!.translationGroup!); + expect(edges.items.map((e) => e.childGroup)).toEqual([first!.translationGroup]); + }); + + it("writes $ref: to the column for a reference field that names no target collection", async () => { + // The shape a seed had before relations existed: the target sits in + // `options.collection`, so apply forms no relation and the resolved entry + // id is a plain column value. + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "related_post", + label: "Related Post", + type: "reference", + options: { collection: "posts" }, + }, + ], + }, + ], + content: { + posts: [ + { id: "post-1", slug: "first", data: { title: "First" } }, + { + id: "post-2", + slug: "second", + data: { title: "Second", related_post: "$ref:post-1" }, + }, + ], + }, + }; + + await applySeed(db, seed, { includeContent: true }); + + const contentRepo = new ContentRepository(db); + const first = await contentRepo.findBySlug("posts", "first"); + const second = await contentRepo.findBySlug("posts", "second"); + expect(second?.data.related_post).toBe(first!.id); + expect(await new RelationRepository(db).findBySlug("posts_related_post")).toBeNull(); }); it("should assign taxonomy terms to content", async () => { diff --git a/packages/core/tests/unit/seed/export-relations.test.ts b/packages/core/tests/unit/seed/export-relations.test.ts new file mode 100644 index 0000000000..b4b61689b4 --- /dev/null +++ b/packages/core/tests/unit/seed/export-relations.test.ts @@ -0,0 +1,191 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { exportSeed } from "../../../src/cli/commands/export-seed.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import type { Database } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { applySeed } from "../../../src/seed/apply.js"; +import type { SeedFile } from "../../../src/seed/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +describe("exportSeed: relations", () => { + let db: Kysely<Database>; + + beforeEach(async () => { + setI18nConfig(null); + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + setI18nConfig(null); + }); + + /** A site whose `posts.author` field binds to a declared relation. */ + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "author", + label: "Author", + type: "reference", + validation: { relation: "post_authors" }, + }, + ], + }, + { + slug: "authors", + label: "Authors", + fields: [{ slug: "name", label: "Name", type: "string" }], + }, + ], + relations: [ + { + slug: "post_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + }, + ], + }; + + it("exports a relation with everything needed to recreate it", async () => { + await applySeed(db, seed); + + const exported = await exportSeed(db); + + expect(exported.relations).toEqual([ + { + slug: "post_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + parentLabelSingular: "Post", + childLabel: "Authors", + childLabelSingular: "Author", + maxChildrenPerParent: 1, + maxParentsPerChild: null, + }, + ]); + }); + + it("applies its own export into a fresh database unchanged", async () => { + await applySeed(db, seed); + const exported = await exportSeed(db); + + const fresh = await setupTestDatabase(); + try { + const result = await applySeed(fresh, exported); + + expect(result.relations.created).toBe(1); + expect(await new RelationRepository(fresh).list()).toHaveLength(1); + + // The field binds to the exported relation rather than getting a second + // one created for it. + const field = await new SchemaRegistry(fresh).getField("posts", "author"); + expect(field?.validation).toMatchObject({ + relation: "post_authors", + relationSide: "parent", + targetCollection: "authors", + }); + } finally { + await teardownTestDatabase(fresh); + } + }); + + it("exports a relation no field binds", async () => { + await new RelationRepository(db).create({ + slug: "orphan", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Parents", + childLabel: "Children", + }); + + const exported = await exportSeed(db); + + expect(exported.relations?.map((relation) => relation.slug)).toEqual(["orphan"]); + }); + + it("exports an entry's links as $ref: values and restores them on apply", async () => { + await applySeed( + db, + { + ...seed, + content: { + authors: [{ id: "author-jane", slug: "jane", data: { name: "Jane" } }], + posts: [ + { + id: "post-hello", + slug: "hello", + data: { title: "Hello", author: "$ref:author-jane" }, + }, + ], + }, + }, + { includeContent: true }, + ); + + const exported = await exportSeed(db, "all"); + + expect(exported.content?.posts?.[0]?.data).toMatchObject({ + title: "Hello", + author: "$ref:authors:jane", + }); + + const fresh = await setupTestDatabase(); + try { + await applySeed(fresh, exported, { includeContent: true }); + + const relation = await new RelationRepository(fresh).findBySlug("post_authors"); + const content = new ContentRepository(fresh); + const post = await content.findBySlug("posts", "hello"); + const author = await content.findBySlug("authors", "jane"); + const links = await new RelationRepository(fresh).getChildrenPage( + relation!.id, + post!.translationGroup!, + ); + + expect(links.items.map((link) => link.childGroup)).toEqual([author!.translationGroup]); + } finally { + await teardownTestDatabase(fresh); + } + }); + + it("drops a link whose child collection was not exported", async () => { + await applySeed( + db, + { + ...seed, + content: { + authors: [{ id: "author-jane", slug: "jane", data: { name: "Jane" } }], + posts: [ + { + id: "post-hello", + slug: "hello", + data: { title: "Hello", author: "$ref:author-jane" }, + }, + ], + }, + }, + { includeContent: true }, + ); + + const exported = await exportSeed(db, "posts"); + + // Nothing in the seed would resolve a reference to an entry it does not carry. + expect(exported.content?.posts?.[0]?.data).not.toHaveProperty("author"); + }); +}); diff --git a/packages/core/tests/unit/seed/validate.test.ts b/packages/core/tests/unit/seed/validate.test.ts index 0a2dda388a..3a26069876 100644 --- a/packages/core/tests/unit/seed/validate.test.ts +++ b/packages/core/tests/unit/seed/validate.test.ts @@ -172,7 +172,26 @@ describe("validateSeed", () => { expect(result.errors[0]).toContain('unsupported field type "invalid"'); }); - it("should reject indexed fields whose type cannot be indexed", () => { + it("should reject indexed portableText fields", () => { + const result = validateSeed({ + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [{ slug: "content", label: "Content", type: "portableText", indexed: true }], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'collections[0].fields[0].indexed: type "portableText" cannot be indexed', + ); + }); + + it("should reject an indexed reference field that names a target collection", () => { + // The target makes the field storage-less on apply, leaving no column. const result = validateSeed({ version: "1", collections: [ @@ -181,10 +200,11 @@ describe("validateSeed", () => { label: "Posts", fields: [ { - slug: "content", - label: "Content", - type: "portableText", + slug: "author", + label: "Author", + type: "reference", indexed: true, + validation: { targetCollection: "authors" }, }, ], }, @@ -193,10 +213,35 @@ describe("validateSeed", () => { expect(result.valid).toBe(false); expect(result.errors).toContain( - 'collections[0].fields[0].indexed: type "portableText" cannot be indexed', + "collections[0].fields[0].indexed: a reference field with a targetCollection stores no column to index", ); }); + it("should accept an indexed reference field with no target collection", () => { + // The shape a seed had before relations existed: a plain entry-id column, + // which a content-list filter can be served from. + const result = validateSeed({ + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { + slug: "author", + label: "Author", + type: "reference", + indexed: true, + options: { collection: "authors" }, + }, + ], + }, + ], + }); + + expect(result.valid).toBe(true); + }); + it("should reject non-boolean indexed values", () => { const result = validateSeed({ version: "1", @@ -329,6 +374,74 @@ describe("validateSeed", () => { }); }); + describe("relation validation", () => { + const relation = { + slug: "post_authors", + parentCollection: "posts", + childCollection: "authors", + parentLabel: "Posts", + childLabel: "Authors", + }; + + it("accepts a complete relation", () => { + const result = validateSeed({ + version: "1", + relations: [{ ...relation, maxChildrenPerParent: 3, maxParentsPerChild: null }], + }); + + expect(result.valid).toBe(true); + }); + + it("requires both ends and both labels", () => { + const result = validateSeed({ version: "1", relations: [{ slug: "post_authors" }] }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain("relations[0]: parentCollection is required"); + expect(result.errors).toContain("relations[0]: childCollection is required"); + expect(result.errors).toContain("relations[0]: parentLabel is required"); + expect(result.errors).toContain("relations[0]: childLabel is required"); + }); + + it("rejects a slug that is not usable as an identifier", () => { + const result = validateSeed({ + version: "1", + relations: [{ ...relation, slug: "Post-Authors" }], + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("relations[0].slug"); + }); + + it("rejects a duplicate slug", () => { + const result = validateSeed({ version: "1", relations: [relation, { ...relation }] }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('relations[1].slug: duplicate relation slug "post_authors"'); + }); + + it("rejects a limit that is not a positive integer", () => { + const result = validateSeed({ + version: "1", + relations: [{ ...relation, maxChildrenPerParent: 0, maxParentsPerChild: 1.5 }], + }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "relations[0].maxChildrenPerParent: must be a positive integer, or null for unlimited", + ); + expect(result.errors).toContain( + "relations[0].maxParentsPerChild: must be a positive integer, or null for unlimited", + ); + }); + + it("rejects relations that are not an array", () => { + const result = validateSeed({ version: "1", relations: {} }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain("relations must be an array"); + }); + }); + describe("taxonomy validation", () => { it("should require taxonomy name", () => { const result = validateSeed({ diff --git a/packages/core/tests/utils/legacy-reference-field.ts b/packages/core/tests/utils/legacy-reference-field.ts new file mode 100644 index 0000000000..d2231b6172 --- /dev/null +++ b/packages/core/tests/utils/legacy-reference-field.ts @@ -0,0 +1,71 @@ +import { type Kysely, sql } from "kysely"; +import { ulid } from "ulidx"; + +import type { Database } from "../../src/database/types.js"; + +export interface LegacyReferenceFieldOptions { + /** Written to `options.collection`, the only place a pre-relations field named its target. */ + targetCollection?: string; + /** `options.allowMultiple`; omitted entirely when undefined, as most legacy rows have it. */ + allowMultiple?: boolean; + /** Written to `validation.targetCollection` instead of `options.collection`. */ + validationTargetCollection?: string; + indexed?: boolean; + searchable?: boolean; + required?: boolean; + label?: string; +} + +/** + * Build a reference field in the shape it had before relations existed: a + * `_emdash_fields` row naming its target in `options.collection`, no + * `validation.relation`, and a real TEXT column on the content table. + * + * Written with raw statements rather than through `SchemaRegistry` so a test + * fixture never depends on the behaviour under test. + */ +export async function createLegacyReferenceField( + db: Kysely<Database>, + collectionSlug: string, + fieldSlug: string, + options: LegacyReferenceFieldOptions = {}, +): Promise<void> { + const collection = await db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", collectionSlug) + .executeTakeFirstOrThrow(); + + const fieldOptions: Record<string, unknown> = {}; + if (options.targetCollection) fieldOptions.collection = options.targetCollection; + if (options.allowMultiple !== undefined) fieldOptions.allowMultiple = options.allowMultiple; + + const validation = options.validationTargetCollection + ? { targetCollection: options.validationTargetCollection } + : null; + + await db + .insertInto("_emdash_fields") + .values({ + id: ulid(), + collection_id: collection.id, + slug: fieldSlug, + label: options.label ?? "Author", + type: "reference", + column_type: "TEXT", + required: options.required ? 1 : 0, + unique: 0, + default_value: null, + validation: validation ? JSON.stringify(validation) : null, + widget: null, + options: Object.keys(fieldOptions).length > 0 ? JSON.stringify(fieldOptions) : null, + sort_order: 10, + indexed: options.indexed ? 1 : 0, + searchable: options.searchable ? 1 : 0, + }) + .execute(); + + await sql`ALTER TABLE ${sql.ref(`ec_${collectionSlug}`)} ADD COLUMN ${sql.ref(fieldSlug)} text`.execute( + db, + ); +} diff --git a/packages/core/tests/workerd/reference-children-batching-d1.test.ts b/packages/core/tests/workerd/reference-children-batching-d1.test.ts new file mode 100644 index 0000000000..a5992f3ab4 --- /dev/null +++ b/packages/core/tests/workerd/reference-children-batching-d1.test.ts @@ -0,0 +1,98 @@ +import { env } from "cloudflare:test"; +import { Kysely } from "kysely"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { RawBindingD1Dialect } from "../../../cloudflare/src/db/d1-dialect.js"; +import { handleContentCreate } from "../../src/api/handlers/content.js"; +import { handleReferenceChildrenSet } from "../../src/api/handlers/relations.js"; +import { runMigrations } from "../../src/database/migrations/runner.js"; +import { RelationRepository } from "../../src/database/repositories/relation.js"; +import type { Database } from "../../src/database/types.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; + +declare module "cloudflare:test" { + interface ProvidedEnv { + DB: D1Database; + } +} + +let db: Kysely<Database>; + +beforeAll(async () => { + db = new Kysely<Database>({ + dialect: new RawBindingD1Dialect({ database: env.DB }), + }); + await runMigrations(db); +}); + +afterAll(async () => { + await db.destroy(); +}); + +it("replaces more than sixteen reference children in order on D1", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "batch_pages", label: "Pages" }); + await registry.createField("batch_pages", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "batch_posts", label: "Posts" }); + await registry.createField("batch_posts", { slug: "title", label: "Title", type: "string" }); + + const repo = new RelationRepository(db); + const relation = await repo.create({ + slug: "batch_related_pages", + parentCollection: "batch_posts", + childCollection: "batch_pages", + parentLabel: "Post", + childLabel: "Related page", + }); + await registry.createField("batch_posts", { + slug: "related_pages", + label: "Related pages", + type: "reference", + validation: { + relation: relation.slug, + relationSide: "parent", + targetCollection: "batch_pages", + multiple: true, + }, + }); + + const parent = await handleContentCreate(db, "batch_posts", { data: { title: "Parent" } }); + const oldChild = await handleContentCreate(db, "batch_pages", { data: { title: "Old" } }); + if (!parent.success || !oldChild.success) throw new Error("Reference fixture setup failed"); + + const children = []; + for (let index = 0; index < 17; index++) { + const child = await handleContentCreate(db, "batch_pages", { + data: { title: `Child ${index}` }, + }); + if (!child.success) throw new Error("Child fixture setup failed"); + children.push(child.data.item); + } + + const seeded = await handleReferenceChildrenSet( + db, + "batch_posts", + parent.data.item.id, + relation.slug, + [oldChild.data.item.id], + ); + expect(seeded.success).toBe(true); + + const replaced = await handleReferenceChildrenSet( + db, + "batch_posts", + parent.data.item.id, + relation.slug, + children.map((child) => child.id), + ); + expect(replaced.success).toBe(true); + + const stored = await repo.getChildren(relation.slug, parent.data.item.translationGroup); + expect(stored.map((edge) => edge.childGroup)).toEqual( + children.map((child) => child.translationGroup), + ); + expect(stored.map((edge) => edge.sortOrder)).toEqual(children.map((_, index) => index)); + expect(stored.some((edge) => edge.childGroup === oldChild.data.item.translationGroup)).toBe( + false, + ); +}); diff --git a/packages/core/tests/workerd/reference-constraints-d1.test.ts b/packages/core/tests/workerd/reference-constraints-d1.test.ts new file mode 100644 index 0000000000..7e6b7f9d44 --- /dev/null +++ b/packages/core/tests/workerd/reference-constraints-d1.test.ts @@ -0,0 +1,102 @@ +import { env } from "cloudflare:test"; +import { Kysely } from "kysely"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { RawBindingD1Dialect } from "../../../cloudflare/src/db/d1-dialect.js"; +import { handleContentCreate, handleContentGet } from "../../src/api/handlers/content.js"; +import { handleReferenceChildrenSet } from "../../src/api/handlers/relations.js"; +import { runMigrations } from "../../src/database/migrations/runner.js"; +import { RelationRepository } from "../../src/database/repositories/relation.js"; +import type { Database } from "../../src/database/types.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; + +declare module "cloudflare:test" { + interface ProvidedEnv { + DB: D1Database; + } +} + +let db: Kysely<Database>; + +beforeAll(async () => { + db = new Kysely<Database>({ + dialect: new RawBindingD1Dialect({ database: env.DB }), + }); + await runMigrations(db); +}); + +afterAll(async () => { + await db.destroy(); +}); + +it("enforces reference constraints while preserving translation-group inheritance on D1", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "constraint_pages", label: "Pages" }); + await registry.createField("constraint_pages", { slug: "title", label: "Title", type: "string" }); + await registry.createCollection({ slug: "constraint_posts", label: "Posts" }); + await registry.createField("constraint_posts", { slug: "title", label: "Title", type: "string" }); + + const relation = await new RelationRepository(db).create({ + slug: "constraint_featured_page", + parentCollection: "constraint_posts", + childCollection: "constraint_pages", + parentLabel: "Posts", + childLabel: "Featured page", + // The limit is the relation's, not the field's. + maxChildrenPerParent: 1, + }); + await registry.createField("constraint_posts", { + slug: "featured_page", + label: "Featured page", + type: "reference", + required: true, + validation: { + relation: relation.slug, + relationSide: "parent", + targetCollection: "constraint_pages", + multiple: false, + }, + }); + + const first = await handleContentCreate(db, "constraint_pages", { data: { title: "First" } }); + const second = await handleContentCreate(db, "constraint_pages", { data: { title: "Second" } }); + if (!first.success || !second.success) throw new Error("Child setup failed"); + + const omitted = await handleContentCreate(db, "constraint_posts", { data: { title: "Omitted" } }); + expect(omitted.success).toBe(false); + if (!omitted.success) expect(omitted.error.code).toBe("VALIDATION_ERROR"); + + const source = await handleContentCreate(db, "constraint_posts", { + data: { title: "Source" }, + references: { featured_page: [first.data.item.id] }, + }); + expect(source.success).toBe(true); + if (!source.success) return; + + const tooMany = await handleReferenceChildrenSet( + db, + "constraint_posts", + source.data.item.id, + relation.slug, + [first.data.item.id, second.data.item.id], + ); + expect(tooMany.success).toBe(false); + if (!tooMany.success) expect(tooMany.error.code).toBe("VALIDATION_ERROR"); + + const translation = await handleContentCreate(db, "constraint_posts", { + data: { title: "Translation" }, + locale: "fr", + translationOf: source.data.item.id, + }); + expect(translation.success).toBe(true); + if (!translation.success) return; + + const hydrated = await handleContentGet(db, "constraint_posts", translation.data.item.id, "fr", { + includeDrafts: true, + }); + expect(hydrated.success).toBe(true); + if (!hydrated.success) return; + const child = hydrated.data.item.references?.featured_page?.children[0]; + expect(child?.id).toBe(first.data.item.id); + expect(child?.translationGroup).toBe(first.data.item.translationGroup); +}); diff --git a/packages/core/tsconfig.typecheck.json b/packages/core/tsconfig.typecheck.json new file mode 100644 index 0000000000..db928a5494 --- /dev/null +++ b/packages/core/tsconfig.typecheck.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*", "tests/types/**/*"], + "exclude": [ + "node_modules", + "dist", + "src/astro/**", + "src/components/**", + "src/preview/**", + "src/ui.ts", + "src/ui-comments.ts", + "src/auth/providers/*-admin.tsx" + ] +} diff --git a/packages/core/vitest.types.config.ts b/packages/core/vitest.types.config.ts new file mode 100644 index 0000000000..2ec8c70929 --- /dev/null +++ b/packages/core/vitest.types.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +// Type-level tests. Typegen's product is a `.d.ts`, so what matters about it is +// what the compiler makes of it: that a selection narrows to the fields it +// named and to the target collection's own interface. Nothing here runs. +export default defineConfig({ + test: { + include: [], + typecheck: { + enabled: true, + only: true, + include: ["tests/types/**/*.test-d.ts"], + tsconfig: "./tsconfig.typecheck.json", + }, + }, +}); diff --git a/scripts/query-counts.mjs b/scripts/query-counts.mjs index 6faa20cec3..e8a18dc84b 100644 --- a/scripts/query-counts.mjs +++ b/scripts/query-counts.mjs @@ -85,6 +85,11 @@ const ROUTES = [ // per-byline media lookup. The gap between them is the N+1 the join removes. ["GET", "/contributors"], ["GET", "/contributors-naive"], + // An entry read with and without a reference field. /related opts in; + // /related-baseline is the same page without the option. The gap between + // them is the whole cost of `getEmDashEntry(..., { references })`. + ["GET", "/related"], + ["GET", "/related-baseline"], ]; const TRACKED_PHASES = new Set(["cold", "warm"]); diff --git a/scripts/query-counts.queries.sqlite.json b/scripts/query-counts.queries.sqlite.json index 9fde0eb3a2..c7c2b1776b 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -140,6 +140,44 @@ "select count(\"id\") as \"count\" from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ?": 1, "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1 }, + "GET /related (cold)": { + "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"value\" from \"options\" where \"name\" = ?": 1, + "select * from \"_emdash_content_references\" where \"relation_id\" = ? and \"parent_group\" = ? order by \"sort_order\" asc, \"id\" asc limit ?": 1, + "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, + "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, + "SELECT *, ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_posts\" WHERE translation_group in (...) AND deleted_at IS NULL AND status = ? ORDER BY translation_group ASC, locale ASC": 1, + "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"c\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"c\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, + "SELECT f.slug AS slug, f.validation AS validation, r.id AS relation_id FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id LEFT JOIN \"_emdash_relations\" AS r ON r.slug = json_extract(validation, '$.relation') WHERE c.slug = ? AND f.type = 'reference'": 1 + }, + "GET /related (warm)": { + "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"value\" from \"options\" where \"name\" = ?": 1, + "select * from \"_emdash_content_references\" where \"relation_id\" = ? and \"parent_group\" = ? order by \"sort_order\" asc, \"id\" asc limit ?": 1, + "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, + "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, + "SELECT *, ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_posts\" WHERE translation_group in (...) AND deleted_at IS NULL AND status = ? ORDER BY translation_group ASC, locale ASC": 1, + "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"c\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"c\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, + "SELECT f.slug AS slug, f.validation AS validation, r.id AS relation_id FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id LEFT JOIN \"_emdash_relations\" AS r ON r.slug = json_extract(validation, '$.relation') WHERE c.slug = ? AND f.type = 'reference'": 1 + }, + "GET /related-baseline (cold)": { + "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"value\" from \"options\" where \"name\" = ?": 1, + "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, + "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, + "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"c\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"c\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 + }, + "GET /related-baseline (warm)": { + "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"value\" from \"options\" where \"name\" = ?": 1, + "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, + "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, + "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"c\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"c\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 + }, "GET /rss.xml (cold)": { "select \"value\" from \"options\" where \"name\" = ?": 1, "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_posts\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\", ( SELECT json_group_array(f.slug) FROM \"_emdash_fields\" AS f INNER JOIN \"_emdash_collections\" AS c ON c.id = f.collection_id WHERE c.slug = ? AND f.type = ? ) AS \"_emdash_boolean_fields\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"published_at\" DESC, \"id\" DESC LIMIT ?": 1 diff --git a/scripts/query-counts.snapshot.sqlite.json b/scripts/query-counts.snapshot.sqlite.json index 9945a176eb..b070e7ff14 100644 --- a/scripts/query-counts.snapshot.sqlite.json +++ b/scripts/query-counts.snapshot.sqlite.json @@ -13,6 +13,10 @@ "GET /posts (warm)": 6, "GET /posts/building-for-the-long-term (cold)": 17, "GET /posts/building-for-the-long-term (warm)": 17, + "GET /related (cold)": 9, + "GET /related (warm)": 9, + "GET /related-baseline (cold)": 6, + "GET /related-baseline (warm)": 6, "GET /rss.xml (cold)": 2, "GET /rss.xml (warm)": 2, "GET /search (cold)": 11, diff --git a/skills/building-emdash-site/references/querying-and-rendering.md b/skills/building-emdash-site/references/querying-and-rendering.md index e568f8b8d4..31b848a178 100644 --- a/skills/building-emdash-site/references/querying-and-rendering.md +++ b/skills/building-emdash-site/references/querying-and-rendering.md @@ -81,6 +81,31 @@ interface PostData { **Important:** `entry.id` is the slug (for URLs), `entry.data.id` is the database ULID (for API calls like `getEntryTerms`). +### Reference Fields + +A `reference` field's value is not in `data`. Ask for it by field slug through the `references` option, and read the page it returns: + +```typescript +const { entry: post } = await getEmDashEntry("posts", slug, { + references: { author: true, related_posts: { limit: 6 } }, +}); + +const author = post?.references?.author.entries[0]; +const related = post?.references?.related_posts.entries ?? []; +``` + +`true` is the first page at the default limit of 50; `{ limit, cursor }` takes at most 100 per page. `getEmDashReferences(collection, id, field, { cursor, limit })` fetches the next page of one field on its own. + +Each referenced entry is a full `ContentEntry` -- same `data` mapping, and an `edit` proxy scoped to the referenced entry. Bylines and taxonomy terms are **not** hydrated onto referenced entries; read those from the entry itself. + +Entries come back in the editor's order when the field sits on the parent end of its relation. A field on the child end lists whatever points at it, unordered. + +Ask only for the fields the page renders: a call with no `references` runs no extra queries, and each selected field costs one link query plus one entry query per distinct target collection. + +A public render sees published entries and the published selection. Preview and visual editing see the selection staged in the entry's draft. + +Generated types register a `{Collection}References` interface per collection with bound reference fields, so `post.references.author.entries[0].data` carries the target collection's interface and a field that was not selected is a type error. + ### Caching Query results include a `cacheHint` for Astro's Route Caching: diff --git a/skills/building-emdash-site/references/schema-and-seed.md b/skills/building-emdash-site/references/schema-and-seed.md index f3fee9b91f..338ac4259d 100644 --- a/skills/building-emdash-site/references/schema-and-seed.md +++ b/skills/building-emdash-site/references/schema-and-seed.md @@ -65,7 +65,7 @@ Collections define content types. Each collection becomes a database table (`ec_ | `boolean` | INTEGER | `boolean` | Stored as 0/1 | | `datetime` | TEXT | `Date` | ISO 8601 string in DB | | `image` | TEXT | `{ id, src?, alt?, width?, height? }` | **Object, not a string** | -| `reference` | TEXT | `string` (ID) | Reference to another entry | +| `reference` | none | Links under `references` | No column; see below | | `portableText` | JSON | `PortableTextBlock[]` | Rich text as structured JSON | | `json` | JSON | `any` | Arbitrary JSON data | @@ -380,12 +380,30 @@ For external images without downloading: ### Reference fields in seed content -Use `$ref:id` format to reference other entries: +Declare the field with the collection it links to: + +```json +{ + "slug": "author", + "label": "Author", + "type": "reference", + "validation": { "targetCollection": "authors", "multiple": false } +} +``` + +Such a field stores no column. Its links live in `_emdash_content_references`, keyed by each entry's +translation group, and content reads return the linked entries under `references`. + +In content, use `$ref:id` to name another seeded entry: ```json "author": "$ref:byline-editorial" ``` +A reference field declared without `targetCollection` keeps a TEXT column instead and holds the +resolved entry ID as a plain string, which is how reference fields behaved before EmDash modelled +relations. + ### Portable Text in seed content Content fields of type `portableText` are arrays of blocks: