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
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user