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

182 lines
6.9 KiB
TypeScript

/**
* Query-plan shape of the folded per-entry hydration subqueries (#1722).
*
* `foldedHydrationSelects` folds per-entry term *and* byline hydration into the
* content query as correlated JSON-array subqueries. On D1 / stats-blind SQLite
* (no ANALYZE, no `sqlite_stat1`) the planner is free to pick the join order,
* and a plain `JOIN` lets it drive a subquery from the term/byline table by
* locale — enumerating *every row in the locale* and probing the pivot once per
* emitted row. On a site with thousands of terms that's tens of thousands of
* rows read per list page, paid on every cache miss.
*
* The fix pins the join order with `CROSS JOIN` on the SQLite path so each
* subquery always drives from its pivot (`content_taxonomies` /
* `_emdash_content_bylines`) by `(collection, entry_id)` and probes the
* term/byline table by `translation_group` — a handful of reads per entry,
* independent of taxonomy/byline size and of statistics.
*
* This asserts the *plan*, not the output (output is covered by loader-fold).
* Since the planner is stats-blind here, the plan is schema-driven and does not
* depend on row counts — this DB matches D1's shape exactly. Both `foldJoin`
* consumers (`loadCollection` and `loadEntry`) and both subqueries (taxonomy and
* byline) are guarded, since the fix hardened the join order for all of them.
*
* SQLite-only: `EXPLAIN QUERY PLAN` and `CROSS JOIN … ON` are SQLite concerns;
* Postgres keeps statistics and is unaffected.
*/
import Database from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import { afterEach, beforeEach, expect, it } from "vitest";
import { runMigrations } from "../../src/database/migrations/runner.js";
import { BylineRepository } from "../../src/database/repositories/byline.js";
import { ContentRepository } from "../../src/database/repositories/content.js";
import { TaxonomyRepository } from "../../src/database/repositories/taxonomy.js";
import type { Database as DatabaseSchema } from "../../src/database/types.js";
import { emdashLoader } from "../../src/loader.js";
import { runWithContext } from "../../src/request-context.js";
import { SchemaRegistry } from "../../src/schema/registry.js";
interface CapturedQuery {
sql: string;
parameters: readonly unknown[];
}
let sqlite: Database.Database;
let db: Kysely<DatabaseSchema>;
let captured: CapturedQuery[];
beforeEach(async () => {
captured = [];
sqlite = new Database(":memory:");
db = new Kysely<DatabaseSchema>({
dialect: new SqliteDialect({ database: sqlite }),
log(event) {
if (event.level === "query") {
captured.push({
sql: event.query.sql,
parameters: event.query.parameters,
});
}
},
});
// Deliberately no ANALYZE: matches D1, which never maintains sqlite_stat1.
await runMigrations(db);
const registry = new SchemaRegistry(db);
await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" });
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
// eslint-disable-next-line typescript/no-explicit-any -- schema type vs Database type
const anyDb = db as any;
const content = new ContentRepository(anyDb);
const tax = new TaxonomyRepository(anyDb);
const byline = new BylineRepository(anyDb);
const post = await content.create({
type: "post",
slug: "tagged",
data: { title: "Tagged" },
locale: "en",
});
await anyDb
.updateTable("ec_post")
.set({ status: "published" })
.where("id", "=", post.id)
.execute();
// A handful of terms and bylines in the active locale; two of each attached to
// the entry. The plan is stats-blind so the count is immaterial — the point is
// the join *order*. Attaching real rows also proves the SQL executes.
const attachedBylines: string[] = [];
for (let i = 0; i < 8; i++) {
const term = await tax.create({
name: "tag",
slug: `tag-${i}`,
label: `Tag ${i}`,
locale: "en",
});
if (i < 2) await tax.attachToEntry("post", post.id, term.id);
const author = await byline.create({
displayName: `Author ${i}`,
slug: `author-${i}`,
locale: "en",
});
if (i < 2) attachedBylines.push(author.id);
}
await byline.setContentBylines(
"post",
post.id,
attachedBylines.map((bylineId) => ({ bylineId, roleLabel: "Author" })),
);
});
afterEach(async () => {
await db.destroy();
});
/** better-sqlite3 only binds primitives; coerce the JS values Kysely captured. */
function bindable(p: unknown): unknown {
if (typeof p === "boolean") return p ? 1 : 0;
if (p instanceof Date) return p.toISOString();
if (p === undefined) return null;
return p;
}
function explain(query: CapturedQuery): string {
const rows = sqlite
.prepare(`EXPLAIN QUERY PLAN ${query.sql}`)
.all(...query.parameters.map(bindable)) as { detail: string }[];
return rows.map((r) => r.detail).join("\n");
}
/**
* Assert both folded subqueries drive from their pivot rather than scanning the
* term/byline table by locale. Bad plan: the subquery drives from
* `taxonomies`/`_emdash_bylines` by locale, scanning every row in the locale.
* Good plan: probe the term/byline table by translation_group, one row per
* attached entry — only reachable when the pivot drives the join.
*/
function assertPivotDrivenFold(plan: string): void {
// Taxonomy subquery.
expect(plan, "taxonomy subquery must not scan taxonomies by locale").not.toContain(
"idx_taxonomies_locale",
);
expect(plan, "taxonomy subquery must probe taxonomies by translation_group").toContain(
"idx_taxonomies_translation_group",
);
// Byline subquery. Its `(translation_group, locale)` composite unique index
// already yields the pivot-driven plan today, so `CROSS JOIN` is a no-op here
// — this guards against a future byline-index change silently regressing it.
expect(plan, "byline subquery must not scan bylines by locale").not.toContain(
"idx__emdash_bylines_locale",
);
expect(plan, "byline subquery must probe bylines by translation_group").toContain(
"idx_bylines_group_locale_unique",
);
}
/** The folded query is the one exposing the `_emdash_terms` alias. */
function foldedQueryPlan(): string {
const foldedQuery = captured.find((q) => q.sql.includes("_emdash_terms"));
expect(foldedQuery, "expected the loader to emit a folded query").toBeDefined();
return explain(foldedQuery!);
}
it("drives folded hydration from the pivot on loadCollection, not term/byline-by-locale", async () => {
const loader = emdashLoader();
// Running the real loader query also proves the SQL executes on SQLite.
await runWithContext({ editMode: false, db }, () =>
loader.loadCollection({ filter: { type: "post" } }),
);
assertPivotDrivenFold(foldedQueryPlan());
});
it("drives folded hydration from the pivot on loadEntry, not term/byline-by-locale", async () => {
const loader = emdashLoader();
captured = [];
await runWithContext({ editMode: false, db }, () =>
loader.loadEntry({ filter: { type: "post", id: "tagged", locale: "en" } }),
);
assertPivotDrivenFold(foldedQueryPlan());
});