From aa0c8c172dfabd4562aee49aaa8a53ad5b7b376c Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Thu, 28 May 2026 14:02:20 +0200 Subject: [PATCH 01/11] feat(blog): add bloom filters in Postgres blog with CodeHike demos Adds a new post explaining the Postgres bloom index for wide cache-style tables, with a CodeHike-powered animated walkthrough of the bloom algorithm (BloomFilterDemo) and two AgentPrompt animations for the SQL schema morph and a recorded run of the bun demo. Extends AgentPrompt's mark extractor to recognize SQL "-- !mark" comments so the schema morph can highlight added lines. Sample numbers were captured by running the live demo in demos-for-content/bun-bloom-filters against a temporary Prisma Postgres DB. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BloomFilterDemo.tsx | 45 +++ .../BloomFilterDemoClient.tsx | 258 ++++++++++++++ .../index.mdx | 210 ++++++++++++ .../snippets.ts | 40 +++ apps/blog/src/app/global.css | 318 +++++++++++++++++- .../blog/src/components/AgentPrompt/index.tsx | 28 +- 6 files changed, 869 insertions(+), 30 deletions(-) create mode 100644 apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemo.tsx create mode 100644 apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemoClient.tsx create mode 100644 apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/index.mdx create mode 100644 apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts 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..a819895d4d --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomFilterDemoClient.tsx @@ -0,0 +1,258 @@ +"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 { Pause, Play } from "lucide-react"; + +type Verdict = "idle" | "present" | "absent" | "false-positive"; + +type Phase = { + step: number; + label: string; + caption: string; + bits: boolean[]; + flipping: number[]; + probe: number[]; + verdict: Verdict; + subject?: string; +}; + +const SIZE = 16; +const STEP_HOLD_MS = 2600; + +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", + caption: "A 16-bit array, all zeros. Nothing has been added yet.", + bits: makeBits([]), + flipping: [], + probe: [], + verdict: "idle", + }, + { + step: 1, + label: '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")', + 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")', + 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")', + 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")', + 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];
+
+  return (
+    
+
+ + {phase.label} + +
+ +
+
+ +
+ +
+
+ {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/index.mdx b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/index.mdx new file mode 100644 index 0000000000..fb94d75086 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/index.mdx @@ -0,0 +1,210 @@ +--- +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 btrees on wide tables with many filter combinations. A tour of the concept, the Postgres extension, and a benchmark you can run yourself." +tags: + - "prisma-postgres" + - "education" +excerpt: "Postgres has shipped a bloom index since 9.6. It can replace a stack of btrees with a single, much smaller index, and almost nobody uses it." +--- + +import { BloomFilterDemo } from "./BloomFilterDemo"; +import { indexesBefore, indexesAfter, demoTerminalLines } from "./snippets"; + +Postgres has shipped a [`bloom` index](https://www.postgresql.org/docs/current/bloom.html) since 9.6. It can replace a stack of btrees with a single, much smaller index, and almost nobody uses it. This post is a short tour: what it is, when it wins, and a benchmark on a realistic cache table. + +## The problem it solves + +Plenty of tables get 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 btree per column, plus a few composite indexes for hot paths. That works, but: + +- Six btrees on six columns add up. They take disk space and slow every write. +- 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. + +## 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, it 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 whole 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. 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. + +That's the whole idea. Burton Bloom's [1970 paper](https://dl.acm.org/doi/10.1145/362686.362692) has the math. + +## How Postgres turns this into an index + +For each row, Postgres computes a small bloom signature over the indexed columns and stores it. + +When you run an equality query, Postgres hashes your filter values, scans the signatures for matches, and rechecks each candidate against the heap. + +The shape of the change is small: drop the stack of btrees, enable the `bloom` extension once, and create a single index that covers every column you might filter on. + + + +One index, and any subset of those six columns can use it for equality lookups. You don't have to anticipate every combination up front. False positives just mean a few extra heap fetches on the way to the right answer. + +The reads do not have to change. A `SELECT id FROM cache_entries WHERE tenant_id = $1 AND region = $2 AND api_version = $3` still works the same; the planner just uses the bloom index instead of one or two of the btrees. + +## Demo: cache lookups + +We'll seed a `cache_entries` table with 10,000 rows, run three realistic lookup queries, and compare: + +- **A.** Six btree 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 (no signup required), seeds the table, then builds each index strategy in turn and times the same three lookups against both. + +```ts +// index.ts +import { create, isDatabaseSuccess } from "create-db"; +import { Client } from "pg"; + +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 NOT NULL, + user_id TEXT NOT NULL, + endpoint TEXT NOT NULL, + locale TEXT NOT NULL, + region TEXT NOT NULL, + api_version INT NOT NULL, + payload JSONB NOT NULL, + cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); +`); + +// Insert 10,000 rows of mixed tenants, users, endpoints, locales, +// regions and api versions. See the full file in the repo. + +const COLS = [ + "tenant_id", + "user_id", + "endpoint", + "locale", + "region", + "api_version", +]; +const sample = (await client.query(`SELECT * FROM cache_entries LIMIT 1`)) + .rows[0]; + +const lookups: [string, unknown[]][] = [ + ["tenant_id = $1 AND endpoint = $2", [sample.tenant_id, sample.endpoint]], + ["user_id = $1 AND locale = $2", [sample.user_id, sample.locale]], + [ + "tenant_id = $1 AND region = $2 AND api_version = $3", + [sample.tenant_id, sample.region, sample.api_version], + ], +]; + +async function totalIndexMB() { + const { rows } = await client.query(` + SELECT COALESCE(SUM(pg_relation_size(indexrelid)), 0)::bigint AS bytes + FROM pg_index + WHERE indrelid = 'cache_entries'::regclass + AND indexrelid <> 'cache_entries_pkey'::regclass + `); + return Number(rows[0].bytes) / 1024 / 1024; +} + +async function runLookups() { + let ms = 0; + for (const [where, params] of lookups) { + const t = performance.now(); + await client.query(`SELECT id FROM cache_entries WHERE ${where}`, params); + ms += performance.now() - t; + } + return ms; +} +``` + +Wrap that with a small helper that drops every non-primary-key index, builds one strategy at a time, and prints the size and lookup time. The full version is in the repo as `index.ts`. + +### Run it + + + +Same queries, same answers. One bloom index, roughly 2.5x smaller, supports any equality combination on the listed columns. Every write only updates one index instead of six. + +The bloom index is a touch slower per lookup because Postgres rechecks each candidate row against the heap. That's the price for the size and flexibility. For a cache table where any column might be a filter and disk is not free, it's a good trade. + +## When to use it + +Reach for a bloom index when: + +- A table has many columns that get filtered on equality. +- Different code paths filter by different subsets of those columns. +- Storage and write amplification from many btrees is becoming a problem. +- A small recheck overhead on reads is acceptable. + +## When not to use it + +Skip the bloom index when: + +- You only ever filter on one or two known columns. A single btree is faster and cheaper. +- You need range, prefix, sort or `LIKE` queries. Bloom is equality-only. +- Your columns are highly selective and you want index-only scans. Btree wins there. +- Your table is small enough that sequential scans are already fast. + +## Recap + +- Postgres ships a [`bloom` extension](https://www.postgresql.org/docs/current/bloom.html), [supported on Prisma Postgres](https://www.prisma.io/docs/postgres/database/postgres-extensions). It's been in core since 9.6. +- One bloom index covers equality lookups on any subset of its columns. +- It's much smaller than a stack of btrees and faster on writes. +- The trade-off is recheck on read and no support for ranges or sorts. +- It's a good fit for wide tables with many possible filter combinations: caches, lookup tables, audit logs, anything where you can't predict the query shape. diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts new file mode 100644 index 0000000000..8b70d17348 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts @@ -0,0 +1,40 @@ +export const indexesBefore = `-- one btree per column +CREATE INDEX btree_tenant_id ON cache_entries (tenant_id); +CREATE INDEX btree_user_id ON cache_entries (user_id); +CREATE INDEX btree_endpoint ON cache_entries (endpoint); +CREATE INDEX btree_locale ON cache_entries (locale); +CREATE INDEX btree_region ON cache_entries (region); +CREATE INDEX btree_api_version ON cache_entries (api_version);`; + +export const indexesAfter = `-- !mark +CREATE EXTENSION IF NOT EXISTS bloom; + +-- !mark +CREATE INDEX cache_bloom_idx ON cache_entries + -- !mark + USING bloom ( + -- !mark + tenant_id, user_id, endpoint, + -- !mark + locale, region, api_version + -- !mark + );`; + +export const demoTerminalLines = [ + "Provisioning a temporary Prisma Postgres database (1h TTL)...", + " claim URL: https://create-db.prisma.io/claim?projectID=...", + "Creating cache_entries table and enabling bloom extension...", + "Seeding 10,000 rows...", + " seeded in 1.2s", + "", + "A. Six btree indexes (one per column)...", + " index size: 0.5 MB", + " 3 lookups: 306.5 ms", + "", + "B. One bloom index (all six columns)...", + " index size: 0.2 MB", + " 3 lookups: 302.7 ms", + "", + " Bloom index is 70% smaller (0.2 MB vs 0.5 MB),", + " and one index covers any subset of those six columns.", +]; diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css index bb31ef261b..6ceba11a6d 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,252 @@ 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; +} 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 ( From fd84e224235446b12f111565b05aa1737c001f2d Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Thu, 28 May 2026 16:07:39 +0200 Subject: [PATCH 02/11] refactor(blog): drop AgentPrompt usages, add labeled-step demo runner The two follow-up animations in the bloom blog were prompt-style chat bubbles, but neither one is actually a prompt - both are commands / schema changes. Replaces them with structures that match what they are: - "How Postgres turns this into an index" now shows a static SQL block instead of a morph animation. - "Run it" now uses a new BloomDemoRunner: code panel on the left with the currently-executing slice of index.ts highlighted, terminal panel on the right that fills cumulatively, six clickable labeled steps, plus prev / play-pause / next. Each step has a one-line caption explaining what that section of code does. Also slows the bloom filter visualization (5.2s per step) and adds the same prev / play-pause / next controls plus clickable step pills, so readers can move through the algorithm at their own pace. Drops the obsolete snippets.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BloomDemoRunner.tsx | 122 ++++++++ .../BloomDemoRunnerClient.tsx | 197 ++++++++++++ .../BloomFilterDemoClient.tsx | 70 ++++- .../index.mdx | 100 +----- .../snippets.ts | 40 --- apps/blog/src/app/global.css | 295 ++++++++++++++++++ 6 files changed, 685 insertions(+), 139 deletions(-) create mode 100644 apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunner.tsx create mode 100644 apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunnerClient.tsx delete mode 100644 apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts 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..bde8fd271e --- /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 btree 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 btree indexes", + caption: + "One btree per filterable column. Measure the total index size and the time for three lookups.", + lines: { from: 30, to: 35 }, + output: [ + "", + "A. Six btree indexes (one per column)...", + " index size: 0.5 MB", + " 3 lookups: 306.5 ms", + ], + }, + { + title: "B: One bloom index", + caption: + "Drop the btrees 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 70% 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..220c726782 --- /dev/null +++ b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/BloomDemoRunnerClient.tsx @@ -0,0 +1,197 @@ +"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 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 lineMarks = []; + for (let n = step.lines.from; n <= step.lines.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 containerRef = useRef(null); + const codeScrollRef = useRef(null); + const terminalRef = 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.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]'); + if (highlighted) { + highlighted.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + const termEl = terminalRef.current; + if (termEl) { + termEl.scrollTop = termEl.scrollHeight; + } + }, [stepIndex]); + + function goTo(index: number) { + setPlaying(false); + setStepIndex(((index % steps.length) + steps.length) % steps.length); + } + + const cumulativeOutput = steps + .slice(0, stepIndex + 1) + .flatMap((s, i) => s.output.map((line) => ({ line, stepIdx: i }))); + + return ( +
+
+ +
+ +
+ {steps.map((s, i) => ( + + ))} +
+ +
{step.caption}
+ +
+
+
+        
+
+
+
+
+ {cumulativeOutput.length === 0 ? ( + Waiting... + ) : ( + cumulativeOutput.map((entry, i) => ( +
+ {entry.line || " "} +
+ )) + )} +
+
+
+
+ ); +} 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 index a819895d4d..feaa9aedb2 100644 --- 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 @@ -7,13 +7,14 @@ import { getStartingSnapshot, type TokenTransitionsSnapshot, } from "codehike/utils/token-transitions"; -import { Pause, Play } from "lucide-react"; +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[]; @@ -23,7 +24,7 @@ type Phase = { }; const SIZE = 16; -const STEP_HOLD_MS = 2600; +const STEP_HOLD_MS = 5200; function makeBits(positions: number[]): boolean[] { const out: boolean[] = Array.from({ length: SIZE }, () => false); @@ -40,6 +41,7 @@ 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: [], @@ -49,6 +51,7 @@ const PHASES: Phase[] = [ { 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, @@ -59,6 +62,7 @@ const PHASES: Phase[] = [ { 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]), @@ -70,6 +74,7 @@ const PHASES: Phase[] = [ { 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]), @@ -81,8 +86,8 @@ const PHASES: Phase[] = [ { step: 4, label: 'check("carol")', - caption: - "Carol hashes to 0, 5 and 9. Position 0 is still 0, so carol was never added. Hard no.", + 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, @@ -92,6 +97,7 @@ const PHASES: Phase[] = [ { 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]), @@ -166,6 +172,11 @@ export function BloomFilterDemoClient({ snippets }: Props) { const phase = PHASES[phaseIndex]; const code = snippets[phaseIndex]; + function goTo(index: number) { + setPlaying(false); + setPhaseIndex(((index % PHASES.length) + PHASES.length) % PHASES.length); + } + return (
@@ -173,14 +184,49 @@ export function BloomFilterDemoClient({ snippets }: Props) { {phase.step + 1} / {PHASES.length} {phase.label} - +
+ + + +
+
+ +
+ {PHASES.map((p, i) => ( + + ))}
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 index fb94d75086..c120120cb8 100644 --- 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 @@ -13,7 +13,7 @@ excerpt: "Postgres has shipped a bloom index since 9.6. It can replace a stack o --- import { BloomFilterDemo } from "./BloomFilterDemo"; -import { indexesBefore, indexesAfter, demoTerminalLines } from "./snippets"; +import { BloomDemoRunner } from "./BloomDemoRunner"; Postgres has shipped a [`bloom` index](https://www.postgresql.org/docs/current/bloom.html) since 9.6. It can replace a stack of btrees with a single, much smaller index, and almost nobody uses it. This post is a short tour: what it is, when it wins, and a benchmark on a realistic cache table. @@ -68,13 +68,15 @@ When you run an equality query, Postgres hashes your filter values, scans the si The shape of the change is small: drop the stack of btrees, 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 + ); +``` One index, and any subset of those six columns can use it for equality lookups. You don't have to anticipate every combination up front. False positives just mean a few extra heap fetches on the way to the right answer. @@ -97,87 +99,11 @@ 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 (no signup required), seeds the table, then builds each index strategy in turn and times the same three lookups against both. -```ts -// index.ts -import { create, isDatabaseSuccess } from "create-db"; -import { Client } from "pg"; - -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 NOT NULL, - user_id TEXT NOT NULL, - endpoint TEXT NOT NULL, - locale TEXT NOT NULL, - region TEXT NOT NULL, - api_version INT NOT NULL, - payload JSONB NOT NULL, - cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); -`); - -// Insert 10,000 rows of mixed tenants, users, endpoints, locales, -// regions and api versions. See the full file in the repo. - -const COLS = [ - "tenant_id", - "user_id", - "endpoint", - "locale", - "region", - "api_version", -]; -const sample = (await client.query(`SELECT * FROM cache_entries LIMIT 1`)) - .rows[0]; - -const lookups: [string, unknown[]][] = [ - ["tenant_id = $1 AND endpoint = $2", [sample.tenant_id, sample.endpoint]], - ["user_id = $1 AND locale = $2", [sample.user_id, sample.locale]], - [ - "tenant_id = $1 AND region = $2 AND api_version = $3", - [sample.tenant_id, sample.region, sample.api_version], - ], -]; - -async function totalIndexMB() { - const { rows } = await client.query(` - SELECT COALESCE(SUM(pg_relation_size(indexrelid)), 0)::bigint AS bytes - FROM pg_index - WHERE indrelid = 'cache_entries'::regclass - AND indexrelid <> 'cache_entries_pkey'::regclass - `); - return Number(rows[0].bytes) / 1024 / 1024; -} - -async function runLookups() { - let ms = 0; - for (const [where, params] of lookups) { - const t = performance.now(); - await client.query(`SELECT id FROM cache_entries WHERE ${where}`, params); - ms += performance.now() - t; - } - return ms; -} -``` - -Wrap that with a small helper that drops every non-primary-key index, builds one strategy at a time, and prints the size and lookup time. The full version is in the repo as `index.ts`. - ### Run it - +Step through what the script does. The left panel highlights the code currently running. The right panel shows the cumulative terminal output. Click any step to jump. + + Same queries, same answers. One bloom index, roughly 2.5x smaller, supports any equality combination on the listed columns. Every write only updates one index instead of six. diff --git a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts b/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts deleted file mode 100644 index 8b70d17348..0000000000 --- a/apps/blog/content/blog/postgres-bloom-index-the-overlooked-postgres-feature/snippets.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const indexesBefore = `-- one btree per column -CREATE INDEX btree_tenant_id ON cache_entries (tenant_id); -CREATE INDEX btree_user_id ON cache_entries (user_id); -CREATE INDEX btree_endpoint ON cache_entries (endpoint); -CREATE INDEX btree_locale ON cache_entries (locale); -CREATE INDEX btree_region ON cache_entries (region); -CREATE INDEX btree_api_version ON cache_entries (api_version);`; - -export const indexesAfter = `-- !mark -CREATE EXTENSION IF NOT EXISTS bloom; - --- !mark -CREATE INDEX cache_bloom_idx ON cache_entries - -- !mark - USING bloom ( - -- !mark - tenant_id, user_id, endpoint, - -- !mark - locale, region, api_version - -- !mark - );`; - -export const demoTerminalLines = [ - "Provisioning a temporary Prisma Postgres database (1h TTL)...", - " claim URL: https://create-db.prisma.io/claim?projectID=...", - "Creating cache_entries table and enabling bloom extension...", - "Seeding 10,000 rows...", - " seeded in 1.2s", - "", - "A. Six btree indexes (one per column)...", - " index size: 0.5 MB", - " 3 lookups: 306.5 ms", - "", - "B. One bloom index (all six columns)...", - " index size: 0.2 MB", - " 3 lookups: 302.7 ms", - "", - " Bloom index is 70% smaller (0.2 MB vs 0.5 MB),", - " and one index covers any subset of those six columns.", -]; diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css index 6ceba11a6d..81a210439b 100644 --- a/apps/blog/src/app/global.css +++ b/apps/blog/src/app/global.css @@ -766,3 +766,298 @@ 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 16%, transparent); + --runner-mark-border: #5fb878; + 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-muted); + font-size: 0.75rem; +} + +.runner-nav { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.runner-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; +} + +.runner-toggle:hover { + background: color-mix(in srgb, var(--color-foreground-ppg) 8%, transparent); + color: var(--color-foreground-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, 1.4fr) minmax(0, 1fr); + gap: 0; +} + +@media (max-width: 780px) { + .runner-body { + grid-template-columns: minmax(0, 1fr); + } +} + +.runner-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); + overflow: auto; + max-height: 460px; +} + +@media (max-width: 780px) { + .runner-code { + border-right: none; + border-bottom: 1px solid var(--color-stroke-neutral); + } +} + +.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] { + background: var(--runner-mark-bg); + box-shadow: inset 3px 0 0 var(--runner-mark-border); +} + +.runner-terminal { + display: flex; + flex-direction: column; + background: var(--runner-terminal-bg); + color: var(--runner-terminal-fg); + min-height: 240px; + max-height: 460px; +} + +.runner-terminal-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--runner-terminal-stroke); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.75rem; + color: var(--runner-terminal-muted); +} + +.runner-terminal-dots { + display: inline-flex; + gap: 5px; +} + +.runner-terminal-dots span { + width: 9px; + height: 9px; + border-radius: 999px; + background: color-mix(in srgb, var(--runner-terminal-muted) 60%, transparent); +} + +.runner-terminal-title { + color: var(--runner-terminal-fg); +} + +.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; +} + +.runner-terminal-line { + opacity: 0.6; + transition: + opacity 220ms ease, + color 220ms ease; +} + +.runner-terminal-line[data-step-active="true"] { + opacity: 1; + color: var(--runner-terminal-fg); +} + +.runner-terminal-placeholder { + color: var(--runner-terminal-muted); + font-style: italic; +} From 0138da80a782430decdad1592db9314a197071d9 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Sun, 31 May 2026 12:23:43 +0200 Subject: [PATCH 03/11] fix(blog): runner panel layout and full-output terminal The runner looked broken because the terminal showed only the current step's two output lines, leaving most of the panel empty. And the side-by-side 1.4fr:1fr split was too cramped at the blog's article width (~768px), so the code panel was narrow enough to force horizontal scrolling on every long line. Fixes: - Terminal now shows the full output across all six steps from the start. Lines for past steps are dimmed (60%), the current step's lines have a green border and full opacity, future lines are faintly visible. Auto-scrolls to the active line on advance. - Code panel auto-scrolls so the highlighted slice is in view. - Side-by-side now uses 1:1 split. - Container query: when the runner element itself drops below 720px the body stacks vertically (code above terminal) so the blog body width never forces side-by-side at unreadable sizes. - Each pane gets a small uppercase label (INDEX.TS, TERMINAL OUTPUT) so the relationship is obvious. - Step counter now includes the active step's title; nav buttons are bordered 28px squares instead of unlabeled 24px gray icons. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BloomDemoRunnerClient.tsx | 72 +++++----- apps/blog/src/app/global.css | 134 ++++++++++-------- 2 files changed, 109 insertions(+), 97 deletions(-) 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 index 220c726782..f529918bf6 100644 --- 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 @@ -39,10 +39,7 @@ function codeForStep(base: HighlightedCode, step: RunnerStep): HighlightedCode { } return { ...base, - annotations: [ - ...base.annotations.filter((a) => a.name !== "mark"), - ...lineMarks, - ], + annotations: [...base.annotations.filter((a) => a.name !== "mark"), ...lineMarks], }; } @@ -81,14 +78,20 @@ export function BloomDemoRunnerClient({ baseCode, steps }: Props) { useEffect(() => { const codeEl = codeScrollRef.current; if (codeEl) { - const highlighted = codeEl.querySelector('[data-mark]'); + const highlighted = codeEl.querySelector('[data-mark="active"]'); if (highlighted) { - highlighted.scrollIntoView({ behavior: "smooth", block: "nearest" }); + 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) { - termEl.scrollTop = termEl.scrollHeight; + 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]); @@ -97,9 +100,7 @@ export function BloomDemoRunnerClient({ baseCode, steps }: Props) { setStepIndex(((index % steps.length) + steps.length) % steps.length); } - const cumulativeOutput = steps - .slice(0, stepIndex + 1) - .flatMap((s, i) => s.output.map((line) => ({ line, stepIdx: i }))); + const allOutput = steps.flatMap((s, i) => s.output.map((line) => ({ line, stepIdx: i }))); return (
@@ -112,8 +113,9 @@ export function BloomDemoRunnerClient({ baseCode, steps }: Props) { index.ts -
@@ -163,32 +165,28 @@ export function BloomDemoRunnerClient({ baseCode, steps }: Props) {
{step.caption}
-
-
-        
-
-
-