From 3e6c824f8d0d1dc1c8fe1cf426d4dcc5accf7545 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:14:32 +0300 Subject: [PATCH 1/8] feat(core): add content references schema (relations + edges) Co-Authored-By: Claude Opus 4.8 --- .../migrations/043_content_references.ts | 93 +++++++++++++++++++ .../core/src/database/migrations/runner.ts | 2 + packages/core/src/database/types.ts | 34 +++++++ .../database/content-references.test.ts | 72 ++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 packages/core/src/database/migrations/043_content_references.ts create mode 100644 packages/core/tests/integration/database/content-references.test.ts 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..60ac71adf --- /dev/null +++ b/packages/core/src/database/migrations/043_content_references.ts @@ -0,0 +1,93 @@ +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. + */ +export async function up(db: Kysely): Promise { + const defaultLocale = getI18nConfig()?.defaultLocale ?? "en"; + + 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(defaultLocale)) + .addColumn("translation_group", "text") + .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_relations_locale") + .on("_emdash_relations") + .column("locale") + .execute(); + await db.schema + .createIndex("idx_relations_translation_group") + .on("_emdash_relations") + .column("translation_group") + .execute(); + await db.schema + .createIndex("idx_relations_parent_collection") + .on("_emdash_relations") + .column("parent_collection") + .execute(); + await db.schema + .createIndex("idx_relations_child_collection") + .on("_emdash_relations") + .column("child_collection") + .execute(); + + await db.schema + .createTable("_emdash_content_references") + .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_content_references_parent") + .on("_emdash_content_references") + .columns(["parent_group", "relation_group", "sort_order"]) + .execute(); + await db.schema + .createIndex("idx_content_references_child") + .on("_emdash_content_references") + .columns(["child_group", "relation_group"]) + .execute(); + await db.schema + .createIndex("idx_content_references_relation") + .on("_emdash_content_references") + .column("relation_group") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("_emdash_content_references").execute(); + await db.schema.dropTable("_emdash_relations").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..cd8935f7f 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 | null; + 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..0dbcbbe70 --- /dev/null +++ b/packages/core/tests/integration/database/content-references.test.ts @@ -0,0 +1,72 @@ +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 + }); +}); From 335d3619e1b9895fe0b7fee49cf778002bb3a5fb Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:19:47 +0300 Subject: [PATCH 2/8] test(core): cover content-reference unique constraints Co-Authored-By: Claude Opus 4.8 --- .../database/content-references.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/core/tests/integration/database/content-references.test.ts b/packages/core/tests/integration/database/content-references.test.ts index 0dbcbbe70..f4cddbfbe 100644 --- a/packages/core/tests/integration/database/content-references.test.ts +++ b/packages/core/tests/integration/database/content-references.test.ts @@ -69,4 +69,61 @@ describeEachDialect("Content references schema", (dialect) => { 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); + }); }); From e39615e523785a02fb130eb93953c5599e21c1e4 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:23:33 +0300 Subject: [PATCH 3/8] test(core): cover content-reference traversal and self-refs Co-Authored-By: Claude Opus 4.8 --- .../database/content-references.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/core/tests/integration/database/content-references.test.ts b/packages/core/tests/integration/database/content-references.test.ts index f4cddbfbe..35b77238a 100644 --- a/packages/core/tests/integration/database/content-references.test.ts +++ b/packages/core/tests/integration/database/content-references.test.ts @@ -126,4 +126,51 @@ describeEachDialect("Content references schema", (dialect) => { .execute(); expect(rows).toHaveLength(2); }); + + 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); + }); }); From 4e41f5b5b65b6cbed25ad6fe9983b4bb4609bee0 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:27:15 +0300 Subject: [PATCH 4/8] test(core): assert content-reference indexes and rollback Co-Authored-By: Claude Opus 4.8 --- .../database/content-references.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/core/tests/integration/database/content-references.test.ts b/packages/core/tests/integration/database/content-references.test.ts index 35b77238a..34da912ce 100644 --- a/packages/core/tests/integration/database/content-references.test.ts +++ b/packages/core/tests/integration/database/content-references.test.ts @@ -1,3 +1,4 @@ +import { sql } from "kysely"; import { afterEach, beforeEach, expect, it } from "vitest"; import type { Database } from "../../../src/database/types.js"; @@ -173,4 +174,40 @@ describeEachDialect("Content references schema", (dialect) => { .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_relations_locale", + "idx_relations_translation_group", + "idx_relations_parent_collection", + "idx_relations_child_collection", + "idx_content_references_parent", + "idx_content_references_child", + "idx_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); + }); }); From ad0ad4d6f53cfe08a1f1f0c2ca9211897be3ee54 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:41:41 +0300 Subject: [PATCH 5/8] fix(core): make migration 043 idempotent for partial-run recovery Guard every CREATE TABLE/INDEX in 043 with .ifNotExists() so a crash mid-migration (or the runner's race-recovery re-run) re-applies cleanly, and add 043 to the trailing-migration re-run test. Co-Authored-By: Claude Opus 4.8 --- .../database/migrations/043_content_references.ts | 14 ++++++++++++++ .../tests/integration/database/migrations.test.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/packages/core/src/database/migrations/043_content_references.ts b/packages/core/src/database/migrations/043_content_references.ts index 60ac71adf..0a3b17063 100644 --- a/packages/core/src/database/migrations/043_content_references.ts +++ b/packages/core/src/database/migrations/043_content_references.ts @@ -15,12 +15,18 @@ import { currentTimestamp } from "../dialect-helpers.js"; * `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()) @@ -36,27 +42,32 @@ export async function up(db: Kysely): Promise { await db.schema .createIndex("idx_relations_locale") + .ifNotExists() .on("_emdash_relations") .column("locale") .execute(); await db.schema .createIndex("idx_relations_translation_group") + .ifNotExists() .on("_emdash_relations") .column("translation_group") .execute(); await db.schema .createIndex("idx_relations_parent_collection") + .ifNotExists() .on("_emdash_relations") .column("parent_collection") .execute(); await db.schema .createIndex("idx_relations_child_collection") + .ifNotExists() .on("_emdash_relations") .column("child_collection") .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()) @@ -72,16 +83,19 @@ export async function up(db: Kysely): Promise { await db.schema .createIndex("idx_content_references_parent") + .ifNotExists() .on("_emdash_content_references") .columns(["parent_group", "relation_group", "sort_order"]) .execute(); await db.schema .createIndex("idx_content_references_child") + .ifNotExists() .on("_emdash_content_references") .columns(["child_group", "relation_group"]) .execute(); await db.schema .createIndex("idx_content_references_relation") + .ifNotExists() .on("_emdash_content_references") .column("relation_group") .execute(); 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(); From 6dfc81bb3585b2304c9b863943f98b81b9c22b7c Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:41:41 +0300 Subject: [PATCH 6/8] chore: changeset for content references schema Co-Authored-By: Claude Opus 4.8 --- .changeset/content-references-schema.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/content-references-schema.md diff --git a/.changeset/content-references-schema.md b/.changeset/content-references-schema.md new file mode 100644 index 000000000..42e833b5e --- /dev/null +++ b/.changeset/content-references-schema.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +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. From 1e17da0d13d0fa7d0a7581ccd90df1995401bfec Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:44:29 +0300 Subject: [PATCH 7/8] fix(core): make migration 043 down() idempotent too Match the .ifNotExists() hardening on up() so dropTable is safe to re-run after a partial rollback. Co-Authored-By: Claude Opus 4.8 --- .../core/src/database/migrations/043_content_references.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/database/migrations/043_content_references.ts b/packages/core/src/database/migrations/043_content_references.ts index 0a3b17063..3931ee6b3 100644 --- a/packages/core/src/database/migrations/043_content_references.ts +++ b/packages/core/src/database/migrations/043_content_references.ts @@ -102,6 +102,6 @@ export async function up(db: Kysely): Promise { } export async function down(db: Kysely): Promise { - await db.schema.dropTable("_emdash_content_references").execute(); - await db.schema.dropTable("_emdash_relations").execute(); + await db.schema.dropTable("_emdash_content_references").ifExists().execute(); + await db.schema.dropTable("_emdash_relations").ifExists().execute(); } From dbbf5dcf44159d6c51ef2f49df04da9c50cd5f5c Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:02:31 +0300 Subject: [PATCH 8/8] fix(core): enforce relation translation_group invariant; align index names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #1367 review: - Make _emdash_relations.translation_group NOT NULL. Edges reference relations by translation_group (relation_group is NOT NULL), so a null group is an unreferenceable dead row. New table, no backfill window. - Add a unique index on (translation_group, locale) — one relation variant per locale. Plain (not partial like bylines' 040) since the column is now NOT NULL. - Align index names with the idx_{full_table}_{column} convention used by 036/040/042 (idx__emdash_*). - Bump changeset to minor (additive feature groundwork). Co-Authored-By: Claude Opus 4.8 --- .changeset/content-references-schema.md | 2 +- .../migrations/043_content_references.ts | 30 ++++++-- packages/core/src/database/types.ts | 2 +- .../database/content-references.test.ts | 73 +++++++++++++++++-- 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/.changeset/content-references-schema.md b/.changeset/content-references-schema.md index 42e833b5e..857f6a56d 100644 --- a/.changeset/content-references-schema.md +++ b/.changeset/content-references-schema.md @@ -1,5 +1,5 @@ --- -"emdash": patch +"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 index 3931ee6b3..914b08687 100644 --- a/packages/core/src/database/migrations/043_content_references.ts +++ b/packages/core/src/database/migrations/043_content_references.ts @@ -34,37 +34,51 @@ export async function up(db: Kysely): Promise { .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") + .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_relations_locale") + .createIndex("idx__emdash_relations_locale") .ifNotExists() .on("_emdash_relations") .column("locale") .execute(); await db.schema - .createIndex("idx_relations_translation_group") + .createIndex("idx__emdash_relations_translation_group") .ifNotExists() .on("_emdash_relations") .column("translation_group") .execute(); await db.schema - .createIndex("idx_relations_parent_collection") + .createIndex("idx__emdash_relations_parent_collection") .ifNotExists() .on("_emdash_relations") .column("parent_collection") .execute(); await db.schema - .createIndex("idx_relations_child_collection") + .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() @@ -82,19 +96,19 @@ export async function up(db: Kysely): Promise { .execute(); await db.schema - .createIndex("idx_content_references_parent") + .createIndex("idx__emdash_content_references_parent") .ifNotExists() .on("_emdash_content_references") .columns(["parent_group", "relation_group", "sort_order"]) .execute(); await db.schema - .createIndex("idx_content_references_child") + .createIndex("idx__emdash_content_references_child") .ifNotExists() .on("_emdash_content_references") .columns(["child_group", "relation_group"]) .execute(); await db.schema - .createIndex("idx_content_references_relation") + .createIndex("idx__emdash_content_references_relation") .ifNotExists() .on("_emdash_content_references") .column("relation_group") diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index cd8935f7f..9da9d9a6c 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -590,7 +590,7 @@ export interface RelationTable { parent_label: string; child_label: string; locale: Generated; - translation_group: string | null; + translation_group: string; created_at: Generated; updated_at: Generated; } diff --git a/packages/core/tests/integration/database/content-references.test.ts b/packages/core/tests/integration/database/content-references.test.ts index 34da912ce..7e3059991 100644 --- a/packages/core/tests/integration/database/content-references.test.ts +++ b/packages/core/tests/integration/database/content-references.test.ts @@ -128,6 +128,64 @@ describeEachDialect("Content references schema", (dialect) => { 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 @@ -184,13 +242,14 @@ describeEachDialect("Content references schema", (dialect) => { const names = new Set(result.rows.map((r) => r.name)); for (const idx of [ - "idx_relations_locale", - "idx_relations_translation_group", - "idx_relations_parent_collection", - "idx_relations_child_collection", - "idx_content_references_parent", - "idx_content_references_child", - "idx_content_references_relation", + "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); }