diff --git a/.changeset/content-references-schema.md b/.changeset/content-references-schema.md new file mode 100644 index 000000000..857f6a56d --- /dev/null +++ b/.changeset/content-references-schema.md @@ -0,0 +1,5 @@ +--- +"emdash": 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. diff --git a/packages/core/src/database/migrations/043_content_references.ts b/packages/core/src/database/migrations/043_content_references.ts new file mode 100644 index 000000000..914b08687 --- /dev/null +++ b/packages/core/src/database/migrations/043_content_references.ts @@ -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): Promise { + 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") + .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(); + + // 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): Promise { + await db.schema.dropTable("_emdash_content_references").ifExists().execute(); + await db.schema.dropTable("_emdash_relations").ifExists().execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 396d017a7..7316b1d4e 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -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> = Object.freeze({ "001_initial": m001, @@ -87,6 +88,7 @@ const MIGRATIONS: Readonly> = 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. */ diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index cc64ce639..9da9d9a6c 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -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; } @@ -573,6 +575,38 @@ export interface BylineFieldGroupValueTable { updated_at: Generated; } +// 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; + translation_group: string; + created_at: Generated; + updated_at: Generated; +} + +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; + created_at: Generated; +} + // Rate Limits export interface RateLimitTable { diff --git a/packages/core/tests/integration/database/content-references.test.ts b/packages/core/tests/integration/database/content-references.test.ts new file mode 100644 index 000000000..7e3059991 --- /dev/null +++ b/packages/core/tests/integration/database/content-references.test.ts @@ -0,0 +1,272 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import type { Database } from "../../../src/database/types.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("Content references schema", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); // runs all migrations + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("creates _emdash_relations and _emdash_content_references", async () => { + for (const table of ["_emdash_relations", "_emdash_content_references"] as const) { + const rows = await ctx.db + .selectFrom(table as keyof Database) + .selectAll() + .execute(); + expect(Array.isArray(rows), `table ${table} should exist`).toBe(true); + } + }); + + it("accepts a relation row and an edge row with the expected columns", async () => { + await ctx.db + .insertInto("_emdash_relations") + .values({ + id: "rel_manages", + name: "manages", + parent_collection: "employees", + child_collection: "employees", + parent_label: "Manager", + child_label: "Direct report", + translation_group: "rel_manages", + }) + .execute(); + + await ctx.db + .insertInto("_emdash_content_references") + .values({ + id: "ref_1", + relation_group: "rel_manages", + parent_group: "grp_alice", + child_group: "grp_bob", + }) + .execute(); + + const rel = await ctx.db + .selectFrom("_emdash_relations") + .selectAll() + .where("name", "=", "manages") + .executeTakeFirstOrThrow(); + expect(rel.locale).toBe("en"); // default locale backfill + expect(rel.child_collection).toBe("employees"); + + const edge = await ctx.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("id", "=", "ref_1") + .executeTakeFirstOrThrow(); + expect(edge.relation_group).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" }) + .execute(); + + await expect( + ctx.db + .insertInto("_emdash_content_references") + .values({ id: "e2", relation_group: "r1", parent_group: "p1", child_group: "c1" }) + .execute(), + ).rejects.toThrow(); + }); + + it("rejects a duplicate relation name within one locale, allows across locales", 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 }) + .execute(); + + // Same (name, locale) -> rejected + await expect( + ctx.db + .insertInto("_emdash_relations") + .values({ id: "r_en2", locale: "en", ...base }) + .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. + await expect( + ctx.db + .insertInto("_emdash_relations") + .values({ + id: "n1", + name: "manages", + parent_collection: "employees", + child_collection: "employees", + parent_label: "Manager", + child_label: "Report", + locale: "en", + translation_group: null as unknown as string, + }) + .execute(), + ).rejects.toThrow(); + }); + + it("forward and backlink traversal return the expected rows", async () => { + // Parent p1 references children c1, c2 (ordered); p2 also references c1. + 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 }, + ]) + .execute(); + + // Forward: p1's children for relation r1, ordered. + const children = await ctx.db + .selectFrom("_emdash_content_references") + .select("child_group") + .where("parent_group", "=", "p1") + .where("relation_group", "=", "r1") + .orderBy("sort_order") + .execute(); + expect(children.map((r) => r.child_group)).toEqual(["c1", "c2"]); + + // Backlink: who references c1 (any parent) for relation r1. + const parents = await ctx.db + .selectFrom("_emdash_content_references") + .select("parent_group") + .where("child_group", "=", "c1") + .where("relation_group", "=", "r1") + .orderBy("parent_group") + .execute(); + expect(parents.map((r) => r.parent_group)).toEqual(["p1", "p2"]); + }); + + it("allows same-collection and self references", async () => { + // 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" }) + .execute(); + + const row = await ctx.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("id", "=", "self1") + .executeTakeFirstOrThrow(); + expect(row.parent_group).toBe(row.child_group); + }); + + it("creates the expected indexes (sqlite)", async () => { + if (ctx.dialect !== "sqlite") return; // index introspection is dialect-specific + + const result = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' + `.execute(ctx.db); + 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); + }); +}); diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 14a7265de..9bd4a5915 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -125,6 +125,7 @@ describe("Database Migrations (Integration)", () => { "040_byline_i18n", "041_content_locale_list_index", "042_byline_fields", + "043_content_references", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute();