Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ tags:
series: postgres-features
seriesIndex: 2
prev: you-dont-need-redis-postgres-already-has-pub-sub
next: you-dont-need-a-vector-database-postgres-already-has-pgvector
---

import { BloomFilterDemo } from "./BloomFilterDemo";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { highlight, type HighlightedCode } from "codehike/code";
import { PgvectorDemoRunnerClient, type RunnerStep } from "./PgvectorDemoRunnerClient";

type StepSource = Omit<RunnerStep, "code"> & { source: string; lang: string };

const STEPS: StepSource[] = [
{
title: "Spawn a database",
filename: "terminal",
lang: "bash",
source: "npx create-db@latest --json",
caption:
"create-db spawns a temporary Prisma Postgres database, no account required. It is deleted after 24 hours unless you open the claim URL. Copy the connection string into .env.",
output: [
"{",
' "connectionString": "postgres://…@db.prisma.io:5432/postgres",',
' "claimUrl": "https://create-db.prisma.io/claim?projectID=…",',
' "deletionDate": "2026-07-11T15:41:11.653Z"',
"}",
],
},
{
title: "Declare the contract",
filename: "src/prisma/contract.prisma",
lang: "prisma",
source: `// use prisma-next

types {
Uuid = String @db.Uuid
Vibe = pgvector.Vector(4)
}

model Movie {
id Uuid @id @default(uuid())
title String
embedding Vibe

@@map("movie")
}`,
caption:
"The dimension is part of the type: Vibe = pgvector.Vector(4) makes every Movie.embedding a 4-dimensional vector, checked from schema to query.",
output: ["", "# contract: movie table with a vector(4) embedding column"],
},
{
title: "Wire the extension",
filename: "prisma-next.config.ts",
lang: "typescript",
source: `import "dotenv/config";
import pgvector from "@prisma-next/extension-pgvector/control";
import { defineConfig } from "@prisma-next/postgres/config";

export default defineConfig({
contract: "./src/prisma/contract.prisma",
extensions: [pgvector],
db: {
connection: process.env.DATABASE_URL!,
},
});`,
caption:
"One entry in extensions teaches the CLI, the migration engine, and the query builder what a vector is.",
output: ["", "# extension packs: [pgvector]"],
},
{
title: "Migrate",
filename: "terminal",
lang: "bash",
source: `bunx prisma-next contract emit
bunx prisma-next migration plan
bunx prisma-next db init`,
caption:
"migration plan copies the pgvector pack's own baseline migration into your repo; db init applies both spaces. You never run CREATE EXTENSION by hand.",
output: [
"",
"Planned 1 operation(s); materialised 1 extension-space migration",
"Applied 2 operation(s) across 2 space(s), database signed",
" pgvector space: CREATE EXTENSION IF NOT EXISTS vector",
" app space: CREATE TABLE movie (…, embedding vector(4))",
],
},
{
title: "Insert + search",
filename: "index.ts",
lang: "typescript",
source: `const db = postgres<Contract>({
contractJson,
extensions: [pgvector],
});

// six movies scored on [action, romance, comedy, scifi]
const movies = [
{ title: "Alien", embedding: [0.6, 0.05, 0.02, 0.95] },
{ title: "The Terminator", embedding: [0.9, 0.15, 0.05, 0.85] },
// ...
];

const runtime = await db.connect({ url: process.env.DATABASE_URL! });
await runtime.execute(db.sql.public.movie.insert(movies).build());

// "an action-heavy sci-fi movie"
const query = [0.9, 0.1, 0.05, 0.9];

const plan = db.sql.public.movie
.select("title")
.select("similarity", (f, fns) =>
fns.cosineSimilarity(f.embedding, query))
.orderBy((f, fns) => fns.cosineDistance(f.embedding, query), {
direction: "asc",
})
.limit(3)
.build();

const rows = await runtime.execute(plan);`,
caption:
"cosineSimilarity and cosineDistance are typed methods on the vector column. They render to pgvector's <=> operator, with the query vector as a parameter.",
output: ["", "Inserted 6 movies."],
},
{
title: "Results",
filename: "terminal",
lang: "bash",
source: "bun index.ts",
caption:
"The action-heavy sci-fi movies win, the romantic comedies are nowhere in sight, and row.similarity is a number, not an any.",
output: [
"",
"Closest matches for [action: 0.9, scifi: 0.9]:",
" The Terminator similarity 0.999",
" Alien similarity 0.975",
" Mad Max: Fury Road similarity 0.972",
],
},
];

