diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BTreeDemo.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BTreeDemo.tsx new file mode 100644 index 0000000000..f904699c38 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BTreeDemo.tsx @@ -0,0 +1,72 @@ +import { highlight, type HighlightedCode } from "codehike/code"; +import { BTreeDemoClient, type BTreePhase } from "./BTreeDemoClient"; + +const SOURCE = `function lookup(tree, key) { + let node = tree.root; + while (!node.isLeaf) { + const child = node.childFor(key); + node = child; + } + return node.findEntry(key)?.rowPointer; +} + +lookup(idx, "t42"); // -> row 142`; + +const PHASES: BTreePhase[] = [ + { + label: "Start at the root", + caption: + "A B-tree is a sorted tree of (key, pointer) entries. Lookups always start at the root.", + lines: { from: 1, to: 2 }, + activeRoot: false, + activeLeaf: null, + matchedKey: null, + query: null, + }, + { + label: 'lookup("t42")', + caption: "We want to find the row for tenant t42. Compare against the root keys.", + lines: { from: 10, to: 10 }, + activeRoot: true, + activeLeaf: null, + matchedKey: null, + query: "t42", + }, + { + label: "Pick the child", + caption: + "t42 is greater than or equal to t20 and less than t50, so descend into the middle child.", + lines: { from: 3, to: 5 }, + activeRoot: true, + activeLeaf: 1, + matchedKey: null, + query: "t42", + }, + { + label: "Search the leaf", + caption: "The middle leaf is sorted. Scan it for the exact key.", + lines: { from: 7, to: 7 }, + activeRoot: false, + activeLeaf: 1, + matchedKey: "t42", + query: "t42", + }, + { + label: "Return the row pointer", + caption: "The leaf entry holds a pointer to row 142 on disk. Follow it to read the row.", + lines: { from: 7, to: 7 }, + activeRoot: false, + activeLeaf: 1, + matchedKey: "t42", + query: "t42", + found: true, + }, +]; + +export async function BTreeDemo() { + const baseCode = (await highlight( + { value: SOURCE, lang: "typescript", meta: "" }, + "github-from-css", + )) as HighlightedCode; + return ; +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BTreeDemoClient.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BTreeDemoClient.tsx new file mode 100644 index 0000000000..aa36ff5ac5 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BTreeDemoClient.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { InnerLine, Pre, type AnnotationHandler, type HighlightedCode } from "codehike/code"; +import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"; + +export type BTreePhase = { + label: string; + caption: string; + lines: { from: number; to: number }; + activeRoot: boolean; + activeLeaf: 0 | 1 | 2 | null; + matchedKey: string | null; + query: string | null; + found?: boolean; +}; + +type Props = { + baseCode: HighlightedCode; + phases: BTreePhase[]; +}; + +const STEP_HOLD_MS = 5400; + +const ROOT_KEYS = ["t20", "t50"]; +const LEAVES: { keys: string[]; rows: number[] }[] = [ + { keys: ["t05", "t12", "t18"], rows: [105, 112, 118] }, + { keys: ["t30", "t40", "t42"], rows: [130, 140, 142] }, + { keys: ["t60", "t75", "t90"], rows: [160, 175, 190] }, +]; + +const markHandler: AnnotationHandler = { + name: "mark", + AnnotatedLine: ({ annotation, ...props }) => ( + + ), +}; + +const handlers = [markHandler]; + +function codeForPhase(base: HighlightedCode, phase: BTreePhase): HighlightedCode { + const totalLines = (base.code ?? "").split("\n").length; + const from = Math.max(1, Math.min(phase.lines.from, totalLines)); + const to = Math.max(from, Math.min(phase.lines.to, totalLines)); + const lineMarks = []; + for (let n = from; n <= to; n += 1) { + lineMarks.push({ name: "mark", query: "active", fromLineNumber: n, toLineNumber: n }); + } + return { + ...base, + annotations: [...base.annotations.filter((a) => a.name !== "mark"), ...lineMarks], + }; +} + +export function BTreeDemoClient({ baseCode, phases }: Props) { + const [phaseIndex, setPhaseIndex] = useState(0); + const [playing, setPlaying] = useState(true); + const [inView, setInView] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el || typeof IntersectionObserver === "undefined") { + setInView(true); + return; + } + const obs = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { + threshold: 0.25, + }); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + useEffect(() => { + if (!playing || !inView) return; + const id = setInterval(() => { + setPhaseIndex((i) => (i + 1) % phases.length); + }, STEP_HOLD_MS); + return () => clearInterval(id); + }, [playing, inView, phases.length]); + + const phase = phases[phaseIndex]; + const code = useMemo(() => codeForPhase(baseCode, phase), [baseCode, phase]); + + function goTo(index: number) { + setPlaying(false); + setPhaseIndex(((index % phases.length) + phases.length) % phases.length); + } + + return ( +
+
+ + {phase.label} +
+ + + +
+
+ +
+ {phases.map((p, i) => ( + + ))} +
+ +
+
+
+        
+ +
+ {phase.query ? ( +
+ looking for + "{phase.query}" +
+ ) : ( +
+ idle +
+ )} + +
+
+
+ root +
+ {ROOT_KEYS.map((k) => ( + + {k} + + ))} +
+
+
+ + + +
{phase.caption}
+ + {phase.found ? ( +
+ + + Found {phase.matchedKey}, returning the row pointer. + +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunner.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunner.tsx new file mode 100644 index 0000000000..6cd7671a7a --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunner.tsx @@ -0,0 +1,122 @@ +import { highlight, type HighlightedCode } from "codehike/code"; +import { BloomDemoRunnerClient, type RunnerStep } from "./BloomDemoRunnerClient"; + +const SOURCE = `import { create, isDatabaseSuccess } from "create-db"; +import { Client } from "pg"; + +console.log("Provisioning a temporary Prisma Postgres database (1h TTL)..."); +const db = await create({ ttl: "1h" }); +if (!isDatabaseSuccess(db)) throw new Error(db.message); +console.log(\` claim URL: \${db.claimUrl}\`); + +const client = new Client({ connectionString: db.connectionString! }); +await client.connect(); + +await client.query(\`CREATE EXTENSION IF NOT EXISTS bloom\`); +await client.query(\` + DROP TABLE IF EXISTS cache_entries; + CREATE TABLE cache_entries ( + id BIGSERIAL PRIMARY KEY, + tenant_id TEXT, user_id TEXT, endpoint TEXT, + locale TEXT, region TEXT, api_version INT, + payload JSONB + ); +\`); + +console.log(\`Seeding \${N.toLocaleString()} rows...\`); +for (let start = 0; start < N; start += BATCH) { + // build BATCH rows mixing tenants / users / endpoints / locales / regions / versions + await client.query(\`INSERT INTO cache_entries (...) VALUES \${placeholders}\`, params); +} +await client.query(\`ANALYZE cache_entries\`); + +// A. Six B-tree indexes (one per column) +for (const c of COLS) { + await client.query(\`CREATE INDEX btree_\${c} ON cache_entries (\${c})\`); +} +const btreeMB = await totalIndexMB(); +const btreeMs = await runLookups(); + +// B. One bloom index covering all six columns +await client.query(\` + CREATE INDEX cache_bloom_idx ON cache_entries + USING bloom (\${COLS.join(", ")}) +\`); +const bloomMB = await totalIndexMB(); +const bloomMs = await runLookups(); + +const shrink = ((1 - bloomMB / btreeMB) * 100).toFixed(0); +console.log( + \`Bloom index is \${shrink}% smaller \` + + \`(\${bloomMB.toFixed(1)} MB vs \${btreeMB.toFixed(1)} MB)\`, +);`; + +const STEPS: RunnerStep[] = [ + { + title: "Provision DB", + caption: + "Spin up a temporary Prisma Postgres database with a 1 hour TTL and connect over pg.", + lines: { from: 1, to: 10 }, + output: [ + "Provisioning a temporary Prisma Postgres database (1h TTL)...", + " claim URL: https://create-db.prisma.io/claim?projectID=...", + ], + }, + { + title: "Schema + bloom extension", + caption: + "Enable the bloom extension once per database, then create the wide cache_entries table.", + lines: { from: 12, to: 21 }, + output: ["Creating cache_entries table and enabling bloom extension..."], + }, + { + title: "Seed 10,000 rows", + caption: + "Insert a mix of tenants, users, endpoints, locales, regions, and api versions so the lookups are realistic.", + lines: { from: 23, to: 28 }, + output: ["Seeding 10,000 rows...", " seeded in 1.2s"], + }, + { + title: "A: Six B-tree indexes", + caption: + "One B-tree per filterable column. Measure the total index size and the time for three lookups.", + lines: { from: 30, to: 35 }, + output: [ + "", + "A. Six B-tree indexes (one per column)...", + " index size: 0.5 MB", + " 3 lookups: 306.5 ms", + ], + }, + { + title: "B: One bloom index", + caption: + "Drop the B-trees and create a single bloom index spanning all six columns. Same three lookups.", + lines: { from: 37, to: 43 }, + output: [ + "", + "B. One bloom index (all six columns)...", + " index size: 0.2 MB", + " 3 lookups: 302.7 ms", + ], + }, + { + title: "Compare", + caption: + "Print the difference. The bloom index is much smaller, and it covers any subset of those six columns.", + lines: { from: 45, to: 49 }, + output: [ + "", + " Bloom index is 60% smaller (0.2 MB vs 0.5 MB),", + " and one index covers any subset of those six columns.", + ], + }, +]; + +export async function BloomDemoRunner() { + const baseCode = (await highlight( + { value: SOURCE, lang: "typescript", meta: "" }, + "github-from-css", + )) as HighlightedCode; + return ; +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunnerClient.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunnerClient.tsx new file mode 100644 index 0000000000..ea226781f5 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunnerClient.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { InnerLine, Pre, type AnnotationHandler, type HighlightedCode } from "codehike/code"; +import { Check, ChevronLeft, ChevronRight, Copy, Pause, Play } from "lucide-react"; + +export type RunnerStep = { + title: string; + caption: string; + lines: { from: number; to: number }; + output: string[]; +}; + +type Props = { + baseCode: HighlightedCode; + steps: RunnerStep[]; +}; + +const STEP_HOLD_MS = 6500; + +const markHandler: AnnotationHandler = { + name: "mark", + AnnotatedLine: ({ annotation, ...props }) => ( + + ), +}; + +const handlers = [markHandler]; + +function codeForStep(base: HighlightedCode, step: RunnerStep): HighlightedCode { + const totalLines = (base.code ?? "").split("\n").length; + const from = Math.max(1, Math.min(step.lines.from, totalLines)); + const to = Math.max(from, Math.min(step.lines.to, totalLines)); + const lineMarks = []; + for (let n = from; n <= to; n += 1) { + lineMarks.push({ + name: "mark", + query: "active", + fromLineNumber: n, + toLineNumber: n, + }); + } + return { + ...base, + annotations: [...base.annotations.filter((a) => a.name !== "mark"), ...lineMarks], + }; +} + +export function BloomDemoRunnerClient({ baseCode, steps }: Props) { + const [stepIndex, setStepIndex] = useState(0); + const [playing, setPlaying] = useState(true); + const [inView, setInView] = useState(false); + const [copied, setCopied] = useState(false); + const containerRef = useRef(null); + const codeScrollRef = useRef(null); + const terminalRef = useRef(null); + const copyTimeoutRef = useRef | null>(null); + + useEffect(() => { + const el = containerRef.current; + if (!el || typeof IntersectionObserver === "undefined") { + setInView(true); + return; + } + const obs = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { + threshold: 0.2, + }); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + useEffect(() => { + if (!playing || !inView) return; + const id = setInterval(() => { + setStepIndex((i) => (i + 1) % steps.length); + }, STEP_HOLD_MS); + return () => clearInterval(id); + }, [playing, inView, steps.length]); + + const step = steps[stepIndex]; + const code = useMemo(() => codeForStep(baseCode, step), [baseCode, step]); + + useEffect(() => { + const codeEl = codeScrollRef.current; + if (codeEl) { + const highlighted = codeEl.querySelector('[data-mark="active"]'); + if (highlighted) { + const parent = codeEl; + const top = highlighted.offsetTop - parent.offsetTop; + parent.scrollTo({ top: Math.max(0, top - 20), behavior: "smooth" }); + } + } + const termEl = terminalRef.current; + if (termEl) { + const active = termEl.querySelector('[data-step-state="active"]'); + if (active) { + const top = active.offsetTop - termEl.offsetTop; + termEl.scrollTo({ top: Math.max(0, top - 20), behavior: "smooth" }); + } + } + }, [stepIndex]); + + function goTo(index: number) { + setPlaying(false); + setStepIndex(((index % steps.length) + steps.length) % steps.length); + } + + const allOutput = steps.flatMap((s, i) => s.output.map((line) => ({ line, stepIdx: i }))); + + function copyOutput() { + const text = allOutput.map((e) => e.line).join("\n"); + if (!navigator.clipboard) return; + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 1600); + }); + } + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + }; + }, []); + + return ( +
+
+ +
+ +
+ {steps.map((s, i) => ( + + ))} +
+ +
{step.caption}
+ +
+
+
+ index.ts +
+
+
+          
+
+
+
+ terminal output + +
+
+ {allOutput.map((entry, i) => { + const state = + entry.stepIdx === stepIndex + ? "active" + : entry.stepIdx < stepIndex + ? "past" + : "future"; + return ( +
+ {entry.line || " "} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemo.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemo.tsx new file mode 100644 index 0000000000..dc5fb5347e --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemo.tsx @@ -0,0 +1,45 @@ +import { highlight, type HighlightedCode } from "codehike/code"; +import { BloomFilterDemoClient } from "./BloomFilterDemoClient"; + +const SNIPPETS: string[] = [ + `const bits = new Uint8Array(16); +// all zeros, nothing added`, + + `function add(item) { + for (const i of hashes(item)) { + bits[i] = 1; + } +} + +add("alice"); // hashes -> [2, 7, 11]`, + + `add("bob"); // hashes -> [4, 7, 13] +// bit 7 was already 1 from alice +// no way to tell who set it`, + + `function check(item) { + return hashes(item).every( + (i) => bits[i] === 1, + ); +} + +check("alice"); // [2, 7, 11] all 1 +// probably present, recheck`, + + `check("carol"); // [0, 5, 9] +// bits[0] is 0 +// definitely not present`, + + `check("dave"); // [4, 11, 13] +// all three happen to be 1 +// from alice + bob +// dave was never added +// false positive`, +]; + +export async function BloomFilterDemo() { + const highlighted = (await Promise.all( + SNIPPETS.map((value) => highlight({ value, lang: "typescript", meta: "" }, "github-from-css")), + )) as HighlightedCode[]; + return ; +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemoClient.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemoClient.tsx new file mode 100644 index 0000000000..feaa9aedb2 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemoClient.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { Component, createRef, useEffect, useRef, useState, type RefObject } from "react"; +import { Pre, type HighlightedCode } from "codehike/code"; +import { + calculateTransitions, + getStartingSnapshot, + type TokenTransitionsSnapshot, +} from "codehike/utils/token-transitions"; +import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"; + +type Verdict = "idle" | "present" | "absent" | "false-positive"; + +type Phase = { + step: number; + label: string; + shortLabel: string; + caption: string; + bits: boolean[]; + flipping: number[]; + probe: number[]; + verdict: Verdict; + subject?: string; +}; + +const SIZE = 16; +const STEP_HOLD_MS = 5200; + +function makeBits(positions: number[]): boolean[] { + const out: boolean[] = Array.from({ length: SIZE }, () => false); + for (const p of positions) out[p] = true; + return out; +} + +const ALICE = [2, 7, 11]; +const BOB = [4, 7, 13]; +const CAROL = [0, 5, 9]; +const DAVE = [4, 11, 13]; + +const PHASES: Phase[] = [ + { + step: 0, + label: "Empty filter", + shortLabel: "Empty", + caption: "A 16-bit array, all zeros. Nothing has been added yet.", + bits: makeBits([]), + flipping: [], + probe: [], + verdict: "idle", + }, + { + step: 1, + label: 'add("alice")', + shortLabel: "Add alice", + caption: "Three hash functions map alice to positions 2, 7 and 11. Flip those bits to 1.", + bits: makeBits(ALICE), + flipping: ALICE, + probe: [], + verdict: "idle", + subject: "alice", + }, + { + step: 2, + label: 'add("bob")', + shortLabel: "Add bob", + caption: + "Bob hashes to 4, 7 and 13. Position 7 was already 1 from alice, so it stays 1. No way to tell who set it.", + bits: makeBits([...ALICE, ...BOB]), + flipping: BOB, + probe: [], + verdict: "idle", + subject: "bob", + }, + { + step: 3, + label: 'check("alice")', + shortLabel: "Check alice", + caption: + "Hash alice again, look at the same positions. All three are 1, so alice is probably present.", + bits: makeBits([...ALICE, ...BOB]), + flipping: [], + probe: ALICE, + verdict: "present", + subject: "alice", + }, + { + step: 4, + label: 'check("carol")', + shortLabel: "Check carol", + caption: "Carol hashes to 0, 5 and 9. Position 0 is still 0, so carol was never added. Hard no.", + bits: makeBits([...ALICE, ...BOB]), + flipping: [], + probe: CAROL, + verdict: "absent", + subject: "carol", + }, + { + step: 5, + label: 'check("dave")', + shortLabel: "Check dave", + caption: + "Dave hashes to 4, 11 and 13. All three happen to be 1 from alice and bob, even though dave was never added. That is a false positive: tolerable because the database rechecks against the row.", + bits: makeBits([...ALICE, ...BOB]), + flipping: [], + probe: DAVE, + verdict: "false-positive", + subject: "dave", + }, +]; + +class SmoothPre extends Component<{ code: HighlightedCode }> { + preRef: RefObject = createRef(); + + getSnapshotBeforeUpdate() { + if (!this.preRef.current) return null; + return getStartingSnapshot(this.preRef.current); + } + + componentDidUpdate( + _prev: { code: HighlightedCode }, + _ps: unknown, + snap: TokenTransitionsSnapshot | null, + ) { + if (!this.preRef.current || !snap) return; + const transitions = calculateTransitions(this.preRef.current, snap); + transitions.forEach(({ element, keyframes, options }) => { + element.animate(keyframes, { + duration: options.duration * 1000, + delay: options.delay * 1000, + easing: options.easing, + fill: options.fill, + }); + }); + } + + render() { + return
;
+  }
+}
+
+type Props = {
+  snippets: HighlightedCode[];
+};
+
+export function BloomFilterDemoClient({ snippets }: Props) {
+  const [phaseIndex, setPhaseIndex] = useState(0);
+  const [playing, setPlaying] = useState(true);
+  const [inView, setInView] = useState(false);
+  const containerRef = useRef(null);
+
+  useEffect(() => {
+    const el = containerRef.current;
+    if (!el || typeof IntersectionObserver === "undefined") {
+      setInView(true);
+      return;
+    }
+    const obs = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
+      threshold: 0.25,
+    });
+    obs.observe(el);
+    return () => obs.disconnect();
+  }, []);
+
+  useEffect(() => {
+    if (!playing || !inView) return;
+    const id = setInterval(() => {
+      setPhaseIndex((i) => (i + 1) % PHASES.length);
+    }, STEP_HOLD_MS);
+    return () => clearInterval(id);
+  }, [playing, inView]);
+
+  const phase = PHASES[phaseIndex];
+  const code = snippets[phaseIndex];
+
+  function goTo(index: number) {
+    setPlaying(false);
+    setPhaseIndex(((index % PHASES.length) + PHASES.length) % PHASES.length);
+  }
+
+  return (
+    
+
+ + {phase.label} +
+ + + +
+
+ +
+ {PHASES.map((p, i) => ( + + ))} +
+ +
+
+ +
+ +
+
+ {phase.bits.map((bit, i) => { + const isFlipping = phase.flipping.includes(i); + const isProbe = phase.probe.includes(i); + return ( +
+ + {bit ? "1" : "0"} +
+ ); + })} +
+ +
{phase.caption}
+ +
+ {phase.verdict === "present" && ( + <> + + + {phase.subject} is probably present. Recheck against the row to + confirm. + + + )} + {phase.verdict === "absent" && ( + <> + + + {phase.subject} is definitely not present. A 0 bit is a hard no. + + + )} + {phase.verdict === "false-positive" && ( + <> + + + {phase.subject} looks present, but was never added. That is a + false positive. + + + )} + {phase.verdict === "idle" && ( + + {phase.subject ? "added" : "ready"} + + )} +
+
+
+
+ ); +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/HashDemo.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/HashDemo.tsx new file mode 100644 index 0000000000..44e7584725 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/HashDemo.tsx @@ -0,0 +1,59 @@ +import { highlight, type HighlightedCode } from "codehike/code"; +import { HashDemoClient, type HashPhase } from "./HashDemoClient"; + +const SOURCE = `function hashes(item) { + const h1 = fnv1a(item) % 16; + const h2 = djb2(item) % 16; + const h3 = murmur3(item) % 16; + return [h1, h2, h3]; +} + +hashes("alice"); // [2, 7, 11]`; + +const PHASES: HashPhase[] = [ + { + label: "Input", + caption: + 'We need three independent bit positions for "alice". Start by feeding the name into the first hash.', + lines: { from: 1, to: 1 }, + completed: 0, + activeHash: null, + }, + { + label: "h1 = fnv1a(item) % 16", + caption: "FNV-1a churns the bytes of 'alice' into a 32-bit number, then mod 16 picks bit 2.", + lines: { from: 2, to: 2 }, + completed: 0, + activeHash: 0, + }, + { + label: "h2 = djb2(item) % 16", + caption: "DJB2 is a different mixer, so the same input lands on bit 7. The three hashes are independent.", + lines: { from: 3, to: 3 }, + completed: 1, + activeHash: 1, + }, + { + label: "h3 = murmur3(item) % 16", + caption: "MurmurHash3 closes out the trio with bit 11.", + lines: { from: 4, to: 4 }, + completed: 2, + activeHash: 2, + }, + { + label: "return [h1, h2, h3]", + caption: + "Bits 2, 7, and 11 are the fingerprint of 'alice'. The bloom filter sets these three bits to remember her.", + lines: { from: 5, to: 5 }, + completed: 3, + activeHash: null, + }, +]; + +export async function HashDemo() { + const baseCode = (await highlight( + { value: SOURCE, lang: "typescript", meta: "" }, + "github-from-css", + )) as HighlightedCode; + return ; +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/HashDemoClient.tsx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/HashDemoClient.tsx new file mode 100644 index 0000000000..cddeefaa56 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/HashDemoClient.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { InnerLine, Pre, type AnnotationHandler, type HighlightedCode } from "codehike/code"; +import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"; + +export type HashPhase = { + label: string; + caption: string; + lines: { from: number; to: number }; + completed: number; + activeHash: 0 | 1 | 2 | null; +}; + +type Props = { + baseCode: HighlightedCode; + phases: HashPhase[]; +}; + +const STEP_HOLD_MS = 4800; +const SIZE = 16; + +const HASHES: { name: string; raw: number; bit: number }[] = [ + { name: "fnv1a", raw: 0x4f5b9aa2, bit: 2 }, + { name: "djb2", raw: 0x0a7c8367, bit: 7 }, + { name: "murmur3", raw: 0x1b2e904b, bit: 11 }, +]; + +const markHandler: AnnotationHandler = { + name: "mark", + AnnotatedLine: ({ annotation, ...props }) => ( + + ), +}; + +const handlers = [markHandler]; + +function codeForPhase(base: HighlightedCode, phase: HashPhase): HighlightedCode { + const totalLines = (base.code ?? "").split("\n").length; + const from = Math.max(1, Math.min(phase.lines.from, totalLines)); + const to = Math.max(from, Math.min(phase.lines.to, totalLines)); + const lineMarks = []; + for (let n = from; n <= to; n += 1) { + lineMarks.push({ name: "mark", query: "active", fromLineNumber: n, toLineNumber: n }); + } + return { + ...base, + annotations: [...base.annotations.filter((a) => a.name !== "mark"), ...lineMarks], + }; +} + +export function HashDemoClient({ baseCode, phases }: Props) { + const [phaseIndex, setPhaseIndex] = useState(0); + const [playing, setPlaying] = useState(true); + const [inView, setInView] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el || typeof IntersectionObserver === "undefined") { + setInView(true); + return; + } + const obs = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { + threshold: 0.25, + }); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + useEffect(() => { + if (!playing || !inView) return; + const id = setInterval(() => { + setPhaseIndex((i) => (i + 1) % phases.length); + }, STEP_HOLD_MS); + return () => clearInterval(id); + }, [playing, inView, phases.length]); + + const phase = phases[phaseIndex]; + const code = useMemo(() => codeForPhase(baseCode, phase), [baseCode, phase]); + + function goTo(index: number) { + setPlaying(false); + setPhaseIndex(((index % phases.length) + phases.length) % phases.length); + } + + const litBits = HASHES.slice(0, phase.completed).map((h) => h.bit); + + return ( +
+
+ + {phase.label} +
+ + + +
+
+ +
+
+
+        
+ +
+
+ item + "alice" +
+ +
+ {HASHES.map((h, i) => { + const done = i < phase.completed; + const active = phase.activeHash === i; + return ( +
+
{h.name}
+ +
+ {active || done ? `0x${h.raw.toString(16)}` : "..."} +
+ +
{active || done ? h.bit : "?"}
+
+ ); + })} +
+ +
+ {Array.from({ length: SIZE }, (_, i) => { + const isLit = litBits.includes(i); + const isProbing = phase.activeHash !== null && HASHES[phase.activeHash].bit === i; + return ( +
+ + {isLit ? "1" : "0"} +
+ ); + })} +
+ +
{phase.caption}
+
+
+
+ ); +} diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/index.mdx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/index.mdx new file mode 100644 index 0000000000..03e2f9688c --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/index.mdx @@ -0,0 +1,195 @@ +--- +title: "Bloom Filters in Postgres: The Index Type Most Developers Overlook" +slug: "postgres-bloom-index-the-overlooked-postgres-feature" +date: "2026-05-28" +authors: + - "Ankur Datta" +metaTitle: "Bloom Filters in Postgres: The Index Type Most Developers Overlook" +metaDescription: "One bloom index can replace a stack of B-trees on wide tables with many filter combinations. A tour of the concept, the Postgres extension, and a benchmark you can run yourself." +heroImagePath: "/postgres-bloom-index-the-overlooked-postgres-feature/imgs/bloom-filters-in-postgres.png" +metaImagePath: "/postgres-bloom-index-the-overlooked-postgres-feature/imgs/bloom-filters-in-postgres.png" +heroImageAlt: "Bloom Filters in Postgres" +tags: + - "prisma-postgres" + - "education" +series: postgres-features +seriesIndex: 2 +prev: you-dont-need-redis-postgres-already-has-pub-sub +--- + +import { BloomFilterDemo } from "./BloomFilterDemo"; +import { BloomDemoRunner } from "./BloomDemoRunner"; +import { BTreeDemo } from "./BTreeDemo"; +import { HashDemo } from "./HashDemo"; + +This is the second post in a short series on Postgres features you can reach for instead of bolting on another piece of infrastructure. The first one, [You Don't Need Redis, Postgres Already Has Pub/Sub](/you-dont-need-redis-postgres-already-has-pub-sub), built a real-time app on `LISTEN` and `NOTIFY`. This one is about the `bloom` index. + +Postgres has shipped a [`bloom` index](https://www.postgresql.org/docs/current/bloom.html) since 9.6. It can replace a stack of B-trees with a single, much smaller index, and almost nobody uses it. This post is a short tour: what a bloom filter is, how Postgres turns it into an index, when that index wins, and a benchmark on a realistic cache table. + +Bloom filters are everywhere outside Postgres too. Google uses them inside Chrome's safe browsing list, Cassandra and HBase use them to skip SSTable lookups on the read path, Akamai uses them in CDN cache hints, Medium uses them to dedupe recommendations, and Bitcoin SPV clients use them to filter relevant transactions. The Postgres `bloom` extension is the same idea, just turned into an index type. + +## What a bloom filter is + +Picture a tiny cache for usernames you've already seen. The naive version is a `Set`: every username is stored in full, so a million users means a million strings in memory. A bloom filter is the cheap version of that set. + +Instead of storing usernames, a bloom filter keeps a fixed-size array of bits, all starting at zero. To remember a name, hash it a few times, flip the bits at each hash position. To check whether a name was added, hash it again and look at the same positions. If any bit is 0, the name was never added. If all bits are 1, it was probably added. + +The demo below walks through the idea: adding two names, then three lookups that show the three possible outcomes (probably present, definitely absent, and the false positive that gives bloom filters their reputation). + + + +You traded exactness for size, so a few hundred KB of bits can stand in for millions of items. False positives are tolerable because they just trigger a recheck against the real data. False negatives never happen, because we never clear bits we set. Burton Bloom's [1970 paper](https://dl.acm.org/doi/10.1145/362686.362692) has the math. + +### What the hash function actually does + +The "hash it a few times" part is small. Pick three independent hash functions, feed them the item, take each result mod the bit-array size. Those three numbers are the bit positions to flip. The same three positions get probed on lookup, which is why the operation is symmetric. + + + +## From a bloom filter to a bloom index + +The visualization above is one bloom filter over a set of names. A Postgres bloom index applies the same trick row by row: for each row in the table, Postgres computes one small bloom signature over the indexed columns and stores it. The index is a stack of those per-row signatures, the same way a B-tree is a stack of `(value, pointer)` entries, except each entry here is a bit signature instead of an exact value. + +When you query `WHERE tenant_id = $1 AND region = $2`, Postgres hashes the filter values, scans the signatures looking for ones whose bits cover those hashes, then rechecks each candidate against the heap to drop false positives. + +The shape of the change in your schema is small: enable the `bloom` extension once, and create a single index that covers every column you might filter on. + +```sql +CREATE EXTENSION IF NOT EXISTS bloom; + +CREATE INDEX cache_bloom_idx ON cache_entries + USING bloom ( + tenant_id, user_id, endpoint, + locale, region, api_version + ); +``` + +Any subset of those six columns can use the index for equality lookups. You don't have to anticipate every combination up front, and your `SELECT` statements stay exactly the same. The planner just reaches for the bloom index instead of one or two of the B-trees. + +## The wide-table problem + +The shape of table where a bloom index pays off is the wide one: many columns, queried by many different filter combinations. A response cache is a clean example: + +```sql +CREATE TABLE cache_entries ( + id BIGSERIAL PRIMARY KEY, + tenant_id TEXT, + user_id TEXT, + endpoint TEXT, + locale TEXT, + region TEXT, + api_version INT, + payload JSONB +); +``` + +Different code paths look up entries differently: + +- `WHERE tenant_id = $1 AND endpoint = $2` +- `WHERE user_id = $1 AND locale = $2` +- `WHERE tenant_id = $1 AND region = $2 AND api_version = $3` + +The default move is one B-tree per column, plus a few composite indexes for hot paths. That works, but: + +- Six B-trees on six columns add up in disk usage and write amplification. +- A new query that mixes columns differently might still miss the indexes you have. +- You end up maintaining indexes for combinations you never knew the app would ask for. + +## A quick refresher on B-trees + +The demo in the next section compares one bloom index against six B-trees on the same table, so it helps to know what the baseline is doing. + +When you write `CREATE INDEX … ON cache_entries (tenant_id)` without specifying a type, Postgres builds a B-tree. A B-tree is a sorted tree of `(column_value, row_pointer)` entries. To answer `WHERE tenant_id = 't42'`, Postgres descends the tree from the root, finds the leaf page that holds `'t42'`, and follows the pointers to the matching rows. + + + +That shape is why B-trees do so much: + +- Exact lookups are O(log n). +- Range and prefix queries (`<`, `BETWEEN`, `LIKE 'foo%'`) work because entries are kept sorted. +- The result can flow straight into `ORDER BY` without an extra sort. + +But the cost grows with the number of indexes: + +- Each B-tree stores its own copy of the column value plus a row pointer, so disk usage scales with rows × indexes. +- Every `INSERT` and `UPDATE` has to update every relevant B-tree. +- A single B-tree only helps if your `WHERE` clause hits its leading columns, so wide tables end up with many B-trees to cover many query shapes. + +## Demo: cache lookups + +We'll seed a `cache_entries` table with 10,000 rows, run three realistic lookup queries, and compare: + +- **A.** Six B-tree indexes, one per column. +- **B.** One bloom index covering all six columns. + +### Setup + +```bash +bun init -y +bun add pg create-db +bun add -d @types/pg +``` + +The whole demo is a single `index.ts`. It provisions a temporary [Prisma Postgres](https://www.prisma.io/docs/postgres/introduction/npx-create-db) database via the programmatic [`create-db`](https://create-db.prisma.io/) API, seeds the table, then builds each index strategy in turn and times the same three lookups against both. + +### Run it + +Step through what the script does. The left panel highlights the code currently running. The right panel shows the cumulative terminal output and you can click any step to jump between steps. + + + +The bloom index covers the same three lookups with one index that is roughly 2.5x smaller than the stack of six B-trees. Every write updates one index instead of six. + +The bloom index is a touch slower per lookup because Postgres rechecks each candidate row against the heap. For a wide table where any column might be a filter and disk is not free, that is usually a good trade. + +## When to use it + +Use the bloom index when: + +**A table has many columns that get filtered on equality.** Wide tables that store one row per resource and get queried by different attributes are the canonical fit. + +```sql +CREATE TABLE events ( + id BIGSERIAL PRIMARY KEY, + tenant_id TEXT, + user_id TEXT, + device_id TEXT, + action TEXT, + feature_flag TEXT, + -- ... +); +``` + +**Different code paths filter by different subsets of those columns.** No single composite index covers them all; a bloom index covers any subset on equality. + +```sql +SELECT * FROM events WHERE tenant_id = $1 AND action = $2; +SELECT * FROM events WHERE user_id = $1 AND feature_flag = $2; +SELECT * FROM events WHERE tenant_id = $1 AND device_id = $2 AND action = $3; +``` + +**Storage or write amplification from many B-trees is becoming a problem.** Six B-trees on a 10M-row table are not free. + +```sql +SELECT pg_size_pretty(SUM(pg_relation_size(indexrelid))) +FROM pg_index +WHERE indrelid = 'events'::regclass; +``` + +**A small recheck overhead on reads is acceptable.** Bloom indexes fetch a candidate set and re-verify; that costs a few extra heap touches per query. + +## When not to use it + +Skip the bloom index when: + +- **You only ever filter on one or two known columns.** A single B-tree on those columns is faster and cheaper. +- **You need range, prefix, sort, or `LIKE` queries.** Bloom indexes only support equality (`=`, `IN`). +- **Your columns are highly selective and you want index-only scans.** B-trees keep the value, so they can answer some queries without touching the heap. Bloom indexes always recheck. +- **Your table is small enough that sequential scans are already fast.** No index pays for itself until the table is big. + +## Recap + +- Postgres ships a [`bloom` extension](https://www.postgresql.org/docs/current/bloom.html) (in core since 9.6, [available on Prisma Postgres](https://www.prisma.io/docs/postgres/database/postgres-extensions)). +- A bloom index stores one bloom signature per row, so one index covers equality lookups on any subset of its columns. +- It trades a recheck against the heap and no support for ranges or sorts for a much smaller footprint and a single write per row. +- The fit is wide tables with unpredictable filter combinations: caches, lookup tables, audit logs, event streams. diff --git a/apps/blog/content/blog/you-dont-need-redis-postgres-already-has-pub-sub/index.mdx b/apps/blog/content/blog/you-dont-need-redis-postgres-already-has-pub-sub/index.mdx index af0338127b..6bae207c84 100644 --- a/apps/blog/content/blog/you-dont-need-redis-postgres-already-has-pub-sub/index.mdx +++ b/apps/blog/content/blog/you-dont-need-redis-postgres-already-has-pub-sub/index.mdx @@ -11,6 +11,9 @@ metaImagePath: "/you-dont-need-redis-postgres-already-has-pub-sub/imgs/you-dont- heroImageAlt: "You Don't Need Redis, Postgres Already Has Pub/Sub" tags: - "education" +series: postgres-features +seriesIndex: 1 +next: postgres-bloom-index-the-overlooked-postgres-feature --- Postgres includes a lightweight Pub/Sub mechanism through `LISTEN` and `NOTIFY`. In this post, you'll build a small real-time Pub/Sub app with Bun, `pg`, and Prisma Postgres. diff --git a/apps/blog/public/postgres-bloom-index-the-overlooked-postgres-feature/imgs/bloom-filters-in-postgres.png b/apps/blog/public/postgres-bloom-index-the-overlooked-postgres-feature/imgs/bloom-filters-in-postgres.png new file mode 100644 index 0000000000..cb1c5c9d03 Binary files /dev/null and b/apps/blog/public/postgres-bloom-index-the-overlooked-postgres-feature/imgs/bloom-filters-in-postgres.png differ diff --git a/apps/blog/src/app/(blog)/author/[slug]/page.tsx b/apps/blog/src/app/(blog)/author/[slug]/page.tsx new file mode 100644 index 0000000000..7bc4b04c78 --- /dev/null +++ b/apps/blog/src/app/(blog)/author/[slug]/page.tsx @@ -0,0 +1,129 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import type { Metadata } from "next"; + +import { Avatar } from "@prisma/eclipse"; + +import { blog } from "@/lib/source"; +import { + findAuthorProfile, + getAllAuthorProfiles, + getPostsByAuthorSlug, +} from "@/lib/authors-pages"; +import { withBlogBasePath, withBlogBasePathForImageSrc } from "@/lib/url"; +import { BlogGrid, type BlogCardItem } from "@/components/BlogGrid"; +import { BLOG_HOME_TITLE } from "@/lib/blog-metadata"; + +export const revalidate = false; + +interface AuthorPageParams { + slug: string; +} + +function buildCardItems(slug: string): BlogCardItem[] { + const posts = getPostsByAuthorSlug(slug); + return posts.map((post) => { + const data = post.data as { + title?: string; + date?: Date | string; + metaDescription?: string; + authors?: string[]; + heroImagePath?: string; + heroImageAlt?: string; + tags?: string[]; + }; + + let dateISO = ""; + if (data.date) { + const dateObj = new Date(data.date); + if (!Number.isNaN(dateObj.getTime())) { + dateISO = dateObj.toISOString(); + } + } + + const authors = Array.isArray(data.authors) + ? data.authors.filter((a): a is string => typeof a === "string") + : []; + + return { + url: withBlogBasePath(post.url), + title: data.title ?? "", + date: dateISO, + excerpt: data.metaDescription, + author: authors[0] ?? null, + authors, + imageSrc: withBlogBasePathForImageSrc(data.heroImagePath ?? ""), + imageAlt: data.heroImageAlt ?? data.title ?? "", + tags: data.tags, + }; + }); +} + +export default async function AuthorPage(props: { params: Promise }) { + const { slug } = await props.params; + const profile = findAuthorProfile(slug); + if (!profile) notFound(); + + const items = buildCardItems(slug); + const avatarSrc = profile.imageSrc ? withBlogBasePathForImageSrc(profile.imageSrc) : null; + + return ( +
+ + ← Back to Blog + + +
+ {avatarSrc ? ( + + ) : null} +
+
+ Author · {items.length} {items.length === 1 ? "post" : "posts"} +
+

+ {profile.name} +

+
+
+ + +
+ ); +} + +export function generateStaticParams(): AuthorPageParams[] { + return getAllAuthorProfiles().map((p) => ({ slug: p.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { slug } = await params; + const profile = findAuthorProfile(slug); + if (!profile) return {}; + + const title = `${profile.name} — ${BLOG_HOME_TITLE}`; + const description = `Posts by ${profile.name} on the Prisma blog.`; + + return { + title, + description, + alternates: { canonical: withBlogBasePath(`/author/${profile.slug}`) }, + openGraph: { + type: "website", + title, + description, + url: withBlogBasePath(`/author/${profile.slug}`), + }, + twitter: { + card: "summary_large_image", + title, + description, + }, + }; +} + +void blog; diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css index bb31ef261b..80f1b3f6b8 100644 --- a/apps/blog/src/app/global.css +++ b/apps/blog/src/app/global.css @@ -143,7 +143,9 @@ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px transparent; - transition: box-shadow 240ms ease, border-color 240ms ease; + transition: + box-shadow 240ms ease, + border-color 240ms ease; } .agent-prompt[data-phase="typing"] .agent-prompt-bubble { @@ -204,8 +206,13 @@ } @keyframes agent-prompt-caret-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } } .agent-prompt-frame { @@ -247,8 +254,15 @@ } @keyframes agent-prompt-pulse-dot { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(0.85); } + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.85); + } } .agent-prompt-skill-label { @@ -264,8 +278,9 @@ } .agent-prompt[data-phase="morphing"] .agent-prompt-frame { - box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-foreground-ppg) 60%, transparent), - 0 0 0 4px color-mix(in srgb, var(--color-foreground-ppg) 12%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--color-foreground-ppg) 60%, transparent), + 0 0 0 4px color-mix(in srgb, var(--color-foreground-ppg) 12%, transparent); } .agent-prompt-filename { @@ -295,7 +310,9 @@ background: transparent; color: var(--color-foreground-muted); cursor: pointer; - transition: background-color 120ms ease, color 120ms ease; + transition: + background-color 120ms ease, + color 120ms ease; } .agent-prompt-toggle:hover { @@ -359,7 +376,9 @@ color: var(--color-foreground-neutral); opacity: 0; transform: translateY(-4px); - transition: opacity 220ms ease, transform 220ms ease; + transition: + opacity 220ms ease, + transform 220ms ease; pointer-events: none; } @@ -383,12 +402,24 @@ animation: agent-prompt-dot 1.2s ease-in-out infinite; } -.agent-prompt-dots span:nth-child(2) { animation-delay: 0.18s; } -.agent-prompt-dots span:nth-child(3) { animation-delay: 0.36s; } +.agent-prompt-dots span:nth-child(2) { + animation-delay: 0.18s; +} +.agent-prompt-dots span:nth-child(3) { + animation-delay: 0.36s; +} @keyframes agent-prompt-dot { - 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); } - 40% { opacity: 1; transform: translateY(-2px); } + 0%, + 80%, + 100% { + opacity: 0.3; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-2px); + } } .agent-prompt-terminal { @@ -426,9 +457,15 @@ background: #3a4148; } -.agent-prompt-terminal-dots span:nth-child(1) { background: #ff5f57; } -.agent-prompt-terminal-dots span:nth-child(2) { background: #febc2e; } -.agent-prompt-terminal-dots span:nth-child(3) { background: #28c840; } +.agent-prompt-terminal-dots span:nth-child(1) { + background: #ff5f57; +} +.agent-prompt-terminal-dots span:nth-child(2) { + background: #febc2e; +} +.agent-prompt-terminal-dots span:nth-child(3) { + background: #28c840; +} .agent-prompt-terminal-title { flex: 1; @@ -480,3 +517,1126 @@ background: color-mix(in srgb, var(--agent-prompt-terminal-fg) 12%, transparent); color: var(--agent-prompt-terminal-fg); } + +.bloom-demo { + --bloom-cell-empty: var(--color-background-default); + --bloom-cell-empty-fg: var(--color-foreground-muted); + --bloom-cell-on-bg: color-mix(in srgb, #5fb878 22%, transparent); + --bloom-cell-on-border: #5fb878; + --bloom-cell-on-fg: var(--color-foreground-neutral); + --bloom-cell-probe-present: #5fb878; + --bloom-cell-probe-absent: #d2a8ff; + --bloom-cell-probe-fp: #ffa657; + --bloom-cell-flipping-shadow: 0 0 0 3px color-mix(in srgb, #5fb878 35%, transparent); + margin: 1.75rem 0; + display: flex; + flex-direction: column; + border: 1px solid var(--color-stroke-neutral); + border-radius: 10px; + background: var(--color-background-default); + overflow: hidden; +} + +.bloom-demo-header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-stroke-neutral); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.8125rem; + color: var(--color-foreground-neutral); +} + +.bloom-demo-step { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-foreground-ppg) 12%, transparent); + color: var(--color-foreground-muted); + font-size: 0.6875rem; + letter-spacing: 0.06em; +} + +.bloom-demo-label { + flex: 1 1 auto; + color: var(--color-foreground-neutral); +} + +.bloom-demo-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 5px; + background: transparent; + color: var(--color-foreground-muted); + cursor: pointer; +} + +.bloom-demo-toggle:hover { + background: color-mix(in srgb, var(--color-foreground-ppg) 8%, transparent); + color: var(--color-foreground-neutral); +} + +.bloom-demo-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 0; +} + +@media (max-width: 720px) { + .bloom-demo-body { + grid-template-columns: minmax(0, 1fr); + } +} + +.bloom-demo-code { + padding: 14px 0; + border-right: 1px solid var(--color-stroke-neutral); + font: normal 400 0.8125rem/1.7 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + background: var(--color-background-subtle, transparent); + min-height: 220px; +} + +@media (max-width: 720px) { + .bloom-demo-code { + border-right: none; + border-bottom: 1px solid var(--color-stroke-neutral); + } +} + +.bloom-demo-code pre { + margin: 0; + background: transparent !important; + font: inherit; + white-space: pre; +} + +.bloom-demo-code pre > div > div { + padding: 0 18px; +} + +.bloom-demo-array-wrap { + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px; +} + +.bloom-demo-array { + display: grid; + grid-template-columns: repeat(16, minmax(0, 1fr)); + gap: 4px; +} + +@media (max-width: 540px) { + .bloom-demo-array { + grid-template-columns: repeat(8, minmax(0, 1fr)); + } +} + +.bloom-demo-cell { + position: relative; + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 6px; + border: 1px solid var(--color-stroke-neutral); + background: var(--bloom-cell-empty); + color: var(--bloom-cell-empty-fg); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; + transition: + background 280ms ease, + border-color 280ms ease, + color 280ms ease, + box-shadow 280ms ease, + transform 280ms ease; +} + +.bloom-demo-cell[data-bit="1"] { + background: var(--bloom-cell-on-bg); + border-color: var(--bloom-cell-on-border); + color: var(--bloom-cell-on-fg); +} + +.bloom-demo-cell[data-flipping="true"] { + box-shadow: var(--bloom-cell-flipping-shadow); + animation: bloom-flip 540ms ease; +} + +.bloom-demo-cell[data-probe="true"] { + outline: 2px dashed var(--bloom-cell-probe-present); + outline-offset: 2px; +} + +.bloom-demo[data-verdict="absent"] .bloom-demo-cell[data-probe="true"][data-bit="0"] { + outline-color: var(--bloom-cell-probe-absent); + background: color-mix(in srgb, var(--bloom-cell-probe-absent) 18%, transparent); +} + +.bloom-demo[data-verdict="false-positive"] .bloom-demo-cell[data-probe="true"] { + outline-color: var(--bloom-cell-probe-fp); +} + +.bloom-demo-cell-index { + font-size: 0.625rem; + opacity: 0.6; + line-height: 1; +} + +.bloom-demo-cell-value { + font-weight: 600; + font-size: 0.875rem; + line-height: 1.1; +} + +@keyframes bloom-flip { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.18); + } + 100% { + transform: scale(1); + } +} + +.bloom-demo-caption { + font-size: 0.875rem; + color: var(--color-foreground-muted); + line-height: 1.5; +} + +.bloom-demo-verdict { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.8125rem; + color: var(--color-foreground-neutral); + background: color-mix(in srgb, var(--color-foreground-ppg) 6%, transparent); + border: 1px solid var(--color-stroke-neutral); + min-height: 38px; +} + +.bloom-demo-verdict[data-verdict="present"] { + background: color-mix(in srgb, var(--bloom-cell-probe-present) 12%, transparent); + border-color: color-mix( + in srgb, + var(--bloom-cell-probe-present) 35%, + var(--color-stroke-neutral) + ); +} + +.bloom-demo-verdict[data-verdict="absent"] { + background: color-mix(in srgb, var(--bloom-cell-probe-absent) 12%, transparent); + border-color: color-mix(in srgb, var(--bloom-cell-probe-absent) 35%, var(--color-stroke-neutral)); +} + +.bloom-demo-verdict[data-verdict="false-positive"] { + background: color-mix(in srgb, var(--bloom-cell-probe-fp) 14%, transparent); + border-color: color-mix(in srgb, var(--bloom-cell-probe-fp) 40%, var(--color-stroke-neutral)); +} + +.bloom-demo-verdict[data-verdict="idle"] { + color: var(--color-foreground-muted); +} + +.bloom-demo-verdict-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 18%, transparent); + font-weight: 700; + font-size: 0.75rem; +} + +.bloom-demo-verdict-placeholder { + opacity: 0.7; + font-style: italic; +} + +.bloom-demo-nav { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.bloom-demo-steps { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-stroke-neutral); + background: color-mix(in srgb, var(--color-foreground-ppg) 4%, transparent); +} + +.bloom-demo-step-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: transparent; + border: 1px solid var(--color-stroke-neutral); + color: var(--color-foreground-muted); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease; +} + +.bloom-demo-step-pill:hover { + color: var(--color-foreground-neutral); + border-color: color-mix(in srgb, var(--color-foreground-ppg) 35%, var(--color-stroke-neutral)); +} + +.bloom-demo-step-pill[data-active="true"] { + background: color-mix(in srgb, #5fb878 18%, transparent); + border-color: #5fb878; + color: var(--color-foreground-neutral); +} + +.bloom-demo-step-pill-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 18%, transparent); + font-size: 0.625rem; + font-weight: 600; +} + +.runner { + --runner-terminal-bg: #0c0e10; + --runner-terminal-fg: #e6e6e6; + --runner-terminal-muted: #8b949e; + --runner-terminal-stroke: #1f242a; + --runner-mark-bg: color-mix(in srgb, #5fb878 20%, transparent); + --runner-mark-border: #5fb878; + container-type: inline-size; + margin: 1.75rem 0; + display: flex; + flex-direction: column; + border: 1px solid var(--color-stroke-neutral); + border-radius: 10px; + background: var(--color-background-default); + overflow: hidden; +} + +.runner-header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-stroke-neutral); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.8125rem; +} + +.runner-filename { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--color-foreground-neutral); + flex: 1 1 auto; +} + +.runner-filename-dots { + display: inline-flex; + gap: 4px; +} + +.runner-filename-dots span { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--color-stroke-neutral); +} + +.runner-step-counter { + color: var(--color-foreground-neutral); + font-size: 0.75rem; + font-weight: 600; +} + +.runner-step-counter-label { + color: var(--color-foreground-muted); + font-weight: 400; +} + +.runner-nav { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.runner-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + background: transparent; + border: 1px solid var(--color-stroke-neutral); + color: var(--color-foreground-neutral); + cursor: pointer; +} + +.runner-toggle:hover { + background: color-mix(in srgb, var(--color-foreground-ppg) 10%, transparent); + border-color: color-mix(in srgb, var(--color-foreground-ppg) 35%, var(--color-stroke-neutral)); +} + +.runner-steps { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-stroke-neutral); + background: color-mix(in srgb, var(--color-foreground-ppg) 4%, transparent); +} + +.runner-step-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: transparent; + border: 1px solid var(--color-stroke-neutral); + color: var(--color-foreground-muted); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease; +} + +.runner-step-pill:hover { + color: var(--color-foreground-neutral); + border-color: color-mix(in srgb, var(--color-foreground-ppg) 35%, var(--color-stroke-neutral)); +} + +.runner-step-pill[data-active="true"] { + background: color-mix(in srgb, #5fb878 18%, transparent); + border-color: var(--runner-mark-border); + color: var(--color-foreground-neutral); +} + +.runner-step-pill-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 18%, transparent); + font-size: 0.625rem; + font-weight: 600; +} + +.runner-caption { + padding: 10px 14px 12px; + font-size: 0.875rem; + color: var(--color-foreground-muted); + border-bottom: 1px solid var(--color-stroke-neutral); + background: var(--color-background-default); +} + +.runner-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0; +} + +@container (max-width: 720px) { + .runner-body { + grid-template-columns: minmax(0, 1fr); + } +} + +.runner-pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.runner-pane-code { + border-right: 1px solid var(--color-stroke-neutral); + background: var(--color-background-default); +} + +@container (max-width: 720px) { + .runner-pane-code { + border-right: none; + border-bottom: 1px solid var(--color-stroke-neutral); + } +} + +.runner-pane-terminal { + background: var(--runner-terminal-bg); + color: var(--runner-terminal-fg); +} + +.runner-pane-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 12px; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-foreground-muted); + border-bottom: 1px solid var(--color-stroke-neutral); + background: color-mix(in srgb, var(--color-foreground-ppg) 4%, transparent); +} + +.runner-pane-label-terminal { + color: var(--runner-terminal-muted); + border-bottom: 1px solid var(--runner-terminal-stroke); + background: color-mix(in srgb, var(--runner-terminal-fg) 6%, transparent); +} + +.runner-copy { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.6875rem; + text-transform: none; + letter-spacing: 0; + color: var(--runner-terminal-muted); + background: transparent; + border: 1px solid var(--runner-terminal-stroke); + border-radius: 4px; + cursor: pointer; + transition: + color 160ms ease, + background 160ms ease, + border-color 160ms ease; +} + +.runner-copy:hover { + color: var(--runner-terminal-fg); + background: color-mix(in srgb, var(--runner-terminal-fg) 10%, transparent); + border-color: color-mix(in srgb, var(--runner-terminal-fg) 30%, transparent); +} + +.runner-copy:focus-visible { + outline: 2px solid var(--runner-mark-border); + outline-offset: 2px; +} + +.runner-code { + padding: 12px 0; + font: normal 400 0.8125rem/1.7 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + overflow: auto; + height: 440px; +} + +.runner-code pre { + margin: 0; + background: transparent !important; + font: inherit; + white-space: pre; + tab-size: 2; +} + +.runner-code pre > div > div { + padding: 0 18px; +} + +.runner-code [data-mark="active"] { + background: var(--runner-mark-bg); + box-shadow: inset 3px 0 0 var(--runner-mark-border); +} + +.runner-terminal-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 12px 14px; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.8125rem; + line-height: 1.65; + white-space: pre; + height: 440px; +} + +.runner-terminal-line { + display: block; + width: max-content; + min-width: 100%; + box-sizing: border-box; + transition: + opacity 220ms ease, + color 220ms ease; +} + +.runner-terminal-line[data-step-state="future"] { + opacity: 0.28; + color: var(--runner-terminal-muted); +} + +.runner-terminal-line[data-step-state="past"] { + opacity: 0.6; + color: var(--runner-terminal-fg); +} + +.runner-terminal-line[data-step-state="active"] { + opacity: 1; + color: var(--runner-terminal-fg); + background: color-mix(in srgb, var(--runner-mark-border) 14%, transparent); + box-shadow: inset 3px 0 0 var(--runner-mark-border); + padding-left: 8px; + margin-left: -14px; +} + +.btree-demo, +.hash-demo { + --demo-accent: #5fb878; + --demo-accent-bg: color-mix(in srgb, #5fb878 18%, transparent); + --demo-accent-soft: color-mix(in srgb, #5fb878 8%, transparent); + margin: 1.75rem 0; + display: flex; + flex-direction: column; + border: 1px solid var(--color-stroke-neutral); + border-radius: 10px; + background: var(--color-background-default); + overflow: hidden; + container-type: inline-size; +} + +.btree-demo-header, +.hash-demo-header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-stroke-neutral); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.8125rem; +} + +.btree-demo-step, +.hash-demo-step { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-foreground-ppg) 12%, transparent); + color: var(--color-foreground-muted); + font-size: 0.6875rem; + letter-spacing: 0.06em; +} + +.btree-demo-label, +.hash-demo-label { + flex: 1 1 auto; + color: var(--color-foreground-neutral); + font-weight: 600; +} + +.btree-demo-nav, +.hash-demo-nav { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.btree-demo-toggle, +.hash-demo-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + background: transparent; + border: 1px solid var(--color-stroke-neutral); + color: var(--color-foreground-neutral); + cursor: pointer; +} + +.btree-demo-toggle:hover, +.hash-demo-toggle:hover { + background: color-mix(in srgb, var(--color-foreground-ppg) 10%, transparent); + border-color: color-mix(in srgb, var(--color-foreground-ppg) 35%, var(--color-stroke-neutral)); +} + +.btree-demo-steps { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-stroke-neutral); + background: color-mix(in srgb, var(--color-foreground-ppg) 4%, transparent); +} + +.btree-demo-step-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: transparent; + border: 1px solid var(--color-stroke-neutral); + color: var(--color-foreground-muted); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease; +} + +.btree-demo-step-pill:hover { + color: var(--color-foreground-neutral); + border-color: color-mix(in srgb, var(--color-foreground-ppg) 35%, var(--color-stroke-neutral)); +} + +.btree-demo-step-pill[data-active="true"] { + background: var(--demo-accent-bg); + border-color: var(--demo-accent); + color: var(--color-foreground-neutral); +} + +.btree-demo-step-pill-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 18%, transparent); + font-size: 0.625rem; + font-weight: 600; +} + +.btree-demo-body, +.hash-demo-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + gap: 0; +} + +@container (max-width: 720px) { + .btree-demo-body, + .hash-demo-body { + grid-template-columns: minmax(0, 1fr); + } +} + +.btree-demo-code, +.hash-demo-code { + padding: 12px 0; + border-right: 1px solid var(--color-stroke-neutral); + font: normal 400 0.8125rem/1.7 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + overflow: auto; + background: var(--color-background-default); +} + +@container (max-width: 720px) { + .btree-demo-code, + .hash-demo-code { + border-right: none; + border-bottom: 1px solid var(--color-stroke-neutral); + } +} + +.btree-demo-code pre, +.hash-demo-code pre { + margin: 0; + background: transparent !important; + font: inherit; + white-space: pre; + tab-size: 2; +} + +.btree-demo-code pre > div > div, +.hash-demo-code pre > div > div { + padding: 0 18px; +} + +.btree-demo-code [data-mark="active"], +.hash-demo-code [data-mark="active"] { + background: var(--demo-accent-bg); + box-shadow: inset 3px 0 0 var(--demo-accent); +} + +.btree-demo-tree-wrap, +.hash-demo-viz { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + min-width: 0; +} + +.btree-demo-query { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + align-self: flex-start; + border-radius: 999px; + background: color-mix(in srgb, var(--demo-accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--demo-accent) 35%, var(--color-stroke-neutral)); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; +} + +.btree-demo-query-label { + color: var(--color-foreground-muted); +} + +.btree-demo-query-value { + color: var(--color-foreground-neutral); + font-weight: 600; +} + +.btree-demo-query-placeholder { + background: transparent; + border-style: dashed; + color: var(--color-foreground-muted); + font-style: italic; +} + +.btree-demo-tree { + display: flex; + flex-direction: column; + gap: 6px; +} + +.btree-demo-row { + display: flex; + align-items: stretch; + gap: 8px; +} + +.btree-demo-row-root { + justify-content: center; +} + +.btree-demo-row-leaves { + flex-wrap: wrap; +} + +.btree-demo-row-leaves > * { + flex: 1 1 0; + min-width: 0; +} + +.btree-demo-node { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--color-stroke-neutral); + background: var(--color-background-default); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; + transition: + background 200ms ease, + border-color 200ms ease, + box-shadow 200ms ease; +} + +.btree-demo-node[data-active="true"] { + background: var(--demo-accent-soft); + border-color: var(--demo-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--demo-accent) 16%, transparent); +} + +.btree-demo-node-label { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-foreground-muted); +} + +.btree-demo-keys { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.btree-demo-key { + padding: 2px 8px; + border-radius: 4px; + background: color-mix(in srgb, var(--color-foreground-ppg) 10%, transparent); + color: var(--color-foreground-neutral); + font-weight: 600; +} + +.btree-demo-edges { + display: grid; + grid-template-columns: repeat(3, 1fr); + height: 18px; + margin: -2px 0 -2px; +} + +.btree-demo-edge { + position: relative; + border-left: 1px dashed color-mix(in srgb, var(--color-foreground-muted) 40%, transparent); + margin-left: 50%; + transition: border-color 200ms ease; +} + +.btree-demo-edge[data-active="true"] { + border-left-color: var(--demo-accent); + border-left-style: solid; +} + +.btree-demo-leaf { + font-size: 0.6875rem; +} + +.btree-demo-leaf-rows { + display: flex; + flex-direction: column; + gap: 2px; +} + +.btree-demo-leaf-entry { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 4px; + border-radius: 4px; + color: var(--color-foreground-muted); + transition: + background 200ms ease, + color 200ms ease; +} + +.btree-demo-leaf-entry[data-matched="true"] { + background: var(--demo-accent-bg); + color: var(--color-foreground-neutral); + font-weight: 600; +} + +.btree-demo-leaf-key { + min-width: 28px; +} + +.btree-demo-leaf-arrow { + opacity: 0.6; +} + +.btree-demo-caption, +.hash-demo-caption { + font-size: 0.875rem; + color: var(--color-foreground-muted); + line-height: 1.5; +} + +.btree-demo-result { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + padding: 6px 10px; + border-radius: 8px; + background: var(--demo-accent-bg); + border: 1px solid var(--demo-accent); + font-size: 0.8125rem; + color: var(--color-foreground-neutral); +} + +.btree-demo-result-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--demo-accent); + color: var(--color-background-default); + font-weight: 700; + font-size: 0.6875rem; +} + +.hash-demo-input { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-foreground-ppg) 8%, transparent); + border: 1px solid var(--color-stroke-neutral); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; +} + +.hash-demo-input-label { + color: var(--color-foreground-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.625rem; +} + +.hash-demo-input-value { + color: var(--color-foreground-neutral); + font-weight: 600; +} + +.hash-demo-pipes { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.hash-demo-pipe { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 8px; + border-radius: 8px; + border: 1px solid var(--color-stroke-neutral); + background: var(--color-background-default); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.6875rem; + text-align: center; + transition: + background 200ms ease, + border-color 200ms ease, + box-shadow 200ms ease; +} + +.hash-demo-pipe[data-state="active"] { + background: var(--demo-accent-soft); + border-color: var(--demo-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--demo-accent) 16%, transparent); +} + +.hash-demo-pipe[data-state="done"] { + background: var(--color-background-default); + border-color: color-mix(in srgb, var(--demo-accent) 40%, var(--color-stroke-neutral)); +} + +.hash-demo-pipe-name { + color: var(--color-foreground-neutral); + font-weight: 600; + font-size: 0.75rem; +} + +.hash-demo-pipe-arrow { + color: var(--color-foreground-muted); + font-size: 0.875rem; + line-height: 1; +} + +.hash-demo-pipe-raw { + color: var(--color-foreground-muted); + font-size: 0.625rem; + letter-spacing: 0.04em; +} + +.hash-demo-pipe-mod { + color: var(--color-foreground-muted); + font-size: 0.625rem; +} + +.hash-demo-pipe-bit { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 28px; + padding: 0 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--color-foreground-ppg) 10%, transparent); + color: var(--color-foreground-neutral); + font-weight: 700; + font-size: 0.875rem; +} + +.hash-demo-pipe[data-state="active"] .hash-demo-pipe-bit, +.hash-demo-pipe[data-state="done"] .hash-demo-pipe-bit { + background: var(--demo-accent); + color: var(--color-background-default); +} + +.hash-demo-array { + display: grid; + grid-template-columns: repeat(16, minmax(0, 1fr)); + gap: 4px; +} + +@container (max-width: 480px) { + .hash-demo-array { + grid-template-columns: repeat(8, minmax(0, 1fr)); + } +} + +.hash-demo-cell { + position: relative; + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 6px; + border: 1px solid var(--color-stroke-neutral); + background: var(--color-background-default); + color: var(--color-foreground-muted); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; + transition: + background 240ms ease, + border-color 240ms ease, + color 240ms ease, + box-shadow 240ms ease; +} + +.hash-demo-cell[data-lit="true"] { + background: var(--demo-accent-bg); + border-color: var(--demo-accent); + color: var(--color-foreground-neutral); +} + +.hash-demo-cell[data-probing="true"] { + outline: 2px dashed var(--demo-accent); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--demo-accent) 18%, transparent); +} + +.hash-demo-cell-index { + font-size: 0.625rem; + opacity: 0.6; + line-height: 1; +} + +.hash-demo-cell-value { + font-weight: 600; + font-size: 0.875rem; + line-height: 1.1; +} diff --git a/apps/blog/src/components/AgentPrompt/index.tsx b/apps/blog/src/components/AgentPrompt/index.tsx index fc93968a83..37af5dfe62 100644 --- a/apps/blog/src/components/AgentPrompt/index.tsx +++ b/apps/blog/src/components/AgentPrompt/index.tsx @@ -20,7 +20,11 @@ function extractMarks(value: string): { const out: string[] = []; const marked: number[] = []; for (const line of lines) { - if (/^\s*\/\/\s*!mark(?:\s|$)/.test(line) || /^\s*#\s*!mark(?:\s|$)/.test(line)) { + if ( + /^\s*\/\/\s*!mark(?:\s|$)/.test(line) || + /^\s*#\s*!mark(?:\s|$)/.test(line) || + /^\s*--\s*!mark(?:\s|$)/.test(line) + ) { // The next source line is marked. marked.push(out.length + 1); continue; @@ -30,17 +34,19 @@ function extractMarks(value: string): { return { value: out.join("\n"), markedLines: marked }; } -async function highlightWithMarks( - value: string, - language: string, -): Promise { +async function highlightWithMarks(value: string, language: string): Promise { const { value: stripped, markedLines } = extractMarks(value); const result = (await highlight( { value: stripped, lang: language, meta: "" }, "github-from-css", )) as HighlightedCode; const injected = markedLines - .filter((n) => !result.annotations.some((a) => a.name === "mark" && "fromLineNumber" in a && a.fromLineNumber === n)) + .filter( + (n) => + !result.annotations.some( + (a) => a.name === "mark" && "fromLineNumber" in a && a.fromLineNumber === n, + ), + ) .map((n) => ({ name: "mark", query: "", @@ -65,16 +71,10 @@ export async function AgentPrompt({ }: AgentPromptProps) { const hasCode = before != null && after != null; const [beforeHL, afterHL] = hasCode - ? await Promise.all([ - highlightWithMarks(before, language), - highlightWithMarks(after, language), - ]) + ? await Promise.all([highlightWithMarks(before, language), highlightWithMarks(after, language)]) : [undefined, undefined]; const maxCodeLines = hasCode - ? Math.max( - countLines(before as string), - countLines(after as string), - ) + ? Math.max(countLines(before as string), countLines(after as string)) : undefined; return ( diff --git a/apps/blog/src/components/AuthorAvatarGroup.tsx b/apps/blog/src/components/AuthorAvatarGroup.tsx index 9f09e0b0ec..e09d0510fc 100644 --- a/apps/blog/src/components/AuthorAvatarGroup.tsx +++ b/apps/blog/src/components/AuthorAvatarGroup.tsx @@ -1,12 +1,18 @@ +import Link from "next/link"; import { Avatar } from "@prisma/eclipse"; -import { getAuthorProfiles } from "@/lib/authors"; +import { getAuthorProfiles, toAuthorSlug } from "@/lib/authors"; import { withBlogBasePathForImageSrc } from "@/lib/url"; type AuthorAvatarGroupProps = { authors?: string[]; className?: string; + linkAuthors?: boolean; }; -export function AuthorAvatarGroup({ authors = [], className }: AuthorAvatarGroupProps) { +export function AuthorAvatarGroup({ + authors = [], + className, + linkAuthors = true, +}: AuthorAvatarGroupProps) { const profiles = getAuthorProfiles(authors); if (profiles.length === 0) { @@ -29,7 +35,31 @@ export function AuthorAvatarGroup({ authors = [], className }: AuthorAvatarGroup ) : null, )} - {profiles.map((profile) => profile.name).join(", ")} + + {profiles.map((profile, i) => { + const slug = toAuthorSlug(profile.name); + const sep = i < profiles.length - 1 ? ", " : ""; + if (linkAuthors && slug) { + return ( + + + {profile.name} + + {sep} + + ); + } + return ( + + {profile.name} + {sep} + + ); + })} + ); } diff --git a/apps/blog/src/lib/authors-pages.ts b/apps/blog/src/lib/authors-pages.ts new file mode 100644 index 0000000000..7236a0621a --- /dev/null +++ b/apps/blog/src/lib/authors-pages.ts @@ -0,0 +1,69 @@ +import { blog } from "./source"; +import { getAuthorImageSrc, normalizeAuthorName, toAuthorSlug } from "./authors"; + +type BlogPage = ReturnType[number]; + +export type AuthorProfile = { + slug: string; + name: string; + imageSrc: string | null; + postCount: number; +}; + +function readAuthors(page: BlogPage): string[] { + const raw = (page.data as { authors?: unknown }).authors; + if (!Array.isArray(raw)) return []; + return raw.filter((a): a is string => typeof a === "string" && a.trim().length > 0); +} + +/** + * Resolves all unique author slugs across the blog corpus. Each slug maps to + * a display name (the first non-normalized form encountered) and the count + * of posts in which the author appears. + */ +export function getAllAuthorProfiles(): AuthorProfile[] { + const bySlug = new Map(); + for (const page of blog.getPages()) { + for (const name of readAuthors(page)) { + const slug = toAuthorSlug(name); + if (!slug) continue; + const entry = bySlug.get(slug); + if (entry) { + entry.count += 1; + } else { + bySlug.set(slug, { name: name.trim(), count: 1 }); + } + } + } + return Array.from(bySlug.entries()) + .map(([slug, { name, count }]) => ({ + slug, + name, + imageSrc: getAuthorImageSrc(name), + postCount: count, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function findAuthorProfile(slug: string): AuthorProfile | null { + const normalized = slug.toLowerCase(); + const all = getAllAuthorProfiles(); + return all.find((p) => p.slug === normalized) ?? null; +} + +/** + * Returns all posts (newest first) that list the author whose slug matches. + */ +export function getPostsByAuthorSlug(slug: string): BlogPage[] { + const normalized = slug.toLowerCase(); + return blog + .getPages() + .filter((page) => readAuthors(page).some((name) => toAuthorSlug(name) === normalized)) + .sort((a, b) => { + const ad = new Date((a.data as { date?: Date }).date ?? 0).getTime(); + const bd = new Date((b.data as { date?: Date }).date ?? 0).getTime(); + return bd - ad; + }); +} + +export { normalizeAuthorName, toAuthorSlug }; diff --git a/apps/blog/src/lib/series-registry.ts b/apps/blog/src/lib/series-registry.ts index e91b0b17ff..d0adef3448 100644 --- a/apps/blog/src/lib/series-registry.ts +++ b/apps/blog/src/lib/series-registry.ts @@ -62,6 +62,12 @@ export const seriesRegistry = { description: "Build code-first GraphQL servers with Nexus, from the problems of schema-first to using Nexus with a database.", }, + "postgres-features": { + title: "Postgres features you can reach for instead of more infrastructure", + description: + "A short series on Postgres features that quietly replace pieces of your stack. Pub/Sub via LISTEN and NOTIFY, the bloom index for wide tables, and more.", + featured: true, + }, } as const satisfies Record; export type SeriesKey = keyof typeof seriesRegistry; diff --git a/apps/docs/cspell.json b/apps/docs/cspell.json index d7efb2b6c0..00958b4716 100644 --- a/apps/docs/cspell.json +++ b/apps/docs/cspell.json @@ -29,6 +29,10 @@ "betterauth", "bigserial", "BIGSERIAL", + "btree", + "btrees", + "Btree", + "Btrees", "bindefault", "biograpy", "blobshape",