Files
wehub-resource-sync b3a7f98e5a
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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

499 lines
18 KiB
TypeScript

/**
* Pivot-driven taxonomy listing (#1834).
*
* A taxonomy-filtered listing seeks the matching entries on the denormalized
* `content_taxonomies` pivot (migration 051) instead of scanning the whole
* collection. Two invariants:
*
* 1. **Parity** — when the pivot agrees with `ec_*` (the steady state the
* backfill + write-path re-stamp guarantee), the seek returns the same rows
* the old EXISTS shape did.
* 2. **Correctness under non-atomic writes** — when the pivot disagrees with
* `ec_*` (the transient window on D1, which has no transactions), the joined
* `ec_*` row decides membership. A stale pivot can under-fill a page; it can
* never leak a deleted or wrong-status/locale row.
*/
import type { Kysely } from "kysely";
import { afterEach, beforeEach, expect, it } from "vitest";
import { ContentRepository } from "../../src/database/repositories/content.js";
import { TaxonomyRepository } from "../../src/database/repositories/taxonomy.js";
import type { Database } from "../../src/database/types.js";
import { buildTaxonomyPivotQuery, emdashLoader } from "../../src/loader.js";
import { runWithContext } from "../../src/request-context.js";
import {
describeEachDialect,
setupForDialectWithCollections,
teardownForDialect,
type DialectName,
type DialectTestContext,
} from "../utils/test-db.js";
describeEachDialect("Loader taxonomy pivot-drive", (dialectName: DialectName) => {
let ctx: DialectTestContext;
let db: Kysely<Database>;
let content: ContentRepository;
let tax: TaxonomyRepository;
let termSeq = 0;
beforeEach(async () => {
ctx = await setupForDialectWithCollections(dialectName);
db = ctx.db;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema vs Database type
content = new ContentRepository(db as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema vs Database type
tax = new TaxonomyRepository(db as any);
termSeq = 0;
});
afterEach(async () => {
await teardownForDialect(ctx);
});
/** Insert a taxonomy term (translation_group = id) and return its id. */
async function term(name: string, slug: string) {
const id = `tax_${name}_${slug}_${termSeq++}`;
await db
.insertInto("taxonomies" as never)
.values({ id, name, slug, label: slug, translation_group: id } as never)
.execute();
return id;
}
async function createPost(
title: string,
opts: {
status?: string;
publishedAt?: string;
createdAt?: string;
locale?: string;
} = {},
) {
return content.create({
type: "post",
slug: `${title.toLowerCase().replace(/\s+/g, "-")}-${termSeq}`,
data: { title },
status: opts.status ?? "published",
publishedAt: opts.publishedAt,
createdAt: opts.createdAt,
locale: opts.locale,
});
}
function load(where: Record<string, unknown>, extra: Record<string, unknown> = {}) {
const loader = emdashLoader();
return runWithContext({ editMode: false, db }, () =>
loader.loadCollection!({
filter: { type: "post", where: where as never, ...extra },
}),
);
}
function titles(result: { entries: { data: Record<string, unknown> }[] }) {
return result.entries.map((e) => e.data.title);
}
/** Order-insensitive comparison helper. */
const sortAsc = (values: unknown[]) =>
values.toSorted((a, b) => String(a).localeCompare(String(b)));
// --- Parity -------------------------------------------------------------
it("selective term: returns only the one tagged entry", async () => {
const news = await term("category", "news");
const hit = await createPost("Hit");
for (let i = 0; i < 20; i++) await createPost(`Filler ${i}`);
await tax.attachToEntry("post", hit.id, news);
const result = await load({ category: "news" });
expect(titles(result)).toEqual(["Hit"]);
});
it("broad term (~half the collection): returns every tagged entry", async () => {
const news = await term("category", "news");
const tagged: string[] = [];
for (let i = 0; i < 10; i++) {
const post = await createPost(`Post ${i}`);
if (i % 2 === 0) {
await tax.attachToEntry("post", post.id, news);
tagged.push(`Post ${i}`);
}
}
const result = await load({ category: "news" });
expect(sortAsc(titles(result))).toEqual(sortAsc(tagged));
});
it("OR within a taxonomy: dedups an entry tagged with two matched terms", async () => {
const news = await term("category", "news");
const sports = await term("category", "sports");
const both = await createPost("Both");
await tax.attachToEntry("post", both.id, news);
await tax.attachToEntry("post", both.id, sports);
const result = await load({ category: ["news", "sports"] });
// One row despite matching two groups (GROUP BY dedup).
expect(titles(result)).toEqual(["Both"]);
});
it("multi-term AND: only entries tagged in every requested taxonomy match", async () => {
const news = await term("category", "news");
const featured = await term("tag", "featured");
const both = await createPost("Both");
const newsOnly = await createPost("News Only");
await tax.attachToEntry("post", both.id, news);
await tax.attachToEntry("post", both.id, featured);
await tax.attachToEntry("post", newsOnly.id, news);
const result = await load({ category: ["news"], tag: ["featured"] });
expect(titles(result)).toEqual(["Both"]);
});
it("sorts by published_at (indexed) descending", async () => {
const news = await term("category", "news");
const a = await createPost("Old", { publishedAt: "2020-01-01T00:00:00Z" });
const b = await createPost("New", { publishedAt: "2024-01-01T00:00:00Z" });
const c = await createPost("Mid", { publishedAt: "2022-01-01T00:00:00Z" });
for (const p of [a, b, c]) await tax.attachToEntry("post", p.id, news);
const result = await load({ category: "news" }, { orderBy: { published_at: "desc" } });
expect(titles(result)).toEqual(["New", "Mid", "Old"]);
});
it("sorts by created_at (indexed, loader default) descending", async () => {
const news = await term("category", "news");
const a = await createPost("First", { createdAt: "2020-01-01T00:00:00Z" });
const b = await createPost("Third", { createdAt: "2024-01-01T00:00:00Z" });
const c = await createPost("Second", { createdAt: "2022-01-01T00:00:00Z" });
for (const p of [a, b, c]) await tax.attachToEntry("post", p.id, news);
const result = await load({ category: "news" });
expect(titles(result)).toEqual(["Third", "Second", "First"]);
});
it("sorts by updated_at (temp-sort path, not denormalized) descending", async () => {
const news = await term("category", "news");
const a = await createPost("A");
const b = await createPost("B");
for (const p of [a, b]) await tax.attachToEntry("post", p.id, news);
// updated_at is read from the joined ec_* row in the temp-sort path (it is
// not a pivot column), so set distinct values directly for a deterministic
// order rather than relying on same-millisecond `now` timestamps.
await db
.updateTable("ec_post" as never)
.set({ updated_at: "2020-01-01T00:00:00Z" } as never)
.where("id" as never, "=", a.id)
.execute();
await db
.updateTable("ec_post" as never)
.set({ updated_at: "2024-01-01T00:00:00Z" } as never)
.where("id" as never, "=", b.id)
.execute();
const result = await load({ category: "news" }, { orderBy: { updated_at: "desc" } });
expect(titles(result)).toEqual(["B", "A"]);
});
it("filters by locale", async () => {
// A single term group with EN + FR variants sharing a slug, so the slug
// resolves in either locale (the loader scopes slug resolution by locale).
const group = "tax_category_news_group";
await db
.insertInto("taxonomies" as never)
.values({
id: group,
name: "category",
slug: "news",
label: "News",
locale: "en",
translation_group: group,
} as never)
.execute();
await db
.insertInto("taxonomies" as never)
.values({
id: "tax_category_news_fr",
name: "category",
slug: "news",
label: "Actualités",
locale: "fr",
translation_group: group,
} as never)
.execute();
const en = await createPost("English", { locale: "en" });
const fr = await createPost("French", { locale: "fr" });
await tax.attachToEntry("post", en.id, group);
await tax.attachToEntry("post", fr.id, group);
// The FR listing returns only the FR entry — the pivot's `locale` column
// narrows it, even though both entries share the term group.
expect(titles(await load({ category: "news" }, { locale: "fr" }))).toEqual(["French"]);
expect(titles(await load({ category: "news" }, { locale: "en" }))).toEqual(["English"]);
});
it("filters by status: a draft query returns drafts, published returns published", async () => {
const news = await term("category", "news");
const pub = await createPost("Published", { status: "published" });
const draft = await createPost("Draft", { status: "draft" });
await tax.attachToEntry("post", pub.id, news);
await tax.attachToEntry("post", draft.id, news);
expect(titles(await load({ category: "news" }, { status: "published" }))).toEqual([
"Published",
]);
expect(titles(await load({ category: "news" }, { status: "draft" }))).toEqual(["Draft"]);
});
// --- Stale-pivot correctness (non-atomic-write guard) -------------------
/**
* Desync the pivot from `ec_*` by writing `ec_*` directly (bypassing the
* repository re-stamp) — the transient window a reader can observe on D1.
*/
async function desyncEcRow(id: string, set: Record<string, string | null>): Promise<void> {
await db
.updateTable("ec_post" as never)
.set(set as never)
.where("id" as never, "=", id)
.execute();
}
it("drops a row the pivot admits but ec_* has soft-deleted", async () => {
const news = await term("category", "news");
const post = await createPost("Ghost");
await tax.attachToEntry("post", post.id, news);
// Pivot still says live+published; ec_* is soft-deleted.
await desyncEcRow(post.id, { deleted_at: "2024-01-01T00:00:00Z" });
const result = await load({ category: "news" });
expect(titles(result)).toEqual([]);
});
it("drops a row the pivot says published but ec_* has as draft", async () => {
const news = await term("category", "news");
const post = await createPost("SecretDraft");
await tax.attachToEntry("post", post.id, news);
await desyncEcRow(post.id, { status: "draft" });
const result = await load({ category: "news" }, { status: "published" });
expect(titles(result)).toEqual([]);
});
it("drops a row whose ec_* locale no longer matches the filter", async () => {
const news = await term("category", "news");
const post = await createPost("MovedLocale", { locale: "en" });
await tax.attachToEntry("post", post.id, news);
await desyncEcRow(post.id, { locale: "fr" });
const result = await load({ category: "news" }, { locale: "en" });
expect(titles(result)).toEqual([]);
});
it("under-fills (never returns wrong rows) when a stale row consumes a LIMIT slot", async () => {
const news = await term("category", "news");
const valid: string[] = [];
for (let i = 0; i < 3; i++) {
const post = await createPost(`Valid ${i}`, {
publishedAt: `2020-0${i + 1}-01T00:00:00Z`,
});
await tax.attachToEntry("post", post.id, news);
valid.push(`Valid ${i}`);
}
const stale = await createPost("Stale", { publishedAt: "2020-09-01T00:00:00Z" });
await tax.attachToEntry("post", stale.id, news);
// Pivot says published; ec_* is a draft → dropped on the outer re-check.
await desyncEcRow(stale.id, { status: "draft" });
const result = await load({ category: "news" }, { orderBy: { published_at: "desc" } });
// The stale row is absent; every returned row is valid (may under-fill).
expect(titles(result)).not.toContain("Stale");
expect(sortAsc(titles(result))).toEqual(sortAsc(valid));
});
// --- Re-stamp sync ------------------------------------------------------
it("publish re-stamps the pivot so the entry appears in a published listing", async () => {
const news = await term("category", "news");
const post = await createPost("Draft", { status: "draft" });
await tax.attachToEntry("post", post.id, news);
expect(titles(await load({ category: "news" }, { status: "published" }))).toEqual([]);
await content.publish("post", post.id);
expect(titles(await load({ category: "news" }, { status: "published" }))).toEqual(["Draft"]);
});
it("soft-delete then restore re-stamps the pivot both ways", async () => {
const news = await term("category", "news");
const post = await createPost("Toggle");
await tax.attachToEntry("post", post.id, news);
expect(titles(await load({ category: "news" }))).toEqual(["Toggle"]);
await content.delete("post", post.id);
expect(titles(await load({ category: "news" }))).toEqual([]);
await content.restore("post", post.id);
expect(titles(await load({ category: "news" }))).toEqual(["Toggle"]);
});
it("schedule re-stamps status+scheduled_at onto the pivot", async () => {
const news = await term("category", "news");
const post = await createPost("Later", { status: "draft" });
await tax.attachToEntry("post", post.id, news);
const future = new Date(Date.now() + 86_400_000).toISOString();
await content.schedule("post", post.id, future);
const pivot = await db
.selectFrom("content_taxonomies")
.select(["status", "scheduled_at"])
.where("entry_id", "=", post.id)
.executeTakeFirstOrThrow();
expect(pivot.status).toBe("scheduled");
expect(pivot.scheduled_at).toBe(future);
});
// --- Pagination ---------------------------------------------------------
it("paginates by keyset cursor over the pivot sort key without overlap", async () => {
const news = await term("category", "news");
const created: string[] = [];
for (let i = 0; i < 5; i++) {
const post = await createPost(`P${i}`, { publishedAt: `2020-0${i + 1}-01T00:00:00Z` });
await tax.attachToEntry("post", post.id, news);
created.push(`P${i}`);
}
const expectedOrder = created.toReversed(); // published_at desc
const seen: string[] = [];
let cursor: string | undefined;
for (let page = 0; page < 3; page++) {
const result: Awaited<ReturnType<typeof load>> = await load(
{ category: "news" },
{ orderBy: { published_at: "desc" }, limit: 2, ...(cursor ? { cursor } : {}) },
);
seen.push(...titles(result));
cursor = (result as { nextCursor?: string }).nextCursor;
if (!cursor) break;
}
expect(seen).toEqual(expectedOrder);
expect(new Set(seen).size).toBe(seen.length); // no overlap
});
it("paginates the OR-within-taxonomy (GROUP BY + HAVING cursor) path", async () => {
// Two matched terms → the multi-group branch (GROUP BY ct.entry_id, cursor
// in HAVING over MAX(sortval)). Each entry is tagged with BOTH terms so it
// fans out to two pivot rows and must still page as one row per entry.
const news = await term("category", "news");
const sports = await term("category", "sports");
const created: string[] = [];
for (let i = 0; i < 5; i++) {
const post = await createPost(`Q${i}`, { publishedAt: `2021-0${i + 1}-01T00:00:00Z` });
await tax.attachToEntry("post", post.id, news);
await tax.attachToEntry("post", post.id, sports);
created.push(`Q${i}`);
}
const expectedOrder = created.toReversed();
const seen: string[] = [];
let cursor: string | undefined;
for (let page = 0; page < 4; page++) {
const result: Awaited<ReturnType<typeof load>> = await load(
{ category: ["news", "sports"] },
{ orderBy: { published_at: "desc" }, limit: 2, ...(cursor ? { cursor } : {}) },
);
seen.push(...titles(result));
cursor = (result as { nextCursor?: string }).nextCursor;
if (!cursor) break;
}
expect(seen).toEqual(expectedOrder);
expect(new Set(seen).size).toBe(seen.length); // no dup fan-out, no overlap
});
// --- Byline filter rides along in the pivot CTE -------------------------
it("ANDs a byline filter with the taxonomy filter on the pivot path", async () => {
const news = await term("category", "news");
const both = await createPost("Bob News");
const taxOnly = await createPost("News Only");
const bylineOnly = await createPost("Bob Only");
await tax.attachToEntry("post", both.id, news);
await tax.attachToEntry("post", taxOnly.id, news);
await tax.attachToEntry("post", bylineOnly.id, news); // tagged but wrong byline
const credit = async (id: string, group: string) =>
db
.insertInto("_emdash_content_bylines" as never)
.values({
id: `cb_${id}`,
collection_slug: "post",
content_id: id,
byline_id: group,
sort_order: 0,
} as never)
.execute();
await credit(both.id, "byline_bob");
await credit(bylineOnly.id, "byline_alice");
const result = await load({ category: "news", byline: "byline_bob" });
expect(titles(result)).toEqual(["Bob News"]);
});
// --- Admin-shape parity (builder targeted directly) ---------------------
it("builder serves the trash shape (deleted_at IS NOT NULL)", async () => {
const news = await term("category", "news");
const live = await createPost("Live");
const trashed = await createPost("Trashed");
await tax.attachToEntry("post", live.id, news);
await tax.attachToEntry("post", trashed.id, news);
await content.delete("post", trashed.id); // re-stamps pivot deleted_at
const rows = await buildTaxonomyPivotQuery({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema vs Database type
db: db as any,
collection: "post",
tableName: "ec_post",
groupSets: [[news]],
orderBy: undefined,
cursor: undefined,
locale: undefined,
status: undefined, // all statuses
deletedIsNull: false, // trash
bylineGroups: null,
fetchLimit: undefined,
offset: undefined,
}).execute(db);
expect(rows.rows.map((r) => (r.title as string) ?? r.slug)).toEqual(["Trashed"]);
});
it("builder serves an all-statuses live shape (status undefined)", async () => {
const news = await term("category", "news");
const pub = await createPost("Pub", { status: "published" });
const draft = await createPost("Draft", { status: "draft" });
await tax.attachToEntry("post", pub.id, news);
await tax.attachToEntry("post", draft.id, news);
const rows = await buildTaxonomyPivotQuery({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema vs Database type
db: db as any,
collection: "post",
tableName: "ec_post",
groupSets: [[news]],
orderBy: undefined,
cursor: undefined,
locale: undefined,
status: undefined, // all statuses
deletedIsNull: true, // live
bylineGroups: null,
fetchLimit: undefined,
offset: undefined,
}).execute(db);
const seen = sortAsc(rows.rows.map((r) => r.status as string));
expect(seen).toEqual(["draft", "published"]);
});
});