chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
@@ -0,0 +1,141 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDatabase } from "../../../../src/database/connection.js";
import { down, up } from "../../../../src/database/migrations/016_api_tokens.js";
import type { Database } from "../../../../src/database/types.js";
describe("016_api_tokens migration", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = createDatabase({ url: ":memory:" });
// 016 has FKs to `users` (id), so the prerequisite table must exist.
await db.schema
.createTable("users")
.addColumn("id", "text", (col) => col.primaryKey())
.execute();
});
afterEach(async () => {
await db.destroy();
});
it("creates api token, oauth token, and device code tables", async () => {
await up(db);
const tables = await db.introspection.getTables();
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("_emdash_api_tokens");
expect(tableNames).toContain("_emdash_oauth_tokens");
expect(tableNames).toContain("_emdash_device_codes");
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'index'
`.execute(db);
const indexNames = indexes.rows.map((r) => r.name);
expect(indexNames).toContain("idx_api_tokens_token_hash");
expect(indexNames).toContain("idx_api_tokens_user_id");
expect(indexNames).toContain("idx_oauth_tokens_user_id");
expect(indexNames).toContain("idx_oauth_tokens_expires");
});
it("reverts all added tables", async () => {
await up(db);
await down(db);
const tables = await db.introspection.getTables();
const tableNames = tables.map((t) => t.name);
expect(tableNames).not.toContain("_emdash_api_tokens");
expect(tableNames).not.toContain("_emdash_oauth_tokens");
expect(tableNames).not.toContain("_emdash_device_codes");
});
// Regression test for #954: when an earlier attempt at `up()` partially
// completed (e.g. the first CREATE TABLE succeeded but a subsequent
// statement crashed with a D1 subrequest limit, isolate cancellation,
// or transient connection error), the migration record never got
// inserted. On the next request Kysely sees 016 still pending and
// re-runs `up()` from the top, which previously crashed with
// `table "_emdash_api_tokens" already exists` and blocked every
// subsequent boot.
//
// `up()` must therefore be safe to re-run against a partially-applied
// schema: any tables/indexes already in place are treated as no-ops and
// the remaining ones are created normally.
describe("re-run safety after a partially-applied migration (#954)", () => {
it("is a no-op when every table and index has already been created", async () => {
await up(db);
// Second run must not throw — this is the exact symptom users hit
// on Cloudflare Workers + D1 when 016 retried.
await expect(up(db)).resolves.not.toThrow();
// Schema is still intact.
const tables = await db.introspection.getTables();
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("_emdash_api_tokens");
expect(tableNames).toContain("_emdash_oauth_tokens");
expect(tableNames).toContain("_emdash_device_codes");
});
it("recovers when only the first tables were created before the crash", async () => {
// Simulate a partial-apply state. Running `up()` once sets up
// `_emdash_api_tokens` with the *exact* schema 016 produces;
// dropping the trailing tables that 016 also creates leaves
// the database in the same shape it would be in if `up()`
// had crashed midway through.
//
// We deliberately reuse the migration's own definition for
// `_emdash_api_tokens` rather than hand-rolling one — a
// hand-rolled copy drifts from the migration silently if the
// schema ever changes, so the test would still pass while
// asserting nothing meaningful.
await up(db);
await db.schema.dropTable("_emdash_device_codes").execute();
await db.schema.dropTable("_emdash_oauth_tokens").execute();
await expect(up(db)).resolves.not.toThrow();
const tables = await db.introspection.getTables();
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("_emdash_api_tokens");
expect(tableNames).toContain("_emdash_oauth_tokens");
expect(tableNames).toContain("_emdash_device_codes");
// Recovered schema must preserve 016's constraints, not just
// table existence — a `CREATE TABLE IF NOT EXISTS` against a
// pre-existing table is a no-op even if the new definition
// differs, so we check the columns and constraints survived.
const apiTokensTable = tables.find((t) => t.name === "_emdash_api_tokens");
expect(apiTokensTable).toBeDefined();
const columnNames = apiTokensTable?.columns.map((c) => c.name) ?? [];
expect(columnNames).toEqual(
expect.arrayContaining([
"id",
"name",
"token_hash",
"prefix",
"user_id",
"scopes",
"expires_at",
"last_used_at",
"created_at",
]),
);
// Indexes that 016 owns must also still be in place, including
// the ones that the retry was responsible for re-creating.
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'index'
`.execute(db);
const indexNames = indexes.rows.map((r) => r.name);
expect(indexNames).toContain("idx_api_tokens_token_hash");
expect(indexNames).toContain("idx_api_tokens_user_id");
expect(indexNames).toContain("idx_oauth_tokens_user_id");
expect(indexNames).toContain("idx_oauth_tokens_expires");
});
});
});
@@ -0,0 +1,74 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDatabase } from "../../../../src/database/connection.js";
import { down, up } from "../../../../src/database/migrations/031_bylines.js";
import type { Database } from "../../../../src/database/types.js";
describe("031_bylines migration", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = createDatabase({ url: ":memory:" });
await db.schema
.createTable("users")
.addColumn("id", "text", (col) => col.primaryKey())
.execute();
await db.schema
.createTable("media")
.addColumn("id", "text", (col) => col.primaryKey())
.execute();
await db.schema
.createTable("ec_posts")
.addColumn("id", "text", (col) => col.primaryKey())
.execute();
});
afterEach(async () => {
await db.destroy();
});
it("adds byline tables and primary_byline_id to existing content tables", async () => {
await up(db);
const tables = await db.introspection.getTables();
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("_emdash_bylines");
expect(tableNames).toContain("_emdash_content_bylines");
const contentTable = tables.find((t) => t.name === "ec_posts");
expect(contentTable).toBeDefined();
expect(contentTable?.columns.map((c) => c.name)).toContain("primary_byline_id");
const idx = await sql<{ name: string }>`
SELECT name
FROM sqlite_master
WHERE type = 'index' AND name = 'idx_ec_posts_primary_byline'
`.execute(db);
expect(idx.rows).toHaveLength(1);
});
it("reverts added tables, indexes, and columns", async () => {
await up(db);
await down(db);
const tables = await db.introspection.getTables();
const tableNames = tables.map((t) => t.name);
expect(tableNames).not.toContain("_emdash_bylines");
expect(tableNames).not.toContain("_emdash_content_bylines");
const contentTable = tables.find((t) => t.name === "ec_posts");
expect(contentTable).toBeDefined();
expect(contentTable?.columns.map((c) => c.name)).not.toContain("primary_byline_id");
const idx = await sql<{ name: string }>`
SELECT name
FROM sqlite_master
WHERE type = 'index' AND name = 'idx_ec_posts_primary_byline'
`.execute(db);
expect(idx.rows).toHaveLength(0);
});
});
@@ -0,0 +1,671 @@
import BetterSqlite3 from "better-sqlite3";
import type { Kysely } from "kysely";
import { Kysely as KyselyCtor, SqliteDialect, sql } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDatabase } from "../../../../src/database/connection.js";
import { down, up } from "../../../../src/database/migrations/036_i18n_menus_and_taxonomies.js";
import type { Database } from "../../../../src/database/types.js";
import { setI18nConfig } from "../../../../src/i18n/config.js";
/**
* Build a Kysely instance backed by better-sqlite3 with foreign keys ON and
* `PRAGMA foreign_keys = OFF` made into a no-op. This simulates Cloudflare
* D1's behavior, where FKs are always enforced and the standard escape hatch
* is silently ignored. Used to verify regressions for #1021 — bugs that only
* surface when FK enforcement can't be turned off mid-transaction.
*/
function createD1LikeDatabase(): Kysely<Database> {
const sqlite = new BetterSqlite3(":memory:");
sqlite.pragma("foreign_keys = ON");
const originalPrepare = sqlite.prepare.bind(sqlite);
sqlite.prepare = ((source: string) => {
// Make `PRAGMA foreign_keys = OFF/ON` a no-op like D1 does. `defer_foreign_keys`
// is intentionally left intact (D1 honors it for deferred validation).
if (/^\s*PRAGMA\s+foreign_keys\s*=/i.test(source)) {
return originalPrepare("SELECT 1") as ReturnType<typeof originalPrepare>;
}
return originalPrepare(source);
}) as typeof sqlite.prepare;
const dialect = new SqliteDialect({ database: sqlite });
return new KyselyCtor<Database>({ dialect });
}
/**
* Seed the four pre-i18n tables that migration 036 widens, plus the support
* tables it reads (`_emdash_collections`, `ec_posts`). Mirrors the schema
* shape immediately before this migration runs in production.
*/
async function seedPreMigrationSchema(db: Kysely<Database>): Promise<void> {
await sql`
CREATE TABLE _emdash_menus (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`.execute(db);
await sql`
CREATE TABLE _emdash_menu_items (
id TEXT PRIMARY KEY,
menu_id TEXT NOT NULL,
parent_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL,
reference_collection TEXT,
reference_id TEXT,
custom_url TEXT,
label TEXT NOT NULL,
title_attr TEXT,
target TEXT,
css_classes TEXT,
created_at TEXT DEFAULT (datetime('now')),
CONSTRAINT menu_items_menu_fk FOREIGN KEY (menu_id)
REFERENCES _emdash_menus(id) ON DELETE CASCADE,
CONSTRAINT menu_items_parent_fk FOREIGN KEY (parent_id)
REFERENCES _emdash_menu_items(id) ON DELETE CASCADE
)
`.execute(db);
await sql`CREATE INDEX idx_menu_items_menu ON _emdash_menu_items(menu_id, sort_order)`.execute(
db,
);
await sql`CREATE INDEX idx_menu_items_parent ON _emdash_menu_items(parent_id)`.execute(db);
await sql`
CREATE TABLE taxonomies (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
label TEXT NOT NULL,
parent_id TEXT,
data TEXT,
UNIQUE(name, slug),
FOREIGN KEY (parent_id) REFERENCES taxonomies(id) ON DELETE SET NULL
)
`.execute(db);
await sql`
CREATE TABLE _emdash_taxonomy_defs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
label_singular TEXT,
hierarchical INTEGER DEFAULT 0,
collections TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
`.execute(db);
await sql`
CREATE TABLE content_taxonomies (
collection TEXT NOT NULL,
entry_id TEXT NOT NULL,
taxonomy_id TEXT NOT NULL,
PRIMARY KEY (collection, entry_id, taxonomy_id),
FOREIGN KEY (taxonomy_id) REFERENCES taxonomies(id) ON DELETE CASCADE
)
`.execute(db);
await sql`
CREATE TABLE _emdash_collections (
slug TEXT PRIMARY KEY
)
`.execute(db);
// translation_group is added to ec_* by migration 019; 036 reads it during remap.
await sql`
CREATE TABLE ec_posts (
id TEXT PRIMARY KEY,
locale TEXT NOT NULL DEFAULT 'en',
translation_group TEXT
)
`.execute(db);
}
describe("036_i18n_menus_and_taxonomies migration", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = createDatabase({ url: ":memory:" });
await seedPreMigrationSchema(db);
});
afterEach(async () => {
await db.destroy();
});
describe("up()", () => {
it("adds locale + translation_group to every widened table", async () => {
await up(db);
const tables = await db.introspection.getTables();
for (const name of [
"_emdash_menus",
"_emdash_menu_items",
"taxonomies",
"_emdash_taxonomy_defs",
]) {
const cols = tables.find((t) => t.name === name)?.columns.map((c) => c.name) ?? [];
expect(cols, `${name} should expose locale`).toContain("locale");
expect(cols, `${name} should expose translation_group`).toContain("translation_group");
}
});
it("creates locale + translation_group indexes on every widened table", async () => {
await up(db);
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'
`.execute(db);
const names = new Set(indexes.rows.map((r) => r.name));
for (const table of [
"_emdash_menus",
"_emdash_menu_items",
"taxonomies",
"_emdash_taxonomy_defs",
]) {
expect(names, `missing locale index for ${table}`).toContain(`idx_${table}_locale`);
expect(names, `missing translation_group index for ${table}`).toContain(
`idx_${table}_translation_group`,
);
}
// The taxonomies rebuild drops the table (and its indexes); it must
// recreate the parent index it inherited from 015 (regression #1665).
expect(names).toContain("idx_taxonomies_parent");
// The content_taxonomies rebuild likewise drops the table and its
// indexes; it must recreate the taxonomy_id index from 015.
expect(names).toContain("idx_content_taxonomies_term");
});
it("restores idx_content_taxonomies_term on a partial-apply retry (regression for #1701)", async () => {
// Simulate a first run that committed the content_taxonomies rebuild
// (FK stripped, old index dropped) but failed before recreating the
// index. D1 DDL auto-commits per statement, so the rebuild survives an
// up() that never resolved; the retry re-enters up() from the top with
// the FK already gone and the index still missing.
await db.destroy();
db = createDatabase({ url: ":memory:" });
await seedPreMigrationSchema(db);
await sql`DROP TABLE content_taxonomies`.execute(db);
await sql`
CREATE TABLE content_taxonomies (
collection TEXT NOT NULL,
entry_id TEXT NOT NULL,
taxonomy_id TEXT NOT NULL,
PRIMARY KEY (collection, entry_id, taxonomy_id)
)
`.execute(db);
// Sanity: the partial state has no FK and no term index.
const fks = await sql`PRAGMA foreign_key_list(content_taxonomies)`.execute(db);
expect(fks.rows.length).toBe(0);
await up(db);
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'content_taxonomies'
`.execute(db);
const names = new Set(indexes.rows.map((r) => r.name));
expect(names).toContain("idx_content_taxonomies_term");
});
it("backfills translation_group = id for pre-existing rows", async () => {
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute(
db,
);
await sql`INSERT INTO _emdash_menu_items (id, menu_id, type, label) VALUES ('mi1', 'm1', 'custom', 'Home')`.execute(
db,
);
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'news', 'News')`.execute(
db,
);
await sql`INSERT INTO _emdash_taxonomy_defs (id, name, label) VALUES ('d1', 'category', 'Categories')`.execute(
db,
);
await up(db);
const checks: Array<{ table: string; id: string }> = [
{ table: "_emdash_menus", id: "m1" },
{ table: "_emdash_menu_items", id: "mi1" },
{ table: "taxonomies", id: "t1" },
{ table: "_emdash_taxonomy_defs", id: "d1" },
];
for (const { table, id } of checks) {
const row = await sql<{ locale: string; translation_group: string | null }>`
SELECT locale, translation_group FROM ${sql.ref(table)} WHERE id = ${id}
`.execute(db);
expect(row.rows[0]?.locale, `${table} locale`).toBe("en");
expect(row.rows[0]?.translation_group, `${table} translation_group`).toBe(id);
}
});
it("widens the menu unique key from (name) to (name, locale)", async () => {
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute(
db,
);
await up(db);
// Same name, different locale must now be allowed.
await sql`
INSERT INTO _emdash_menus (id, name, label, locale, translation_group)
VALUES ('m2', 'main', 'Principal', 'es', 'm1')
`.execute(db);
// Same name and same locale must still conflict.
await expect(
sql`
INSERT INTO _emdash_menus (id, name, label, locale, translation_group)
VALUES ('m3', 'main', 'Other', 'es', 'm1')
`.execute(db),
).rejects.toThrow();
});
it("remaps content_taxonomies.taxonomy_id to taxonomies.translation_group", async () => {
// Two terms in different translation groups.
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'news', 'News')`.execute(
db,
);
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t2', 'category', 'sports', 'Sports')`.execute(
db,
);
await sql`INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) VALUES ('posts', 'p1', 't1')`.execute(
db,
);
await sql`INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) VALUES ('posts', 'p1', 't2')`.execute(
db,
);
await up(db);
const groups = await sql<{ taxonomy_id: string }>`
SELECT taxonomy_id FROM content_taxonomies WHERE entry_id = 'p1' ORDER BY taxonomy_id
`.execute(db);
// On a fresh install translation_group == id, so values look unchanged.
expect(groups.rows.map((r) => r.taxonomy_id).toSorted()).toEqual(["t1", "t2"]);
// FK to taxonomies.id is gone: insert via group whose row id differs.
await sql`
INSERT INTO taxonomies (id, name, slug, label, locale, translation_group)
VALUES ('t1-es', 'category', 'noticias', 'Noticias', 'es', 't1')
`.execute(db);
await sql`
INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id)
VALUES ('posts', 'p2', 't1')
`.execute(db);
const orphan = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM content_taxonomies WHERE entry_id = 'p2'
`.execute(db);
expect(Number(orphan.rows[0]?.count ?? 0)).toBe(1);
});
it("preserves taxonomy parent_id hierarchy on D1 (regression for #1021)", async () => {
// On D1 where `PRAGMA foreign_keys = OFF` is a no-op, dropping the old
// taxonomies table cascades ON DELETE SET NULL through the new table's
// self-FK on parent_id and flattens the hierarchy. Pointing the self-FK
// at `taxonomies_new` (rebound to `taxonomies` by SQLite's RENAME) avoids
// this. Run against a Kysely instance that mimics D1's locked-on FK
// enforcement.
await db.destroy();
db = createD1LikeDatabase();
await seedPreMigrationSchema(db);
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('news', 'category', 'news', 'News')`.execute(
db,
);
await sql`INSERT INTO taxonomies (id, name, slug, label, parent_id) VALUES ('tech', 'category', 'tech', 'Tech', 'news')`.execute(
db,
);
await up(db);
const child = await sql<{ parent_id: string | null }>`
SELECT parent_id FROM taxonomies WHERE id = 'tech'
`.execute(db);
expect(child.rows[0]?.parent_id).toBe("news");
});
it("preserves content_taxonomies rows on D1 (regression for #1021)", async () => {
// The original migration relied on PRAGMA foreign_keys = OFF to suppress
// the ON DELETE CASCADE on content_taxonomies.taxonomy_id when dropping
// the old taxonomies table. D1 ignores that PRAGMA, so the cascade fired
// and wiped all post-taxonomy associations. Run against a Kysely instance
// that mimics D1's locked-on FK enforcement.
await db.destroy();
db = createD1LikeDatabase();
await seedPreMigrationSchema(db);
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('news', 'category', 'news', 'News')`.execute(
db,
);
await sql`INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) VALUES ('posts', 'p1', 'news')`.execute(
db,
);
await up(db);
const count = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM content_taxonomies WHERE entry_id = 'p1'
`.execute(db);
expect(Number(count.rows[0]?.count ?? 0)).toBe(1);
});
it("preserves _emdash_menu_items rows on D1 (regression for #1021)", async () => {
// Same bug class as content_taxonomies: `_emdash_menu_items.menu_id`
// has `ON DELETE CASCADE` to `_emdash_menus(id)` from migration 005.
// When `rebuildMenus()` drops the old `_emdash_menus` on D1 the
// cascade fires and wipes all menu items (including nested children
// via the self-FK on parent_id). The fix rebuilds `_emdash_menu_items`
// first to physically strip both FKs.
await db.destroy();
db = createD1LikeDatabase();
await seedPreMigrationSchema(db);
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('main', 'main', 'Main')`.execute(
db,
);
await sql`
INSERT INTO _emdash_menu_items (id, menu_id, type, label)
VALUES ('top', 'main', 'custom', 'Top')
`.execute(db);
await sql`
INSERT INTO _emdash_menu_items (id, menu_id, parent_id, type, label)
VALUES ('child', 'main', 'top', 'custom', 'Child')
`.execute(db);
await up(db);
const count = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM _emdash_menu_items WHERE menu_id = 'main'
`.execute(db);
expect(Number(count.rows[0]?.count ?? 0)).toBe(2);
// Nested item still references its parent.
const child = await sql<{ parent_id: string | null }>`
SELECT parent_id FROM _emdash_menu_items WHERE id = 'child'
`.execute(db);
expect(child.rows[0]?.parent_id).toBe("top");
});
it("strips _emdash_menu_items FKs so the menus rebuild is safe", async () => {
// Defensive assertion: after up() runs, _emdash_menu_items must
// have no FKs to _emdash_menus, otherwise the down() rollback
// (which drops _emdash_menus before rebuilding menu_items) would
// re-trigger the same cascade on D1.
await db.destroy();
db = createD1LikeDatabase();
await seedPreMigrationSchema(db);
await up(db);
const fks = await sql<{ table: string }>`
PRAGMA foreign_key_list(_emdash_menu_items)
`.execute(db);
expect(fks.rows).toHaveLength(0);
});
it("remaps _emdash_menu_items.reference_id for content references", async () => {
await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db);
// Pre-existing post whose translation_group was minted by migration 019.
await sql`INSERT INTO ec_posts (id, locale, translation_group) VALUES ('post-1', 'en', 'group-1')`.execute(
db,
);
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute(
db,
);
await sql`
INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_collection, reference_id)
VALUES ('mi-content', 'm1', 'page', 'Home', 'posts', 'post-1')
`.execute(db);
await up(db);
const item = await sql<{ reference_id: string }>`
SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-content'
`.execute(db);
expect(item.rows[0]?.reference_id).toBe("group-1");
});
it("remaps _emdash_menu_items.reference_id for taxonomy references", async () => {
await sql`
INSERT INTO taxonomies (id, name, slug, label)
VALUES ('term-1', 'category', 'news', 'News')
`.execute(db);
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute(
db,
);
await sql`
INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_id)
VALUES ('mi-tax', 'm1', 'taxonomy', 'News', 'term-1')
`.execute(db);
await up(db);
const item = await sql<{ reference_id: string }>`
SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-tax'
`.execute(db);
// On a fresh install the remap is a no-op (translation_group == id).
expect(item.rows[0]?.reference_id).toBe("term-1");
});
it("leaves items with reference_collection NULL untouched (runtime fallback handles them)", async () => {
await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db);
await sql`INSERT INTO ec_posts (id, locale, translation_group) VALUES ('post-1', 'en', 'group-1')`.execute(
db,
);
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute(
db,
);
// Legacy item: type='post' with reference_collection NULL. We can't
// migrate without guessing the collection slug, so the value is left
// as the original row id — runtime fallback resolves it directly.
await sql`
INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_collection, reference_id)
VALUES ('mi-legacy', 'm1', 'post', 'Post', NULL, 'post-1')
`.execute(db);
await up(db);
const item = await sql<{ reference_id: string }>`
SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-legacy'
`.execute(db);
expect(item.rows[0]?.reference_id).toBe("post-1");
});
it("leaves menu items with non-resolving references untouched", async () => {
await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db);
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute(
db,
);
// reference_id points at a post that doesn't exist — should stay as-is.
await sql`
INSERT INTO _emdash_menu_items (id, menu_id, type, label, reference_collection, reference_id)
VALUES ('mi-orphan', 'm1', 'page', 'Ghost', 'posts', 'post-missing')
`.execute(db);
await up(db);
const item = await sql<{ reference_id: string | null }>`
SELECT reference_id FROM _emdash_menu_items WHERE id = 'mi-orphan'
`.execute(db);
expect(item.rows[0]?.reference_id).toBe("post-missing");
});
});
describe("down()", () => {
it("reverts cleanly on a single-locale install", async () => {
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Main')`.execute(
db,
);
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'news', 'News')`.execute(
db,
);
await up(db);
await down(db);
const tables = await db.introspection.getTables();
for (const name of [
"_emdash_menus",
"_emdash_menu_items",
"taxonomies",
"_emdash_taxonomy_defs",
]) {
const cols = tables.find((t) => t.name === name)?.columns.map((c) => c.name) ?? [];
expect(cols, `${name} should not retain locale after rollback`).not.toContain("locale");
expect(cols, `${name} should not retain translation_group after rollback`).not.toContain(
"translation_group",
);
}
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%_locale'
`.execute(db);
expect(indexes.rows).toHaveLength(0);
// Original rows survived the rollback.
const menu = await sql<{ name: string }>`
SELECT name FROM _emdash_menus WHERE id = 'm1'
`.execute(db);
expect(menu.rows[0]?.name).toBe("main");
});
it("refuses to rollback when non-default-locale rows exist", async () => {
await up(db);
await sql`
INSERT INTO _emdash_menus (id, name, label, locale, translation_group)
VALUES ('m-fr', 'main', 'Principal', 'fr', 'm-fr')
`.execute(db);
await expect(down(db)).rejects.toThrow(/non-default locale/i);
// Assertion fired before any destructive work — schema still post-up.
const cols = (await db.introspection.getTables())
.find((t) => t.name === "_emdash_menus")
?.columns.map((c) => c.name);
expect(cols).toContain("locale");
});
it("names the offending table in the rollback error", async () => {
await up(db);
await sql`
INSERT INTO taxonomies (id, name, slug, label, locale, translation_group)
VALUES ('t-es', 'category', 'noticias', 'Noticias', 'es', 't-es')
`.execute(db);
await expect(down(db)).rejects.toThrow(/taxonomies/);
});
it("preserves _emdash_menu_items rows on D1 rollback (regression for #1021)", async () => {
// Down() drops _emdash_menus before stripping locale from
// _emdash_menu_items. This must not cascade — up() already
// removed the FK that would otherwise wipe child rows.
await db.destroy();
db = createD1LikeDatabase();
await seedPreMigrationSchema(db);
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('main', 'main', 'Main')`.execute(
db,
);
await sql`
INSERT INTO _emdash_menu_items (id, menu_id, type, label)
VALUES ('top', 'main', 'custom', 'Top')
`.execute(db);
await up(db);
await down(db);
const count = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM _emdash_menu_items WHERE menu_id = 'main'
`.execute(db);
expect(Number(count.rows[0]?.count ?? 0)).toBe(1);
});
it("refuses to rollback when content_taxonomies has dangling rows", async () => {
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'news', 'News')`.execute(
db,
);
await sql`INSERT INTO content_taxonomies (collection, entry_id, taxonomy_id) VALUES ('posts', 'p1', 't1')`.execute(
db,
);
await up(db);
// Delete the taxonomy row, leaving content_taxonomies pointing at a
// translation_group with no taxonomies row. (Possible because up()
// removed the cascading FK.)
await sql`DELETE FROM taxonomies WHERE id = 't1'`.execute(db);
await expect(down(db)).rejects.toThrow(/content_taxonomies/);
// Assertion fired before any destructive work — locale columns still present.
const cols = (await db.introspection.getTables())
.find((t) => t.name === "taxonomies")
?.columns.map((c) => c.name);
expect(cols).toContain("locale");
});
});
describe("with non-default locale (defaultLocale='es')", () => {
beforeEach(() => {
setI18nConfig({ defaultLocale: "es", locales: ["es", "en"] });
});
afterEach(() => {
setI18nConfig(null);
});
it("backfills pre-existing rows with the configured defaultLocale", async () => {
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Principal')`.execute(
db,
);
await sql`INSERT INTO _emdash_menu_items (id, menu_id, type, label) VALUES ('mi1', 'm1', 'custom', 'Inicio')`.execute(
db,
);
await sql`INSERT INTO taxonomies (id, name, slug, label) VALUES ('t1', 'category', 'noticias', 'Noticias')`.execute(
db,
);
await sql`INSERT INTO _emdash_taxonomy_defs (id, name, label) VALUES ('d1', 'category', 'Categorías')`.execute(
db,
);
await up(db);
for (const table of [
"_emdash_menus",
"_emdash_menu_items",
"taxonomies",
"_emdash_taxonomy_defs",
]) {
const row = await sql<{ locale: string }>`
SELECT locale FROM ${sql.ref(table)} LIMIT 1
`.execute(db);
expect(row.rows[0]?.locale, `${table} should backfill with 'es'`).toBe("es");
}
});
it("rolls back cleanly when only defaultLocale rows exist", async () => {
await sql`INSERT INTO _emdash_menus (id, name, label) VALUES ('m1', 'main', 'Principal')`.execute(
db,
);
await up(db);
await expect(down(db)).resolves.not.toThrow();
const cols = (await db.introspection.getTables())
.find((t) => t.name === "_emdash_menus")
?.columns.map((c) => c.name);
expect(cols).not.toContain("locale");
});
it("blocks rollback when rows use a locale other than the configured default", async () => {
await up(db);
await sql`
INSERT INTO _emdash_menus (id, name, label, locale, translation_group)
VALUES ('m-en', 'main', 'Main', 'en', 'm-en')
`.execute(db);
await expect(down(db)).rejects.toThrow(/defaultLocale="es"/);
});
});
});
@@ -0,0 +1,339 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContentRepository } from "../../../../src/database/repositories/content.js";
import type { Database } from "../../../../src/database/types.js";
import { SchemaRegistry } from "../../../../src/schema/registry.js";
import { FTSManager } from "../../../../src/search/fts-manager.js";
import { setupTestDatabase, teardownTestDatabase } from "../../../utils/test-db.js";
/**
* Migration 039 rebuilds every FTS5 index with corruption-safe triggers.
*
* The "happy path" cases (fresh install with the fixed triggers in place)
* are covered by `tests/integration/search/fts-corruption.test.ts`. These
* tests exercise the migration directly against pre-fix state — the case
* a real upgrade hits.
*/
describe("migration 039: rebuild FTS5 triggers", () => {
let db: Kysely<Database>;
let registry: SchemaRegistry;
let repo: ContentRepository;
beforeEach(async () => {
db = await setupTestDatabase();
registry = new SchemaRegistry(db);
repo = new ContentRepository(db);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
/**
* Replace the FTS update + delete triggers on a collection with the
* broken pre-fix form so we can verify migration 039 fixes them.
*
* Mirrors exactly what `sqlite_master` looked like on every site that
* ran a pre-fix EmDash version: the contentless-table sync pattern
* (`DELETE FROM "<fts>" WHERE rowid = OLD.rowid`) installed on what is
* actually an external-content FTS5 table.
*/
async function installPreFixTriggers(collectionSlug: string, fields: string[]): Promise<void> {
const ftsTable = `_emdash_fts_${collectionSlug}`;
const contentTable = `ec_${collectionSlug}`;
const fieldList = fields.join(", ");
const newFieldList = fields.map((f) => `NEW.${f}`).join(", ");
await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_update"`).execute(db);
await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_delete"`).execute(db);
await sql
.raw(`
CREATE TRIGGER "${ftsTable}_update"
AFTER UPDATE ON "${contentTable}"
BEGIN
DELETE FROM "${ftsTable}" WHERE rowid = OLD.rowid;
INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
SELECT NEW.rowid, NEW.id, NEW.locale, ${newFieldList}
WHERE NEW.deleted_at IS NULL;
END
`)
.execute(db);
await sql
.raw(`
CREATE TRIGGER "${ftsTable}_delete"
AFTER DELETE ON "${contentTable}"
BEGIN
DELETE FROM "${ftsTable}" WHERE rowid = OLD.rowid;
END
`)
.execute(db);
}
async function setupSearchEnabledPages(): Promise<void> {
await registry.createCollection({
slug: "pages",
label: "Pages",
labelSingular: "Page",
supports: ["drafts", "revisions", "search"],
});
await registry.createField("pages", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await registry.createField("pages", {
slug: "body",
label: "Body",
type: "text",
searchable: true,
});
const fts = new FTSManager(db);
await fts.enableSearch("pages");
}
async function runMigration039(): Promise<void> {
const { up } = await import("../../../../src/database/migrations/039_fix_fts5_triggers.js");
await up(db as unknown as Kysely<unknown>);
}
/**
* Count FTS5 matches for a single term. We use raw SQL (rather than
* `searchWithDb`) because the corruption test inspects the inverted
* index directly to prove stale tokens are gone after migration.
*/
async function fts5Matches(
conn: Kysely<Database>,
ftsTable: string,
term: string,
): Promise<number> {
const result = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM ${sql.ref(ftsTable)}
WHERE ${sql.ref(ftsTable)} MATCH ${term}
`.execute(conn);
return Number(result.rows[0]?.count ?? 0);
}
it("is a no-op on databases with no search-enabled collections", async () => {
// Empty DB — just the system tables, no `_emdash_collections` rows
// with a `search_config`. Migration must not throw.
await expect(runMigration039()).resolves.toBeUndefined();
});
it("rebuilds the FTS index for every search-enabled collection", async () => {
await setupSearchEnabledPages();
const created = await repo.create({
type: "pages",
slug: "about",
status: "published",
publishedAt: new Date().toISOString(),
data: { title: "About", body: "Some searchable body text." },
});
// Simulate the pre-fix state: broken triggers + a published row.
// The legacy triggers are functional on INSERT (the contentless and
// external-content forms agree there), so the row is in the index
// at this point. The migration must replace the triggers without
// losing that row.
await installPreFixTriggers("pages", ["title", "body"]);
await runMigration039();
// New triggers in place (no `DELETE FROM "<fts>" WHERE rowid = OLD.rowid`).
const triggers = await sql<{ name: string; sql: string }>`
SELECT name, sql FROM sqlite_master
WHERE type = 'trigger' AND tbl_name = 'ec_pages'
`.execute(db);
for (const trigger of triggers.rows) {
expect(trigger.sql).not.toMatch(/DELETE\s+FROM\s+"_emdash_fts_pages"\s+WHERE\s+rowid/i);
}
// The new triggers use the `'delete'` command.
const updateTrigger = triggers.rows.find((r) => r.name === "_emdash_fts_pages_update");
expect(updateTrigger?.sql).toMatch(/'delete'/);
// Content row is still in the index after rebuild.
const docsize = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM "_emdash_fts_pages_docsize"
`.execute(db);
expect(docsize.rows[0]?.count).toBe(1);
// And an edit + publish on the rebuilt index does not corrupt it
// (the regression PR #768 was originally trying to fix).
await repo.update("pages", created.id, {
data: { title: "About v2", body: "Revised body." },
});
await expect(
sql
.raw(`INSERT INTO _emdash_fts_pages(_emdash_fts_pages) VALUES('integrity-check')`)
.execute(db),
).resolves.toBeDefined();
});
it("repairs a database whose FTS index is already corrupted from earlier mutations", async () => {
await setupSearchEnabledPages();
const created = await repo.create({
type: "pages",
slug: "corrupt-me",
status: "published",
publishedAt: new Date().toISOString(),
data: { title: "Corrupt me", body: "Original aardvark body." },
});
// Install the broken triggers and then *fire them* by issuing the
// kind of UPDATE the publish path does. The broken trigger's
// `DELETE FROM fts WHERE rowid = OLD.rowid` on an external-content
// table reads NEW values from the content table when removing
// tokens, so the OLD tokens are left behind in the inverted index
// even though the content table no longer holds them. The result
// is a stale-token leak: searches for words from the OLD body
// keep matching the (now updated) row, and segment metadata
// drifts out of sync until SQLite eventually surfaces it as
// SQLITE_CORRUPT_VTAB.
await installPreFixTriggers("pages", ["title", "body"]);
// Sanity check: the OLD content's unique token is indexed before
// the corrupting UPDATE.
const preUpdateOldTokenMatches = await fts5Matches(db, "_emdash_fts_pages", "aardvark");
expect(preUpdateOldTokenMatches).toBe(1);
try {
await sql`
UPDATE ec_pages SET title = 'Updated', body = 'Updated zebra body.' WHERE id = ${created.id}
`.execute(db);
} catch {
// Expected on some SQLite builds -- the corruption surfaces immediately.
}
// After the broken UPDATE on an external-content table, the OLD
// token ("aardvark", no longer present in the row) is still in
// the inverted index. This is the stale-token leak the migration
// must fix; assert it exists before running the migration so the
// post-migration assertion has teeth.
const postUpdateOldTokenMatches = await fts5Matches(db, "_emdash_fts_pages", "aardvark");
expect(postUpdateOldTokenMatches).toBe(1);
// Run the migration. This must succeed regardless of the corrupt state.
await runMigration039();
// After the migration the index is consistent: stale OLD token is
// gone, NEW token from the current row is present.
const repairedOldTokenMatches = await fts5Matches(db, "_emdash_fts_pages", "aardvark");
expect(repairedOldTokenMatches).toBe(0);
const repairedNewTokenMatches = await fts5Matches(db, "_emdash_fts_pages", "zebra");
expect(repairedNewTokenMatches).toBe(1);
// And the index itself is structurally clean.
await expect(
sql
.raw(`INSERT INTO _emdash_fts_pages(_emdash_fts_pages) VALUES('integrity-check')`)
.execute(db),
).resolves.toBeDefined();
const docsize = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM "_emdash_fts_pages_docsize"
`.execute(db);
expect(docsize.rows[0]?.count).toBe(1);
});
it("disables FTS cleanly when search_config.enabled is true but no fields are searchable", async () => {
// Edge case: search was enabled, then every searchable field was
// later unmarked, leaving an FTS table with no columns to index.
// The migration should drop the FTS table and flip search_config
// to enabled=false instead of recreating a useless index.
await registry.createCollection({
slug: "items",
label: "Items",
labelSingular: "Item",
supports: ["search"],
});
await registry.createField("items", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
const fts = new FTSManager(db);
await fts.enableSearch("items");
// Now unmark the field as searchable directly in the DB so the
// rebuild path is exercised with `searchableFields.length === 0`.
await sql`
UPDATE _emdash_fields SET searchable = 0
WHERE collection_id = (SELECT id FROM _emdash_collections WHERE slug = 'items')
`.execute(db);
await runMigration039();
const ftsTableExists = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM sqlite_master
WHERE type = 'table' AND name = '_emdash_fts_items'
`.execute(db);
expect(ftsTableExists.rows[0]?.count).toBe(0);
const config = await sql<{ search_config: string | null }>`
SELECT search_config FROM _emdash_collections WHERE slug = 'items'
`.execute(db);
const parsed = JSON.parse(config.rows[0]?.search_config ?? "{}");
expect(parsed.enabled).toBe(false);
});
it("ignores collections whose search_config has enabled=false", async () => {
await registry.createCollection({
slug: "drafts",
label: "Drafts",
labelSingular: "Draft",
supports: ["search"],
});
await registry.createField("drafts", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
// Explicitly disabled (the normal state for collections that
// declare `supports: ['search']` but the operator never flipped
// on). The migration should not touch them.
await sql`
UPDATE _emdash_collections SET search_config = '{"enabled":false}'
WHERE slug = 'drafts'
`.execute(db);
await expect(runMigration039()).resolves.toBeUndefined();
});
it("is safe to re-run", async () => {
await setupSearchEnabledPages();
await repo.create({
type: "pages",
slug: "idempotent",
status: "published",
publishedAt: new Date().toISOString(),
data: { title: "Idempotent", body: "Body." },
});
await runMigration039();
await runMigration039();
await runMigration039();
await expect(
sql
.raw(`INSERT INTO _emdash_fts_pages(_emdash_fts_pages) VALUES('integrity-check')`)
.execute(db),
).resolves.toBeDefined();
const docsize = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM "_emdash_fts_pages_docsize"
`.execute(db);
expect(docsize.rows[0]?.count).toBe(1);
});
});
@@ -0,0 +1,438 @@
import BetterSqlite3 from "better-sqlite3";
import type { Kysely } from "kysely";
import { Kysely as KyselyCtor, SqliteDialect, sql } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDatabase } from "../../../../src/database/connection.js";
import { down, up } from "../../../../src/database/migrations/040_byline_i18n.js";
import type { Database } from "../../../../src/database/types.js";
import { setI18nConfig } from "../../../../src/i18n/config.js";
/**
* Build a Kysely instance backed by better-sqlite3 with foreign keys ON and
* `PRAGMA foreign_keys = OFF` made into a no-op. This simulates Cloudflare
* D1's behavior, where FKs are always enforced and the standard escape hatch
* is silently ignored. Used to verify regressions for #1021 — bugs that only
* surface when FK enforcement can't be turned off mid-transaction.
*/
function createD1LikeDatabase(): Kysely<Database> {
const sqlite = new BetterSqlite3(":memory:");
sqlite.pragma("foreign_keys = ON");
const originalPrepare = sqlite.prepare.bind(sqlite);
sqlite.prepare = ((source: string) => {
if (/^\s*PRAGMA\s+foreign_keys\s*=/i.test(source)) {
return originalPrepare("SELECT 1") as ReturnType<typeof originalPrepare>;
}
return originalPrepare(source);
}) as typeof sqlite.prepare;
const dialect = new SqliteDialect({ database: sqlite });
return new KyselyCtor<Database>({ dialect });
}
/**
* Seed the byline tables in their pre-040 shape (matching migration 031's
* schema) plus the support tables 040 reads (`_emdash_collections`, `ec_*`,
* `users`, `media`).
*/
async function seedPreMigrationSchema(db: Kysely<Database>): Promise<void> {
await sql`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT
)
`.execute(db);
await sql`
CREATE TABLE media (
id TEXT PRIMARY KEY
)
`.execute(db);
await sql`
CREATE TABLE _emdash_bylines (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
bio TEXT,
avatar_media_id TEXT REFERENCES media(id) ON DELETE SET NULL,
website_url TEXT,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`.execute(db);
await sql`
CREATE UNIQUE INDEX idx_bylines_user_id_unique
ON _emdash_bylines (user_id) WHERE user_id IS NOT NULL
`.execute(db);
await sql`CREATE INDEX idx_bylines_slug ON _emdash_bylines(slug)`.execute(db);
await sql`CREATE INDEX idx_bylines_display_name ON _emdash_bylines(display_name)`.execute(db);
await sql`
CREATE TABLE _emdash_content_bylines (
id TEXT PRIMARY KEY,
collection_slug TEXT NOT NULL,
content_id TEXT NOT NULL,
byline_id TEXT NOT NULL REFERENCES _emdash_bylines(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
role_label TEXT,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(collection_slug, content_id, byline_id)
)
`.execute(db);
await sql`
CREATE INDEX idx_content_bylines_content
ON _emdash_content_bylines(collection_slug, content_id, sort_order)
`.execute(db);
await sql`
CREATE INDEX idx_content_bylines_byline
ON _emdash_content_bylines(byline_id)
`.execute(db);
await sql`
CREATE TABLE _emdash_collections (
slug TEXT PRIMARY KEY
)
`.execute(db);
await sql`
CREATE TABLE ec_posts (
id TEXT PRIMARY KEY,
primary_byline_id TEXT
)
`.execute(db);
}
describe("040_byline_i18n migration", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = createDatabase({ url: ":memory:" });
await seedPreMigrationSchema(db);
});
afterEach(async () => {
await db.destroy();
});
describe("up()", () => {
it("adds locale + translation_group to _emdash_bylines", async () => {
await up(db);
const tables = await db.introspection.getTables();
const cols =
tables.find((t) => t.name === "_emdash_bylines")?.columns.map((c) => c.name) ?? [];
expect(cols).toContain("locale");
expect(cols).toContain("translation_group");
});
it("creates locale + translation_group indexes on _emdash_bylines", async () => {
await up(db);
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'
`.execute(db);
const names = new Set(indexes.rows.map((r) => r.name));
expect(names).toContain("idx__emdash_bylines_locale");
expect(names).toContain("idx__emdash_bylines_translation_group");
});
it("backfills locale=defaultLocale and translation_group=id for pre-existing rows", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await up(db);
const row = await sql<{ locale: string; translation_group: string | null }>`
SELECT locale, translation_group FROM _emdash_bylines WHERE id = 'b1'
`.execute(db);
expect(row.rows[0]?.locale).toBe("en");
expect(row.rows[0]?.translation_group).toBe("b1");
});
it("widens the byline unique key from (slug) to (slug, locale)", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await up(db);
// Same slug, different locale must now be allowed.
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, locale, translation_group)
VALUES ('b2', 'jane', 'Jane Doe DE', 'de', 'b1')
`.execute(db);
// Same slug AND same locale still conflicts.
await expect(
sql`
INSERT INTO _emdash_bylines (id, slug, display_name, locale, translation_group)
VALUES ('b3', 'jane', 'Jane Other DE', 'de', 'b1')
`.execute(db),
).rejects.toThrow();
});
it("widens the user_id partial unique from (user_id) to (user_id, locale)", async () => {
await sql`INSERT INTO users (id) VALUES ('u1')`.execute(db);
await up(db);
// One byline per locale per user is allowed.
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, user_id, locale, translation_group)
VALUES ('b1', 'jane', 'Jane Doe', 'u1', 'en', 'b1')
`.execute(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, user_id, locale, translation_group)
VALUES ('b2', 'jane', 'Jane Doe DE', 'u1', 'de', 'b1')
`.execute(db);
// Two bylines for the same (user_id, locale) must still fail.
await expect(
sql`
INSERT INTO _emdash_bylines (id, slug, display_name, user_id, locale, translation_group)
VALUES ('b3', 'jane-alt', 'Jane Alt', 'u1', 'en', 'b3')
`.execute(db),
).rejects.toThrow();
});
it("drops the FK on _emdash_content_bylines.byline_id", async () => {
await up(db);
const fks = await sql<{ table: string }>`
PRAGMA foreign_key_list(_emdash_content_bylines)
`.execute(db);
expect(fks.rows).toHaveLength(0);
});
it("preserves _emdash_content_bylines rows on D1 (regression for #1021)", async () => {
// On D1 where `PRAGMA foreign_keys = OFF` is a no-op, dropping the
// old `_emdash_bylines` table would cascade ON DELETE CASCADE
// through the original FK and wipe credits. rebuildContentBylines
// strips the FK *before* `_emdash_bylines` is rebuilt so the drop
// can't cascade.
await db.destroy();
db = createD1LikeDatabase();
await seedPreMigrationSchema(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await sql`
INSERT INTO _emdash_content_bylines (id, collection_slug, content_id, byline_id)
VALUES ('cb1', 'posts', 'p1', 'b1')
`.execute(db);
await up(db);
const count = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM _emdash_content_bylines WHERE content_id = 'p1'
`.execute(db);
expect(Number(count.rows[0]?.count ?? 0)).toBe(1);
});
it("preserves ec_*.primary_byline_id semantically (row id == translation_group on fresh install)", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db);
await sql`
INSERT INTO ec_posts (id, primary_byline_id) VALUES ('p1', 'b1')
`.execute(db);
await up(db);
const row = await sql<{ primary_byline_id: string | null }>`
SELECT primary_byline_id FROM ec_posts WHERE id = 'p1'
`.execute(db);
// Value looks unchanged because translation_group == id on fresh
// install, but the semantics flip: this is now a translation_group
// reference, not a row id.
expect(row.rows[0]?.primary_byline_id).toBe("b1");
});
it("is idempotent (running twice is safe)", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await up(db);
await expect(up(db)).resolves.not.toThrow();
// Existing row survives both passes.
const row = await sql<{ locale: string; translation_group: string | null }>`
SELECT locale, translation_group FROM _emdash_bylines WHERE id = 'b1'
`.execute(db);
expect(row.rows[0]?.locale).toBe("en");
expect(row.rows[0]?.translation_group).toBe("b1");
});
it("leaves orphan primary_byline_id pointers untouched", async () => {
await sql`INSERT INTO _emdash_collections (slug) VALUES ('posts')`.execute(db);
// Post references a byline id that doesn't exist — should not be cleared.
await sql`
INSERT INTO ec_posts (id, primary_byline_id) VALUES ('p1', 'b-missing')
`.execute(db);
await up(db);
const row = await sql<{ primary_byline_id: string | null }>`
SELECT primary_byline_id FROM ec_posts WHERE id = 'p1'
`.execute(db);
expect(row.rows[0]?.primary_byline_id).toBe("b-missing");
});
});
describe("down()", () => {
it("reverts cleanly on a single-locale install", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await up(db);
await down(db);
const tables = await db.introspection.getTables();
const cols =
tables.find((t) => t.name === "_emdash_bylines")?.columns.map((c) => c.name) ?? [];
expect(cols).not.toContain("locale");
expect(cols).not.toContain("translation_group");
// Original row survived.
const row = await sql<{ slug: string; display_name: string }>`
SELECT slug, display_name FROM _emdash_bylines WHERE id = 'b1'
`.execute(db);
expect(row.rows[0]?.slug).toBe("jane");
expect(row.rows[0]?.display_name).toBe("Jane Doe");
// FK restored on _emdash_content_bylines.byline_id.
const fks = await sql<{ table: string }>`
PRAGMA foreign_key_list(_emdash_content_bylines)
`.execute(db);
expect(fks.rows.length).toBeGreaterThan(0);
});
it("refuses to rollback when non-default-locale rows exist", async () => {
await up(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, locale, translation_group)
VALUES ('b-fr', 'jane', 'Jeanne', 'fr', 'b-fr')
`.execute(db);
await expect(down(db)).rejects.toThrow(/non-default locale/i);
// Assertion fired before any destructive work — schema still post-up.
const cols = (await db.introspection.getTables())
.find((t) => t.name === "_emdash_bylines")
?.columns.map((c) => c.name);
expect(cols).toContain("locale");
});
it("refuses to rollback when _emdash_content_bylines has dangling rows", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await sql`
INSERT INTO _emdash_content_bylines (id, collection_slug, content_id, byline_id)
VALUES ('cb1', 'posts', 'p1', 'b1')
`.execute(db);
await up(db);
// Delete the byline row, leaving _emdash_content_bylines pointing at a
// translation_group with no anchor row. (Possible because up()
// removed the cascading FK.)
await sql`DELETE FROM _emdash_bylines WHERE id = 'b1'`.execute(db);
await expect(down(db)).rejects.toThrow(/_emdash_content_bylines/);
// Assertion fired before any destructive work.
const cols = (await db.introspection.getTables())
.find((t) => t.name === "_emdash_bylines")
?.columns.map((c) => c.name);
expect(cols).toContain("locale");
});
it("preserves _emdash_content_bylines rows on D1 rollback (regression for #1021)", async () => {
// down() rebuilds _emdash_bylines before restoring the FK on
// _emdash_content_bylines. The intermediate state must not
// cascade — neither table has an FK pointing at the other while
// the rebuild is in flight.
await db.destroy();
db = createD1LikeDatabase();
await seedPreMigrationSchema(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await sql`
INSERT INTO _emdash_content_bylines (id, collection_slug, content_id, byline_id)
VALUES ('cb1', 'posts', 'p1', 'b1')
`.execute(db);
await up(db);
await down(db);
const count = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM _emdash_content_bylines WHERE content_id = 'p1'
`.execute(db);
expect(Number(count.rows[0]?.count ?? 0)).toBe(1);
});
});
describe("with non-default locale (defaultLocale='es')", () => {
beforeEach(() => {
setI18nConfig({ defaultLocale: "es", locales: ["es", "en"] });
});
afterEach(() => {
setI18nConfig(null);
});
it("backfills pre-existing rows with the configured defaultLocale", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await up(db);
const row = await sql<{ locale: string }>`
SELECT locale FROM _emdash_bylines WHERE id = 'b1'
`.execute(db);
expect(row.rows[0]?.locale).toBe("es");
});
it("rolls back cleanly when only defaultLocale rows exist", async () => {
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name)
VALUES ('b1', 'jane', 'Jane Doe')
`.execute(db);
await up(db);
await expect(down(db)).resolves.not.toThrow();
const cols = (await db.introspection.getTables())
.find((t) => t.name === "_emdash_bylines")
?.columns.map((c) => c.name);
expect(cols).not.toContain("locale");
});
it("blocks rollback when rows use a locale other than the configured default", async () => {
await up(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, locale, translation_group)
VALUES ('b-en', 'jane', 'Jane Doe', 'en', 'b-en')
`.execute(db);
await expect(down(db)).rejects.toThrow(/defaultLocale="es"/);
});
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
/**
* The locale-aware list indexes (migration 041 / schema/registry.ts) are named
* `idx_{table}_loc_upd` and `idx_{table}_loc_crt` where `table = ec_{slug}`.
* Postgres truncates identifiers to 63 bytes, so the names must keep the
* `upd`/`crt` discriminator inside the first 63 bytes for the longest slugs we
* accept; otherwise both names truncate to the same identifier and the second
* index either silently disappears (migration's `IF NOT EXISTS`) or hard-fails
* collection creation (registry's plain `CREATE INDEX`).
*
* These names must stay identical between the two sources. The earlier
* `deleted_locale_updated_id` / `deleted_locale_created_id` form collided from
* slugs as short as 40 chars.
*/
function localeUpdatedIndexName(table: string): string {
return `idx_${table}_loc_upd`;
}
function localeCreatedIndexName(table: string): string {
return `idx_${table}_loc_crt`;
}
function truncateToPostgresIdentifier(name: string): string {
return Buffer.from(name, "utf8").subarray(0, 63).toString("utf8");
}
describe("041 locale list index names", () => {
it("stay <=63 bytes and distinct after Postgres truncation for a 47-char slug", () => {
const table = `ec_${"a".repeat(47)}`;
const updated = localeUpdatedIndexName(table);
const created = localeCreatedIndexName(table);
expect(Buffer.byteLength(updated, "utf8")).toBeLessThanOrEqual(63);
expect(Buffer.byteLength(created, "utf8")).toBeLessThanOrEqual(63);
expect(truncateToPostgresIdentifier(updated)).not.toBe(truncateToPostgresIdentifier(created));
});
it("stay distinct after truncation for every slug length up to 50 chars", () => {
for (let slugLength = 1; slugLength <= 50; slugLength++) {
const table = `ec_${"a".repeat(slugLength)}`;
const updated = truncateToPostgresIdentifier(localeUpdatedIndexName(table));
const created = truncateToPostgresIdentifier(localeCreatedIndexName(table));
expect(updated, `slug length ${slugLength}`).not.toBe(created);
}
});
});
@@ -0,0 +1,361 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDatabase } from "../../../../src/database/connection.js";
import { up as up040 } from "../../../../src/database/migrations/040_byline_i18n.js";
import { down, up } from "../../../../src/database/migrations/042_byline_fields.js";
import type { Database } from "../../../../src/database/types.js";
/**
* Seed the pre-042 schema: byline tables in their post-040 shape, plus the
* `options` table the version-counter row writes to. 042 doesn't touch
* `_emdash_content_bylines` or `ec_*`, but 040 needs the support tables to
* apply — set them up so the "applies on a #1146-era DB" case is exercised
* the same way as a real upgrade.
*/
async function seedPostMigration040Schema(db: Kysely<Database>): Promise<void> {
await sql`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT
)
`.execute(db);
await sql`
CREATE TABLE media (
id TEXT PRIMARY KEY
)
`.execute(db);
await sql`
CREATE TABLE options (
name TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`.execute(db);
// _emdash_bylines in its pre-040 shape; up040 will widen it.
await sql`
CREATE TABLE _emdash_bylines (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
bio TEXT,
avatar_media_id TEXT REFERENCES media(id) ON DELETE SET NULL,
website_url TEXT,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`.execute(db);
await sql`
CREATE UNIQUE INDEX idx_bylines_user_id_unique
ON _emdash_bylines (user_id) WHERE user_id IS NOT NULL
`.execute(db);
await sql`CREATE INDEX idx_bylines_slug ON _emdash_bylines(slug)`.execute(db);
await sql`CREATE INDEX idx_bylines_display_name ON _emdash_bylines(display_name)`.execute(db);
await sql`
CREATE TABLE _emdash_content_bylines (
id TEXT PRIMARY KEY,
collection_slug TEXT NOT NULL,
content_id TEXT NOT NULL,
byline_id TEXT NOT NULL REFERENCES _emdash_bylines(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
role_label TEXT,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(collection_slug, content_id, byline_id)
)
`.execute(db);
await sql`
CREATE INDEX idx_content_bylines_content
ON _emdash_content_bylines(collection_slug, content_id, sort_order)
`.execute(db);
await sql`
CREATE INDEX idx_content_bylines_byline
ON _emdash_content_bylines(byline_id)
`.execute(db);
await sql`
CREATE TABLE _emdash_collections (
slug TEXT PRIMARY KEY
)
`.execute(db);
await up040(db);
}
async function tableNames(db: Kysely<Database>): Promise<Set<string>> {
const tables = await db.introspection.getTables();
return new Set(tables.map((t) => t.name));
}
async function indexNames(db: Kysely<Database>): Promise<Set<string>> {
const rows = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'
`.execute(db);
return new Set(rows.rows.map((r) => r.name));
}
async function getVersionRow(db: Kysely<Database>): Promise<string | null> {
const row = await sql<{ value: string }>`
SELECT value FROM options WHERE name = 'byline_fields_version'
`.execute(db);
return row.rows[0]?.value ?? null;
}
describe("042_byline_fields migration", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = createDatabase({ url: ":memory:" });
await seedPostMigration040Schema(db);
});
afterEach(async () => {
await db.destroy();
});
describe("up()", () => {
it("creates the three byline-field tables", async () => {
await up(db);
const names = await tableNames(db);
expect(names).toContain("_emdash_byline_fields");
expect(names).toContain("_emdash_byline_field_values");
expect(names).toContain("_emdash_byline_field_group_values");
});
it("creates expected indexes", async () => {
await up(db);
const names = await indexNames(db);
expect(names).toContain("idx__emdash_byline_fields_sort_order");
expect(names).toContain("idx__emdash_byline_field_values_byline");
expect(names).toContain("idx__emdash_byline_field_values_field");
expect(names).toContain("idx__emdash_byline_field_group_values_group");
expect(names).toContain("idx__emdash_byline_field_group_values_field");
});
it("inserts the byline_fields_version counter into options", async () => {
await up(db);
// `options.value` stores JSON; the initial counter is the JSON
// literal `0`. Phase 3's cache parses with JSON.parse and reads
// it as `number 0`.
expect(await getVersionRow(db)).toBe("0");
});
it("_emdash_byline_fields enforces slug uniqueness", async () => {
await up(db);
await sql`
INSERT INTO _emdash_byline_fields (id, slug, label, type)
VALUES ('f1', 'job_title', 'Job title', 'string')
`.execute(db);
await expect(
sql`
INSERT INTO _emdash_byline_fields (id, slug, label, type)
VALUES ('f2', 'job_title', 'Other label', 'string')
`.execute(db),
).rejects.toThrow();
});
it("translatable defaults to 1 (per-locale storage is the default)", async () => {
await up(db);
await sql`
INSERT INTO _emdash_byline_fields (id, slug, label, type)
VALUES ('f1', 'job_title', 'Job title', 'string')
`.execute(db);
const row = await sql<{ translatable: number; required: number; sort_order: number }>`
SELECT translatable, required, sort_order FROM _emdash_byline_fields WHERE id = 'f1'
`.execute(db);
expect(row.rows[0]?.translatable).toBe(1);
expect(row.rows[0]?.required).toBe(0);
expect(row.rows[0]?.sort_order).toBe(0);
});
it("_emdash_byline_field_values composite PK rejects duplicate (byline_id, field_id)", async () => {
await up(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, locale, translation_group)
VALUES ('b1', 'jane', 'Jane Doe', 'en', 'b1')
`.execute(db);
await sql`
INSERT INTO _emdash_byline_fields (id, slug, label, type)
VALUES ('f1', 'job_title', 'Job title', 'string')
`.execute(db);
await sql`
INSERT INTO _emdash_byline_field_values (byline_id, field_id, value)
VALUES ('b1', 'f1', '"Editor"')
`.execute(db);
await expect(
sql`
INSERT INTO _emdash_byline_field_values (byline_id, field_id, value)
VALUES ('b1', 'f1', '"Senior Editor"')
`.execute(db),
).rejects.toThrow();
});
it("_emdash_byline_field_group_values composite PK rejects duplicate (translation_group, field_id)", async () => {
await up(db);
await sql`
INSERT INTO _emdash_byline_fields (id, slug, label, type, translatable)
VALUES ('f1', 'twitter_handle', 'Twitter', 'string', 0)
`.execute(db);
await sql`
INSERT INTO _emdash_byline_field_group_values (translation_group, field_id, value)
VALUES ('g1', 'f1', '"@jane"')
`.execute(db);
await expect(
sql`
INSERT INTO _emdash_byline_field_group_values (translation_group, field_id, value)
VALUES ('g1', 'f1', '"@other"')
`.execute(db),
).rejects.toThrow();
});
it("ON DELETE CASCADE: deleting a byline removes its translatable values", async () => {
await up(db);
await sql`PRAGMA foreign_keys = ON`.execute(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, locale, translation_group)
VALUES ('b1', 'jane', 'Jane Doe', 'en', 'b1')
`.execute(db);
await sql`
INSERT INTO _emdash_byline_fields (id, slug, label, type)
VALUES ('f1', 'job_title', 'Job title', 'string')
`.execute(db);
await sql`
INSERT INTO _emdash_byline_field_values (byline_id, field_id, value)
VALUES ('b1', 'f1', '"Editor"')
`.execute(db);
await sql`DELETE FROM _emdash_bylines WHERE id = 'b1'`.execute(db);
const remaining = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM _emdash_byline_field_values
`.execute(db);
expect(Number(remaining.rows[0]?.count ?? -1)).toBe(0);
});
it("ON DELETE CASCADE: deleting a field removes its translatable AND group-shared values", async () => {
await up(db);
await sql`PRAGMA foreign_keys = ON`.execute(db);
await sql`
INSERT INTO _emdash_bylines (id, slug, display_name, locale, translation_group)
VALUES ('b1', 'jane', 'Jane Doe', 'en', 'b1')
`.execute(db);
await sql`
INSERT INTO _emdash_byline_fields (id, slug, label, type)
VALUES ('f1', 'job_title', 'Job title', 'string')
`.execute(db);
await sql`
INSERT INTO _emdash_byline_field_values (byline_id, field_id, value)
VALUES ('b1', 'f1', '"Editor"')
`.execute(db);
await sql`
INSERT INTO _emdash_byline_field_group_values (translation_group, field_id, value)
VALUES ('b1', 'f1', '"@jane"')
`.execute(db);
await sql`DELETE FROM _emdash_byline_fields WHERE id = 'f1'`.execute(db);
const trCount = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM _emdash_byline_field_values
`.execute(db);
expect(Number(trCount.rows[0]?.count ?? -1)).toBe(0);
const grpCount = await sql<{ count: number }>`
SELECT COUNT(*) AS count FROM _emdash_byline_field_group_values
`.execute(db);
expect(Number(grpCount.rows[0]?.count ?? -1)).toBe(0);
});
it("is idempotent on partial re-application (table-exists guard)", async () => {
await up(db);
// Simulate a crashed partial prior run: tables exist but the
// version row is gone. `INSERT … ON CONFLICT DO NOTHING` should
// leave existing state untouched if present, restore if missing.
await sql`DELETE FROM options WHERE name = 'byline_fields_version'`.execute(db);
await expect(up(db)).resolves.not.toThrow();
expect(await getVersionRow(db)).toBe("0");
});
it("recreates missing indexes when a crash dropped them mid-up()", async () => {
// Realistic partial-failure scenario: CREATE TABLE landed in the
// failed pass, CREATE INDEX did not. A coarse table-level guard
// would skip the index on retry; per-statement `.ifNotExists()`
// on the createIndex calls keeps the migration retry-safe (Phase
// 1 AC: "applies cleanly … on a DB with bylines+content from
// #1146" includes the partial-failure case).
await up(db);
await sql`DROP INDEX idx__emdash_byline_field_values_byline`.execute(db);
await sql`DROP INDEX idx__emdash_byline_field_group_values_group`.execute(db);
await expect(up(db)).resolves.not.toThrow();
const names = await indexNames(db);
expect(names).toContain("idx__emdash_byline_field_values_byline");
expect(names).toContain("idx__emdash_byline_field_group_values_group");
});
it("preserves a non-zero version counter on re-application", async () => {
await up(db);
// Simulate post-cache-bump state: version has been incremented by
// a registry mutation (Phase 2). A second `up()` (recovery path)
// must not reset it.
await sql`
UPDATE options SET value = '5' WHERE name = 'byline_fields_version'
`.execute(db);
await up(db);
expect(await getVersionRow(db)).toBe("5");
});
});
describe("down()", () => {
it("drops all three tables and removes the version row", async () => {
await up(db);
await down(db);
const names = await tableNames(db);
expect(names).not.toContain("_emdash_byline_fields");
expect(names).not.toContain("_emdash_byline_field_values");
expect(names).not.toContain("_emdash_byline_field_group_values");
expect(await getVersionRow(db)).toBeNull();
});
it("up -> down -> up settles to the same state as a single up", async () => {
await up(db);
await down(db);
await up(db);
const names = await tableNames(db);
expect(names).toContain("_emdash_byline_fields");
expect(names).toContain("_emdash_byline_field_values");
expect(names).toContain("_emdash_byline_field_group_values");
expect(await getVersionRow(db)).toBe("0");
});
it("tolerates running on a partially-applied up() (missing tables)", async () => {
// No tables created — down() should still be a no-op rather than throw.
await expect(down(db)).resolves.not.toThrow();
});
});
});
@@ -0,0 +1,38 @@
import { PostgresDialect } from "kysely";
import { describe, expect, it } from "vitest";
import { FailFastPostgresDialect } from "../../../src/database/pg-migration-lock.js";
/**
* The dialect must change only `acquireMigrationLock`. If it dropped the
* adapter's capability flags, the Kysely Migrator would stop running
* migrations inside a transaction (supportsTransactionalDdl) — silently
* breaking rollback-on-failure. The lock behavior itself is covered by the
* Postgres integration tests (migration-lock-pg.test.ts).
*/
describe("FailFastPostgresDialect", () => {
it("preserves the stock adapter's capability flags", () => {
const stock = new PostgresDialect({ pool: {} as never }).createAdapter();
const adapter = new FailFastPostgresDialect({ pool: {} as never }).createAdapter();
expect(adapter.supportsTransactionalDdl).toBe(true);
expect(adapter.supportsTransactionalDdl).toBe(stock.supportsTransactionalDdl);
expect(adapter.supportsReturning).toBe(stock.supportsReturning);
expect(adapter.supportsCreateIfNotExists).toBe(stock.supportsCreateIfNotExists ?? false);
expect(adapter.supportsMultipleConnections).toBe(stock.supportsMultipleConnections ?? true);
});
it("still identifies as PostgresAdapter for dialect detection", () => {
// detectDialect() (dialect-helpers.ts) matches on the adapter's
// constructor name; a differently-named adapter class would make
// every dialect helper emit SQLite SQL against Postgres.
const adapter = new FailFastPostgresDialect({ pool: {} as never }).createAdapter();
expect(adapter.constructor.name).toBe("PostgresAdapter");
});
it("remains a PostgresDialect for the public dialect type", () => {
// `emdash/db/postgres` publicly returns PostgresDialect; the fail-fast
// dialect must not narrow or break that type contract.
expect(new FailFastPostgresDialect({ pool: {} as never })).toBeInstanceOf(PostgresDialect);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,611 @@
import type { Kysely } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { ContentRepository } from "../../../../src/database/repositories/content.js";
import { RevisionRepository } from "../../../../src/database/repositories/revision.js";
import { EmDashValidationError } from "../../../../src/database/repositories/types.js";
import type { Database } from "../../../../src/database/types.js";
import { createPostFixture, createPageFixture } from "../../../utils/fixtures.js";
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js";
// Regex patterns for ID validation
const ULID_FORMAT_REGEX = /^[0-9A-Z]+$/i;
describe("ContentRepository", () => {
let db: Kysely<Database>;
let repo: ContentRepository;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
repo = new ContentRepository(db);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
describe("create()", () => {
it("should create content with valid data", async () => {
const input = createPostFixture();
const result = await repo.create(input);
expect(result).toBeDefined();
expect(result.id).toBeTruthy();
expect(result.type).toBe("post");
expect(result.slug).toBe("hello-world");
expect(result.status).toBe("draft");
expect(result.data).toEqual(input.data);
});
it("should generate ULID for ID", async () => {
const input = createPostFixture();
const result = await repo.create(input);
// ULID is 26 characters long
expect(result.id).toHaveLength(26);
// ULID starts with timestamp (base32) - should be alphanumeric
expect(result.id).toMatch(ULID_FORMAT_REGEX);
});
it("should set default status to draft", async () => {
const input = createPostFixture();
delete (input as any).status;
const result = await repo.create(input);
expect(result.status).toBe("draft");
});
it("should throw validation error when type is missing", async () => {
const input = createPostFixture();
delete (input as any).type;
await expect(repo.create(input)).rejects.toThrow(EmDashValidationError);
});
it("should allow creating content without slug", async () => {
const input = createPostFixture();
delete (input as any).slug;
const result = await repo.create(input);
expect(result.slug).toBeNull();
});
it("should set createdAt and updatedAt timestamps", async () => {
const input = createPostFixture();
const result = await repo.create(input);
expect(result.createdAt).toBeTruthy();
expect(result.updatedAt).toBeTruthy();
});
it("should persist primaryBylineId on create", async () => {
const result = await repo.create(
createPostFixture({
slug: "with-primary-byline",
primaryBylineId: "byline_1",
}),
);
expect(result.primaryBylineId).toBe("byline_1");
});
});
describe("findById()", () => {
it("should return content by ID", async () => {
const input = createPostFixture();
const created = await repo.create(input);
const found = await repo.findById("post", created.id);
expect(found).toBeDefined();
expect(found?.id).toBe(created.id);
expect(found?.data).toEqual(created.data);
});
it("should return null for non-existent ID", async () => {
const found = await repo.findById("post", "01J9FAKE0000000000000000");
expect(found).toBeNull();
});
it("should exclude soft-deleted content", async () => {
const input = createPostFixture();
const created = await repo.create(input);
await repo.delete("post", created.id);
const found = await repo.findById("post", created.id);
expect(found).toBeNull();
});
it("should not return content of wrong type", async () => {
const input = createPostFixture();
const created = await repo.create(input);
const found = await repo.findById("page", created.id);
expect(found).toBeNull();
});
});
describe("findBySlug()", () => {
it("should return content by slug", async () => {
const input = createPostFixture({ slug: "test-slug" });
const created = await repo.create(input);
const found = await repo.findBySlug("post", "test-slug");
expect(found).toBeDefined();
expect(found?.id).toBe(created.id);
expect(found?.slug).toBe("test-slug");
});
it("should return null for non-existent slug", async () => {
const found = await repo.findBySlug("post", "non-existent");
expect(found).toBeNull();
});
it("should not return content of wrong type", async () => {
const input = createPostFixture({ slug: "test-slug" });
await repo.create(input);
const found = await repo.findBySlug("page", "test-slug");
expect(found).toBeNull();
});
});
describe("findMany()", () => {
it("should return all content of specified type", async () => {
await repo.create(createPostFixture({ slug: "post-1" }));
await repo.create(createPostFixture({ slug: "post-2" }));
await repo.create(createPageFixture({ slug: "page-1" }));
const result = await repo.findMany("post");
expect(result.items).toHaveLength(2);
expect(result.items.every((item) => item.type === "post")).toBe(true);
});
it("should filter by status", async () => {
await repo.create(createPostFixture({ slug: "draft", status: "draft" }));
await repo.create(createPostFixture({ slug: "published", status: "published" }));
const result = await repo.findMany("post", {
where: { status: "published" },
});
expect(result.items).toHaveLength(1);
expect(result.items[0].status).toBe("published");
});
it("should filter by authorId", async () => {
await repo.create(createPostFixture({ slug: "author1", authorId: "user1" }));
await repo.create(createPostFixture({ slug: "author2", authorId: "user2" }));
const result = await repo.findMany("post", {
where: { authorId: "user1" },
});
expect(result.items).toHaveLength(1);
expect(result.items[0].authorId).toBe("user1");
});
it("should support cursor pagination", async () => {
// Create multiple posts
for (let i = 1; i <= 5; i++) {
await repo.create(createPostFixture({ slug: `post-${i}` }));
}
// First page
const page1 = await repo.findMany("post", { limit: 2 });
expect(page1.items).toHaveLength(2);
expect(page1.nextCursor).toBeTruthy();
// Second page
const page2 = await repo.findMany("post", {
limit: 2,
cursor: page1.nextCursor,
});
expect(page2.items).toHaveLength(2);
expect(page2.nextCursor).toBeTruthy();
// Verify no overlap
const page1Ids = page1.items.map((i) => i.id);
const page2Ids = page2.items.map((i) => i.id);
expect(page1Ids).not.toContain(page2Ids[0]);
});
it("should support ordering", async () => {
// Create posts with specific dates
const post1 = await repo.create(createPostFixture({ slug: "old-post" }));
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const post2 = await repo.create(createPostFixture({ slug: "new-post" }));
// Default order (desc by createdAt)
const resultDesc = await repo.findMany("post", {
orderBy: { field: "createdAt", direction: "desc" },
});
expect(resultDesc.items[0].id).toBe(post2.id);
// Ascending order
const resultAsc = await repo.findMany("post", {
orderBy: { field: "createdAt", direction: "asc" },
});
expect(resultAsc.items[0].id).toBe(post1.id);
});
it("should respect limit", async () => {
for (let i = 1; i <= 10; i++) {
await repo.create(createPostFixture({ slug: `post-${i}` }));
}
const result = await repo.findMany("post", { limit: 5 });
expect(result.items).toHaveLength(5);
});
it("should exclude soft-deleted content", async () => {
const post1 = await repo.create(createPostFixture({ slug: "post-1" }));
await repo.create(createPostFixture({ slug: "post-2" }));
await repo.delete("post", post1.id);
const result = await repo.findMany("post");
expect(result.items).toHaveLength(1);
expect(result.items[0].slug).toBe("post-2");
});
});
describe("update()", () => {
it("should update content data", async () => {
const input = createPostFixture();
const created = await repo.create(input);
const updated = await repo.update("post", created.id, {
data: { title: "Updated Title", content: [] },
});
expect(updated.data).toEqual({ title: "Updated Title", content: [] });
});
it("should update status", async () => {
const input = createPostFixture();
const created = await repo.create(input);
const updated = await repo.update("post", created.id, {
status: "published",
});
expect(updated.status).toBe("published");
});
it("should update slug", async () => {
const input = createPostFixture();
const created = await repo.create(input);
const updated = await repo.update("post", created.id, {
slug: "new-slug",
});
expect(updated.slug).toBe("new-slug");
});
it("should update publishedAt timestamp", async () => {
const input = createPostFixture();
const created = await repo.create(input);
const publishedAt = new Date().toISOString();
const updated = await repo.update("post", created.id, {
publishedAt,
});
expect(updated.publishedAt).toBe(publishedAt);
});
it("should update updatedAt timestamp automatically", async () => {
const input = createPostFixture();
const created = await repo.create(input);
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await repo.update("post", created.id, {
data: { title: "Updated" },
});
expect(updated.updatedAt).not.toBe(created.updatedAt);
});
it("should throw error for non-existent content", async () => {
await expect(repo.update("post", "01J9FAKE0000000000000000", { data: {} })).rejects.toThrow(
"Content not found",
);
});
it("should update primaryBylineId", async () => {
const created = await repo.create(
createPostFixture({
slug: "update-primary-byline",
primaryBylineId: "byline_old",
}),
);
const updated = await repo.update("post", created.id, {
primaryBylineId: "byline_new",
});
expect(updated.primaryBylineId).toBe("byline_new");
});
});
describe("delete()", () => {
it("should soft delete content", async () => {
const input = createPostFixture();
const created = await repo.create(input);
const result = await repo.delete("post", created.id);
expect(result).toBe(true);
// Verify content is not returned by findById
const found = await repo.findById("post", created.id);
expect(found).toBeNull();
});
it("should return false for non-existent content", async () => {
const result = await repo.delete("post", "01J9FAKE0000000000000000");
expect(result).toBe(false);
});
it("should return false when deleting already deleted content", async () => {
const input = createPostFixture();
const created = await repo.create(input);
await repo.delete("post", created.id);
const result = await repo.delete("post", created.id);
expect(result).toBe(false);
});
});
describe("count()", () => {
it("should count all content of specified type", async () => {
await repo.create(createPostFixture({ slug: "post-1" }));
await repo.create(createPostFixture({ slug: "post-2" }));
await repo.create(createPageFixture({ slug: "page-1" }));
const count = await repo.count("post");
expect(count).toBe(2);
});
it("should count with status filter", async () => {
await repo.create(createPostFixture({ slug: "draft", status: "draft" }));
await repo.create(createPostFixture({ slug: "published", status: "published" }));
const count = await repo.count("post", { status: "published" });
expect(count).toBe(1);
});
it("should count with authorId filter", async () => {
await repo.create(createPostFixture({ slug: "author1", authorId: "user1" }));
await repo.create(createPostFixture({ slug: "author2", authorId: "user2" }));
const count = await repo.count("post", { authorId: "user1" });
expect(count).toBe(1);
});
it("should exclude soft-deleted content", async () => {
const post1 = await repo.create(createPostFixture({ slug: "post-1" }));
await repo.create(createPostFixture({ slug: "post-2" }));
await repo.delete("post", post1.id);
const count = await repo.count("post");
expect(count).toBe(1);
});
});
describe("schedule()", () => {
it("should set status to 'scheduled' for draft posts", async () => {
const post = await repo.create(createPostFixture());
const future = new Date(Date.now() + 86_400_000).toISOString();
const updated = await repo.schedule("post", post.id, future);
expect(updated.status).toBe("scheduled");
expect(updated.scheduledAt).toBe(future);
});
it("should keep status 'published' for published posts", async () => {
const post = await repo.create(createPostFixture());
await repo.publish("post", post.id);
const future = new Date(Date.now() + 86_400_000).toISOString();
const updated = await repo.schedule("post", post.id, future);
expect(updated.status).toBe("published");
expect(updated.scheduledAt).toBe(future);
});
it("should reject dates in the past", async () => {
const post = await repo.create(createPostFixture());
const past = new Date(Date.now() - 86_400_000).toISOString();
await expect(repo.schedule("post", post.id, past)).rejects.toThrow(EmDashValidationError);
});
it("should reject invalid date strings", async () => {
const post = await repo.create(createPostFixture());
await expect(repo.schedule("post", post.id, "not-a-date")).rejects.toThrow(
EmDashValidationError,
);
});
});
describe("unschedule()", () => {
it("should revert scheduled draft to 'draft'", async () => {
const post = await repo.create(createPostFixture());
const future = new Date(Date.now() + 86_400_000).toISOString();
await repo.schedule("post", post.id, future);
const updated = await repo.unschedule("post", post.id);
expect(updated.status).toBe("draft");
expect(updated.scheduledAt).toBeNull();
});
it("should keep published posts as 'published'", async () => {
const post = await repo.create(createPostFixture());
await repo.publish("post", post.id);
const future = new Date(Date.now() + 86_400_000).toISOString();
await repo.schedule("post", post.id, future);
const updated = await repo.unschedule("post", post.id);
expect(updated.status).toBe("published");
expect(updated.scheduledAt).toBeNull();
});
});
describe("setDraftRevision()", () => {
it("sets the draft_revision_id so publish() picks it up", async () => {
const post = await repo.create(createPostFixture());
const revisionRepo = new RevisionRepository(db);
const draft = await revisionRepo.create({
collection: "post",
entryId: post.id,
data: { ...post.data, title: "Staged for publish" },
});
await repo.setDraftRevision("post", post.id, draft.id);
const afterStaging = await repo.findById("post", post.id);
expect(afterStaging?.draftRevisionId).toBe(draft.id);
const published = await repo.publish("post", post.id);
expect(published.liveRevisionId).toBe(draft.id);
expect(published.draftRevisionId).toBeNull();
});
it("throws when the content item does not exist", async () => {
await expect(
repo.setDraftRevision("post", "01K0000000000000000000000", "01K0000000000000000000001"),
).rejects.toThrow(EmDashValidationError);
});
it("throws when the revision does not exist", async () => {
const post = await repo.create(createPostFixture());
await expect(
repo.setDraftRevision("post", post.id, "01K0000000000000000000001"),
).rejects.toThrow(EmDashValidationError);
});
it("throws when the revision belongs to a different content item", async () => {
const post1 = await repo.create(createPostFixture({ slug: "one" }));
const post2 = await repo.create(createPostFixture({ slug: "two" }));
const revisionRepo = new RevisionRepository(db);
const draft = await revisionRepo.create({
collection: "post",
entryId: post2.id,
data: post2.data,
});
await expect(repo.setDraftRevision("post", post1.id, draft.id)).rejects.toThrow(
EmDashValidationError,
);
});
});
describe("publish() clears schedule", () => {
it("should clear scheduled_at when publishing a scheduled draft", async () => {
const post = await repo.create(createPostFixture());
const future = new Date(Date.now() + 86_400_000).toISOString();
await repo.schedule("post", post.id, future);
const published = await repo.publish("post", post.id);
expect(published.status).toBe("published");
expect(published.scheduledAt).toBeNull();
});
it("should clear scheduled_at when publishing a published post with scheduled changes", async () => {
const post = await repo.create(createPostFixture());
await repo.publish("post", post.id);
const future = new Date(Date.now() + 86_400_000).toISOString();
await repo.schedule("post", post.id, future);
const republished = await repo.publish("post", post.id);
expect(republished.status).toBe("published");
expect(republished.scheduledAt).toBeNull();
});
});
describe("findReadyToPublish()", () => {
it("should find scheduled drafts past their time", async () => {
const post = await repo.create(createPostFixture());
// Schedule in the past by directly updating (schedule() rejects past dates)
const past = new Date(Date.now() - 60_000).toISOString();
await repo.update("post", post.id, { status: "scheduled", scheduledAt: past });
const ready = await repo.findReadyToPublish("post");
expect(ready).toHaveLength(1);
expect(ready[0]!.id).toBe(post.id);
});
it("should find published posts with past scheduled_at", async () => {
const post = await repo.create(createPostFixture());
await repo.publish("post", post.id);
// Set scheduled_at in the past directly
const past = new Date(Date.now() - 60_000).toISOString();
await repo.update("post", post.id, { scheduledAt: past });
const ready = await repo.findReadyToPublish("post");
expect(ready).toHaveLength(1);
expect(ready[0]!.id).toBe(post.id);
});
it("should not include items with future scheduled_at", async () => {
const post = await repo.create(createPostFixture());
const future = new Date(Date.now() + 86_400_000).toISOString();
await repo.schedule("post", post.id, future);
const ready = await repo.findReadyToPublish("post");
expect(ready).toHaveLength(0);
});
});
describe("countScheduled()", () => {
it("should count both scheduled drafts and published posts with scheduled_at", async () => {
// Draft with schedule
const draft = await repo.create(createPostFixture({ slug: "draft-scheduled" }));
const future1 = new Date(Date.now() + 86_400_000).toISOString();
await repo.schedule("post", draft.id, future1);
// Published with schedule
const pub = await repo.create(createPostFixture({ slug: "pub-scheduled" }));
await repo.publish("post", pub.id);
const future2 = new Date(Date.now() + 172_800_000).toISOString();
await repo.schedule("post", pub.id, future2);
// Unscheduled draft (should not be counted)
await repo.create(createPostFixture({ slug: "plain-draft" }));
const count = await repo.countScheduled("post");
expect(count).toBe(2);
});
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
decodeCursor,
encodeCursor,
InvalidCursorError,
} from "../../../../src/database/repositories/types.js";
describe("decodeCursor", () => {
it("round-trips a valid cursor", () => {
const cursor = encodeCursor("2024-01-01", "01ABC");
const decoded = decodeCursor(cursor);
expect(decoded).toEqual({ orderValue: "2024-01-01", id: "01ABC" });
});
it("throws InvalidCursorError on empty string", () => {
expect(() => decodeCursor("")).toThrow(InvalidCursorError);
});
it("throws InvalidCursorError on non-base64 input", () => {
expect(() => decodeCursor("not-base64-!!!")).toThrow(InvalidCursorError);
});
it("throws InvalidCursorError on base64 of malformed JSON", () => {
const bad = Buffer.from("{not valid json").toString("base64");
expect(() => decodeCursor(bad)).toThrow(InvalidCursorError);
});
it("throws InvalidCursorError on base64 JSON missing required fields", () => {
const bad = Buffer.from(JSON.stringify({ wrong: "shape" })).toString("base64");
expect(() => decodeCursor(bad)).toThrow(InvalidCursorError);
});
it("throws InvalidCursorError when id is not a string", () => {
const bad = Buffer.from(JSON.stringify({ orderValue: "x", id: 42 })).toString("base64");
expect(() => decodeCursor(bad)).toThrow(InvalidCursorError);
});
it("rejects oversized cursors before attempting to decode (DoS guard)", () => {
// MAX_CURSOR_LENGTH is 4096 inside the decoder. The MCP/REST schemas
// cap earlier (2048), but the decoder is the last line of defense
// for any caller that bypasses the schemas. A pre-decode rejection
// avoids allocating O(N) bytes for `decodeBase64` on a hostile
// input.
const huge = "A".repeat(5000);
expect(() => decodeCursor(huge)).toThrow(InvalidCursorError);
});
it("error message truncates very long cursors", () => {
const longish = "A".repeat(200);
try {
decodeCursor(longish);
expect.fail("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(InvalidCursorError);
// The truncation cap is 50; the message itself stays short.
expect((error as Error).message.length).toBeLessThan(120);
}
});
});
@@ -0,0 +1,41 @@
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MediaRepository } from "../../../../src/database/repositories/media.js";
import type { Database } from "../../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../../utils/test-db.js";
describe("MediaRepository.confirmUpload", () => {
let db: Kysely<Database>;
let repo: MediaRepository;
beforeEach(async () => {
db = await setupTestDatabase();
repo = new MediaRepository(db);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("persists blurhash and dominantColor when confirming a pending upload", async () => {
const pending = await repo.createPending({
filename: "x.jpg",
mimeType: "image/jpeg",
storageKey: "x.jpg",
});
const confirmed = await repo.confirmUpload(pending.id, {
width: 4,
height: 4,
size: 100,
blurhash: "LEHV6nWB2yk8",
dominantColor: "rgb(255,0,0)",
});
expect(confirmed?.status).toBe("ready");
expect(confirmed?.width).toBe(4);
expect(confirmed?.blurhash).toBe("LEHV6nWB2yk8");
expect(confirmed?.dominantColor).toBe("rgb(255,0,0)");
});
});
@@ -0,0 +1,114 @@
import type { Kysely } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { ContentRepository } from "../../../../src/database/repositories/content.js";
import { SeoRepository } from "../../../../src/database/repositories/seo.js";
import type { Database } from "../../../../src/database/types.js";
import { SQL_BATCH_SIZE } from "../../../../src/utils/chunks.js";
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js";
describe("SeoRepository", () => {
let db: Kysely<Database>;
let seoRepo: SeoRepository;
let contentRepo: ContentRepository;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
// Enable SEO on the post collection — createCollection defaults has_seo to 0.
await db
.updateTable("_emdash_collections")
.set({ has_seo: 1 })
.where("slug", "=", "post")
.execute();
seoRepo = new SeoRepository(db);
contentRepo = new ContentRepository(db);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("getMany handles more IDs than SQL_BATCH_SIZE", async () => {
// Create a few real content entries with SEO rows
const realIds: string[] = [];
for (let i = 0; i < 3; i++) {
const content = await contentRepo.create({
type: "post",
slug: `seo-batch-post-${i}`,
data: { title: `SEO Batch Post ${i}` },
});
await seoRepo.upsert("post", content.id, {
title: `SEO Title ${i}`,
description: `SEO Description ${i}`,
});
realIds.push(content.id);
}
// Build an ID list larger than SQL_BATCH_SIZE with real IDs spread across chunks
const ids: string[] = [];
for (let i = 0; i < SQL_BATCH_SIZE + 10; i++) {
ids.push(`fake-id-${i}`);
}
ids[0] = realIds[0]!;
ids[SQL_BATCH_SIZE - 1] = realIds[1]!;
ids[SQL_BATCH_SIZE + 5] = realIds[2]!;
const result = await seoRepo.getMany("post", ids);
// All input IDs should be present in the result Map
expect(result.size).toBe(ids.length);
// Real IDs should have their SEO data resolved
expect(result.get(realIds[0]!)?.title).toBe("SEO Title 0");
expect(result.get(realIds[1]!)?.title).toBe("SEO Title 1");
expect(result.get(realIds[2]!)?.title).toBe("SEO Title 2");
// Fake IDs should get default values
expect(result.get("fake-id-5")?.title).toBeNull();
expect(result.get("fake-id-5")?.description).toBeNull();
expect(result.get("fake-id-5")?.noIndex).toBe(false);
});
it("getMany returns defaults for every input id when no rows exist", async () => {
const ids: string[] = [];
for (let i = 0; i < SQL_BATCH_SIZE + 10; i++) {
ids.push(`missing-id-${i}`);
}
const result = await seoRepo.getMany("post", ids);
expect(result.size).toBe(ids.length);
for (const id of ids) {
const entry = result.get(id);
expect(entry).toBeDefined();
expect(entry?.title).toBeNull();
expect(entry?.description).toBeNull();
expect(entry?.image).toBeNull();
expect(entry?.canonical).toBeNull();
expect(entry?.noIndex).toBe(false);
}
});
it("getMany deduplicates repeated content IDs without duplicate rows", async () => {
const content = await contentRepo.create({
type: "post",
slug: "seo-duplicate-post",
data: { title: "SEO Duplicate" },
});
await seoRepo.upsert("post", content.id, {
title: "Duplicate SEO",
});
const ids: string[] = [];
for (let i = 0; i < SQL_BATCH_SIZE + 10; i++) {
ids.push(`fake-id-${i}`);
}
ids[0] = content.id;
ids[SQL_BATCH_SIZE + 5] = content.id;
const result = await seoRepo.getMany("post", ids);
// The real entry should resolve to its SEO row regardless of the duplicate input
expect(result.get(content.id)?.title).toBe("Duplicate SEO");
});
});