export async function PgvectorDemoRunner() {
const steps: RunnerStep[] = await Promise.all(
STEPS.map(async ({ source, lang, ...step }) => ({
...step,
code: (await highlight(
{ value: source, lang, meta: "" },
"github-from-css",
)) as HighlightedCode,
})),
);
return <PgvectorDemoRunnerClient steps={steps} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"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 { Check, ChevronLeft, ChevronRight, Copy, Pause, Play } from "lucide-react";

export type RunnerStep = {
title: string;
filename: string;
caption: string;
code: HighlightedCode;
output: string[];
};

type Props = {
steps: RunnerStep[];
};

const STEP_HOLD_MS = 6500;

class SmoothPre extends Component<{ code: HighlightedCode }> {
preRef: RefObject<HTMLPreElement | null> = 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 <Pre ref={this.preRef} code={this.props.code} />;
}
}

export function PgvectorDemoRunnerClient({ 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<HTMLDivElement>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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];

useEffect(() => {
const termEl = terminalRef.current;
if (!termEl) return;
const active = termEl.querySelector<HTMLElement>('[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 (
<div ref={containerRef} className="runner not-prose">
<div className="runner-header">
<span className="runner-filename">
<span className="runner-filename-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
{step.filename}
</span>
<span className="runner-step-counter">
Step {stepIndex + 1} of {steps.length}
<span className="runner-step-counter-label"> &middot; {step.title}</span>
</span>
<div className="runner-nav">
<button
type="button"
className="runner-toggle"
onClick={() => goTo(stepIndex - 1)}
aria-label="Previous step"
>
<ChevronLeft size={16} />
</button>
<button
type="button"
className="runner-toggle"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? "Pause demo" : "Play demo"}
>
{playing ? <Pause size={16} /> : <Play size={16} />}
</button>
<button
type="button"
className="runner-toggle"
onClick={() => goTo(stepIndex + 1)}
aria-label="Next step"
>
<ChevronRight size={16} />
</button>
</div>
</div>

<div className="runner-steps" role="tablist" aria-label="Demo steps">
{steps.map((s, i) => (
<button
key={s.title}
type="button"
role="tab"
aria-selected={i === stepIndex}
data-active={i === stepIndex ? "true" : undefined}
className="runner-step-pill"
onClick={() => goTo(i)}
>
<span className="runner-step-pill-num">{i + 1}</span>
<span className="runner-step-pill-label">{s.title}</span>
</button>
))}
</div>

<div className="runner-caption">{step.caption}</div>

<div className="runner-body">
<div className="runner-pane runner-pane-code">
<div className="runner-pane-label">
<span>{step.filename}</span>
</div>
<div className="runner-code">
<SmoothPre code={step.code} />
</div>
</div>
<div className="runner-pane runner-pane-terminal">
<div className="runner-pane-label runner-pane-label-terminal">
<span>terminal output</span>
<button
type="button"
className="runner-copy"
onClick={copyOutput}
aria-label={copied ? "Copied terminal output" : "Copy terminal output"}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</div>
<div className="runner-terminal-body" ref={terminalRef}>
{allOutput.map((entry, i) => {
const state =
entry.stepIdx === stepIndex
? "active"
: entry.stepIdx < stepIndex
? "past"
: "future";
return (
<div key={i} className="runner-terminal-line" data-step-state={state}>
{entry.line || " "}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
Loading
Loading