Skip to content
5 changes: 5 additions & 0 deletions .changeset/content-references-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This is net-new feature groundwork. While the package is pre-1.0, a minor bump is more idiomatic in Changesets for additive capabilities than patch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumped to minor.


Add content-reference database schema: `_emdash_relations` (relationship-type definitions, row-per-locale) and `_emdash_content_references` (directed, locale-agnostic edges between content entries linked by `translation_group`). Additive, forward-only migration `043`; no existing tables change. Groundwork for reference fields — no field type, API, or admin UI yet.
121 changes: 121 additions & 0 deletions packages/core/src/database/migrations/043_content_references.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { Kysely } from "kysely";

import { getI18nConfig } from "../../i18n/config.js";
import { currentTimestamp } from "../dialect-helpers.js";

/**
* Content references.
*
* `_emdash_relations` defines relationship types (row-per-locale, mirroring
* `_emdash_taxonomy_defs`): which collection is the parent, which is the child
* (the side that may multiply), and localized labels for each role.
*
* `_emdash_content_references` holds directed `parent → child` edges between
* content entries. Both endpoints and the relation are referenced by
* `translation_group`, so edges are locale-agnostic. As with
* `content_taxonomies`, group-linking precludes SQL foreign keys; referential
* cleanup is an application-layer concern.
*
* Idempotency: every `CREATE TABLE` and `CREATE INDEX` uses `.ifNotExists()`,
* so a partial prior run (a crash mid-migration, or a retry after the runner's
* race-recovery path) re-applies cleanly — including any indexes that landed in
* the failed pass after their table.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
const defaultLocale = getI18nConfig()?.defaultLocale ?? "en";

await db.schema
.createTable("_emdash_relations")
.ifNotExists()
.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(defaultLocale))
.addColumn("translation_group", "text", (c) => c.notNull())
.addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
.addColumn("updated_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
.addUniqueConstraint("_emdash_relations_name_locale_unique", ["name", "locale"])
.execute();

await db.schema
.createIndex("idx__emdash_relations_locale")
.ifNotExists()
.on("_emdash_relations")
Comment thread
MA2153 marked this conversation as resolved.
.column("locale")
.execute();
await db.schema
.createIndex("idx__emdash_relations_translation_group")
.ifNotExists()
.on("_emdash_relations")
.column("translation_group")
.execute();
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();

Comment thread
MA2153 marked this conversation as resolved.
// One row per (translation_group, locale): the row-per-locale model wants a
// relation to have at most one variant per locale. Migration 040 enforces
// the same invariant for `_emdash_bylines` with a *partial* unique
// (`WHERE translation_group IS NOT NULL`) only because it back-fills an
// existing table; this table is new and `translation_group` is `NOT NULL`,
// so a plain unique index suffices.
await db.schema
.createIndex("idx__emdash_relations_group_locale_unique")
.ifNotExists()
.unique()
.on("_emdash_relations")
.columns(["translation_group", "locale"])
.execute();

await db.schema
.createTable("_emdash_content_references")
.ifNotExists()
.addColumn("id", "text", (c) => c.primaryKey())
.addColumn("relation_group", "text", (c) => c.notNull())
.addColumn("parent_group", "text", (c) => c.notNull())
.addColumn("child_group", "text", (c) => c.notNull())
.addColumn("sort_order", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
.addUniqueConstraint("content_references_unique", [
"relation_group",
"parent_group",
"child_group",
])
.execute();

await db.schema
.createIndex("idx__emdash_content_references_parent")
.ifNotExists()
.on("_emdash_content_references")
.columns(["parent_group", "relation_group", "sort_order"])
.execute();
await db.schema
.createIndex("idx__emdash_content_references_child")
.ifNotExists()
.on("_emdash_content_references")
.columns(["child_group", "relation_group"])
.execute();
await db.schema
.createIndex("idx__emdash_content_references_relation")
.ifNotExists()
.on("_emdash_content_references")
.column("relation_group")
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable("_emdash_content_references").ifExists().execute();
await db.schema.dropTable("_emdash_relations").ifExists().execute();
}
2 changes: 2 additions & 0 deletions packages/core/src/database/migrations/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import * as m039 from "./039_fix_fts5_triggers.js";
import * as m040 from "./040_byline_i18n.js";
import * as m041 from "./041_content_locale_list_index.js";
import * as m042 from "./042_byline_fields.js";
import * as m043 from "./043_content_references.js";

const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
"001_initial": m001,
Expand Down Expand Up @@ -87,6 +88,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
"040_byline_i18n": m040,
"041_content_locale_list_index": m041,
"042_byline_fields": m042,
"043_content_references": m043,
});

/** Total number of registered migrations. Exported for use in tests. */
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ export interface Database {
_emdash_byline_fields: BylineFieldTable;
_emdash_byline_field_values: BylineFieldValueTable;
_emdash_byline_field_group_values: BylineFieldGroupValueTable;
_emdash_relations: RelationTable;
_emdash_content_references: ContentReferenceTable;
_emdash_rate_limits: RateLimitTable;
}

Expand Down Expand Up @@ -573,6 +575,38 @@ export interface BylineFieldGroupValueTable {
updated_at: Generated<string>;
}

// Content references
//
// `_emdash_relations` defines relationship types (row-per-locale, like
// `_emdash_taxonomy_defs`). `_emdash_content_references` holds directed edges
// between content entries, linked by `translation_group` so they are
// locale-agnostic — no foreign keys, mirroring `content_taxonomies`.

export interface RelationTable {
id: string;
name: string;
parent_collection: string;
child_collection: string;
parent_label: string;
child_label: string;
locale: Generated<string>;
translation_group: string;
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;
/** Parent entry's `translation_group`. */
parent_group: string;
/** Child entry's `translation_group`. */
child_group: string;
sort_order: Generated<number>;
created_at: Generated<string>;
}

// Rate Limits

export interface RateLimitTable {
Expand Down
Loading
Loading