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
146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
/**
|
|
* Query-plan shape of the pivot-driven taxonomy listing (#1834).
|
|
*
|
|
* On stats-blind SQLite/D1 (no ANALYZE, no `sqlite_stat1`) the old EXISTS shape
|
|
* drove the scan from the collection's order index and probed a taxonomy EXISTS
|
|
* per row — a full `ec_*` walk for a selective term. The restructure seeks the
|
|
* term on a `(taxonomy_id, collection, deleted_at, [locale,] <sort> DESC,
|
|
* entry_id)` pivot index, lets `LIMIT` short-circuit, and touches `ec_*` only by
|
|
* primary key.
|
|
*
|
|
* This asserts the plan, not the output (output is covered by
|
|
* loader-taxonomy-pivot). SQLite-only: `EXPLAIN QUERY PLAN` is a SQLite concern
|
|
* and, being stats-blind here, the plan is schema-driven — matching D1 exactly.
|
|
*/
|
|
|
|
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 { 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-eslint/no-explicit-any -- schema vs Database type
|
|
const anyDb = db as any;
|
|
const content = new ContentRepository(anyDb);
|
|
const tax = new TaxonomyRepository(anyDb);
|
|
const term = await tax.create({ name: "category", slug: "news", label: "News", locale: "en" });
|
|
// A selective term: one tagged entry among many. The plan is stats-blind so
|
|
// the ratio is immaterial — the point is that the seek short-circuits.
|
|
for (let i = 0; i < 30; i++) {
|
|
const post = await content.create({
|
|
type: "post",
|
|
slug: `post-${i}`,
|
|
data: { title: `Post ${i}` },
|
|
status: "published",
|
|
locale: "en",
|
|
});
|
|
if (i === 0) await tax.attachToEntry("post", post.id, term.id);
|
|
}
|
|
});
|
|
|
|
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");
|
|
}
|
|
|
|
/** The pivot-drive query is the one with the `picked` CTE. */
|
|
function pivotQueryPlan(): string {
|
|
const query = captured.find((q) => q.sql.includes("picked"));
|
|
expect(query, "expected the loader to emit a pivot-drive query").toBeDefined();
|
|
return explain(query!);
|
|
}
|
|
|
|
async function runLoad(extra: Record<string, unknown>): Promise<void> {
|
|
captured = [];
|
|
const loader = emdashLoader();
|
|
await runWithContext({ editMode: false, db }, () =>
|
|
loader.loadCollection!({
|
|
filter: { type: "post", where: { category: "news" } as never, limit: 5, ...extra },
|
|
}),
|
|
);
|
|
}
|
|
|
|
it("seeks the term via idx_content_taxonomies_pub for a published_at sort", async () => {
|
|
await runLoad({ orderBy: { published_at: "desc" } });
|
|
const plan = pivotQueryPlan();
|
|
expect(plan).toContain("idx_content_taxonomies_pub");
|
|
// No full scan of the content table — it is reached only by primary key.
|
|
expect(plan).not.toContain("SCAN r");
|
|
// The `entry_id DESC` tiebreaker lets the index satisfy the whole ORDER BY,
|
|
// so the LIMIT short-circuits without buffering an equal-sortval block.
|
|
expect(plan).not.toContain("TEMP B-TREE");
|
|
});
|
|
|
|
it("seeks via idx_content_taxonomies_crt for the default created_at sort", async () => {
|
|
await runLoad({});
|
|
const plan = pivotQueryPlan();
|
|
expect(plan).toContain("idx_content_taxonomies_crt");
|
|
expect(plan).not.toContain("SCAN r");
|
|
expect(plan).not.toContain("TEMP B-TREE");
|
|
});
|
|
|
|
it("uses the locale-variant index (loc_pub) when locale-filtered + published_at", async () => {
|
|
await runLoad({ orderBy: { published_at: "desc" }, locale: "en" });
|
|
const plan = pivotQueryPlan();
|
|
expect(plan).toContain("idx_content_taxonomies_loc_pub");
|
|
expect(plan).not.toContain("SCAN r");
|
|
expect(plan).not.toContain("TEMP B-TREE");
|
|
});
|
|
|
|
it("updated_at sort seeks the term via the pivot and does not full-scan the content table", async () => {
|
|
await runLoad({ orderBy: { updated_at: "desc" } });
|
|
const plan = pivotQueryPlan();
|
|
// A pivot index seek on taxonomy_id (any composite is prefixed by it), not a
|
|
// full pivot scan and not a full ec_* scan.
|
|
expect(plan).toContain("idx_content_taxonomies");
|
|
expect(plan).not.toContain("SCAN ct");
|
|
expect(plan).not.toContain("SCAN r");
|
|
});
|