+ );
+}
diff --git a/web/src/app/playground/PlaygroundLoader.tsx b/web/src/app/playground/PlaygroundLoader.tsx
new file mode 100644
index 0000000..a65efed
--- /dev/null
+++ b/web/src/app/playground/PlaygroundLoader.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+// The playground pulls in CodeMirror and (at runtime) the WASM engine —
+// neither has any business running during SSR. `ssr: false` keeps this
+// route's heavy client code out of the server render and off the initial
+// HTML, while the static page shell around it stays server-rendered for
+// SEO. The skeleton holds layout so there's no jank before hydration.
+const Playground = dynamic(
+ () => import("./Playground").then((m) => m.Playground),
+ {
+ ssr: false,
+ loading: () => (
+
+ {columns.map((col) => {
+ const v = row[col];
+ if (v === null) {
+ return (
+
+ NULL
+
+ );
+ }
+ return (
+
+ {String(v)}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/web/src/app/playground/lib/datasets.ts b/web/src/app/playground/lib/datasets.ts
new file mode 100644
index 0000000..cf0799a
--- /dev/null
+++ b/web/src/app/playground/lib/datasets.ts
@@ -0,0 +1,225 @@
+// Pre-loaded sample datasets for the playground. Each one is a complete
+// setup script (DDL + seed rows) plus a sample query that drops into the
+// editor when the dataset loads. The scripts only use SQL the engine
+// actually supports today — notably:
+// * No aggregates over JOIN results (rejected by the engine — SQLR issue
+// #6), so the northwind JOIN demo is projection-only.
+// * `vec_distance_*` lives in ORDER BY / WHERE, never the SELECT list
+// (SQLR docs/supported-sql.md), so the movies KNN query projects plain
+// columns and ranks in ORDER BY.
+// * Booleans are stored as 0/1 INTEGERs to avoid leaning on boolean
+// literal support.
+
+export type Dataset = {
+ id: string;
+ label: string;
+ /** One-line description shown in the dataset picker / status line. */
+ blurb: string;
+ /** SQLRite features this dataset is meant to show off (UI pills). */
+ features: string[];
+ /** DDL + seed data, run on a fresh DB when the dataset is selected. */
+ setup: string;
+ /** Query dropped into the editor after the dataset loads. */
+ sampleQuery: string;
+};
+
+const POKEMON: Dataset = {
+ id: "pokemon",
+ label: "Pokémon",
+ blurb: "151 first-gen creatures (a 24-row sample) — filters, ORDER BY, GROUP BY.",
+ features: ["WHERE", "GROUP BY", "aggregates"],
+ setup: `-- Pokémon — a single-table dataset for SELECT / WHERE / GROUP BY.
+CREATE TABLE pokemon (
+ id INTEGER PRIMARY KEY,
+ name TEXT,
+ type1 TEXT,
+ type2 TEXT,
+ hp INTEGER,
+ attack INTEGER,
+ defense INTEGER,
+ speed INTEGER,
+ generation INTEGER,
+ legendary INTEGER -- 0 / 1
+);
+
+INSERT INTO pokemon (name, type1, type2, hp, attack, defense, speed, generation, legendary) VALUES
+ ('Bulbasaur', 'Grass', 'Poison', 45, 49, 49, 45, 1, 0),
+ ('Charmander', 'Fire', NULL, 39, 52, 43, 65, 1, 0),
+ ('Charizard', 'Fire', 'Flying', 78, 84, 78,100, 1, 0),
+ ('Squirtle', 'Water', NULL, 44, 48, 65, 43, 1, 0),
+ ('Blastoise', 'Water', NULL, 79, 83,100, 78, 1, 0),
+ ('Pikachu', 'Electric', NULL, 35, 55, 40, 90, 1, 0),
+ ('Raichu', 'Electric', NULL, 60, 90, 55,110, 1, 0),
+ ('Jigglypuff', 'Normal', 'Fairy',115, 45, 20, 20, 1, 0),
+ ('Geodude', 'Rock', 'Ground', 40, 80,100, 20, 1, 0),
+ ('Onix', 'Rock', 'Ground', 35, 45,160, 70, 1, 0),
+ ('Gengar', 'Ghost', 'Poison', 60, 65, 60,110, 1, 0),
+ ('Onix', 'Rock', 'Ground', 35, 45,160, 70, 1, 0),
+ ('Eevee', 'Normal', NULL, 55, 55, 50, 55, 1, 0),
+ ('Vaporeon', 'Water', NULL, 130, 65, 60, 65, 1, 0),
+ ('Jolteon', 'Electric', NULL, 65, 65, 60,130, 1, 0),
+ ('Snorlax', 'Normal', NULL, 160,110, 65, 30, 1, 0),
+ ('Dragonite', 'Dragon', 'Flying', 91,134, 95, 80, 1, 0),
+ ('Mewtwo', 'Psychic', NULL, 106,110, 90,130, 1, 1),
+ ('Mew', 'Psychic', NULL, 100,100,100,100, 1, 1),
+ ('Articuno', 'Ice', 'Flying', 90, 85,100, 85, 1, 1),
+ ('Zapdos', 'Electric', 'Flying', 90, 90, 85,100, 1, 1),
+ ('Moltres', 'Fire', 'Flying', 90,100, 90, 90, 1, 1),
+ ('Machamp', 'Fighting', NULL, 90,130, 80, 55, 1, 0),
+ ('Alakazam', 'Psychic', NULL, 55, 50, 45,120, 1, 0);`,
+ sampleQuery: `-- Strongest non-legendary attackers.
+SELECT name, type1, attack, speed
+FROM pokemon
+WHERE legendary = 0
+ORDER BY attack DESC
+LIMIT 8;
+
+-- Try a single-table aggregate too (run it on its own):
+-- SELECT type1, COUNT(*) AS n, AVG(hp) AS avg_hp
+-- FROM pokemon GROUP BY type1 ORDER BY n DESC;`,
+};
+
+const NORTHWIND: Dataset = {
+ id: "northwind",
+ label: "Northwind (slim)",
+ blurb: "Classic orders schema — multi-table INNER JOINs across 4 tables.",
+ features: ["JOIN", "multi-table", "ORDER BY"],
+ setup: `-- Northwind (slimmed) — customers, products, orders, order_items.
+-- Showcases SQLRite's multi-table JOINs. (Aggregates over JOIN results
+-- aren't supported yet, so the JOIN demo projects columns directly.)
+CREATE TABLE customers (
+ id INTEGER PRIMARY KEY,
+ name TEXT,
+ country TEXT
+);
+CREATE TABLE products (
+ id INTEGER PRIMARY KEY,
+ name TEXT,
+ category TEXT,
+ price REAL
+);
+CREATE TABLE orders (
+ id INTEGER PRIMARY KEY,
+ customer_id INTEGER,
+ order_date TEXT
+);
+CREATE TABLE order_items (
+ id INTEGER PRIMARY KEY,
+ order_id INTEGER,
+ product_id INTEGER,
+ quantity INTEGER
+);
+
+INSERT INTO customers (name, country) VALUES
+ ('Alfreds Futterkiste', 'Germany'),
+ ('Around the Horn', 'UK'),
+ ('Berglunds snabbköp', 'Sweden'),
+ ('Blondel père et fils', 'France'),
+ ('Ernst Handel', 'Austria');
+
+INSERT INTO products (name, category, price) VALUES
+ ('Chai', 'Beverages', 18.00),
+ ('Chang', 'Beverages', 19.00),
+ ('Aniseed Syrup', 'Condiments', 10.00),
+ ('Chef Anton''s Mix','Condiments', 22.00),
+ ('Pavlova', 'Confections', 17.45),
+ ('Tofu', 'Produce', 23.25),
+ ('Konbu', 'Seafood', 6.00);
+
+INSERT INTO orders (customer_id, order_date) VALUES
+ (1, '2024-01-15'),
+ (2, '2024-01-18'),
+ (3, '2024-02-02'),
+ (1, '2024-02-20'),
+ (5, '2024-03-09');
+
+INSERT INTO order_items (order_id, product_id, quantity) VALUES
+ (1, 1, 10), (1, 5, 3),
+ (2, 2, 6), (2, 7, 20),
+ (3, 3, 12), (3, 6, 4),
+ (4, 4, 8),
+ (5, 1, 15), (5, 2, 5), (5, 5, 2);`,
+ sampleQuery: `-- Order lines joined across 4 tables (no aggregates).
+-- NB: SQLRite's ORDER BY takes a single sort key for now.
+SELECT c.name AS customer, o.order_date, p.name AS product, oi.quantity
+FROM order_items oi
+JOIN orders o ON oi.order_id = o.id
+JOIN customers c ON o.customer_id = c.id
+JOIN products p ON oi.product_id = p.id
+ORDER BY o.order_date
+LIMIT 20;
+
+-- Single-table aggregate (run separately):
+-- SELECT category, COUNT(*) AS n, AVG(price) AS avg_price
+-- FROM products GROUP BY category ORDER BY avg_price DESC;`,
+};
+
+const MOVIES: Dataset = {
+ id: "movies",
+ label: "Movies (vector search)",
+ blurb: "12 films with 4-dim embeddings + an HNSW index — cosine KNN search.",
+ features: ["VECTOR(4)", "HNSW", "cosine KNN"],
+ setup: `-- Movies — the vector-search demo. Each film carries a hand-made
+-- 4-dim "taste" embedding over the axes [sci-fi, romance, action, comedy].
+-- The HNSW index makes the ORDER BY vec_distance_cosine query an
+-- approximate-nearest-neighbour probe instead of a full scan — the same
+-- machinery the Python agent + notes examples use for RAG, here entirely
+-- in your browser tab.
+CREATE TABLE movies (
+ id INTEGER PRIMARY KEY,
+ title TEXT,
+ genre TEXT,
+ year INTEGER,
+ embedding VECTOR(4)
+);
+
+CREATE INDEX idx_movies_embedding
+ ON movies USING hnsw (embedding) WITH (metric = 'cosine');
+
+INSERT INTO movies (title, genre, year, embedding) VALUES
+ ('The Matrix', 'sci-fi / action', 1999, [0.80, 0.05, 0.50, 0.05]),
+ ('Blade Runner', 'sci-fi', 1982, [0.90, 0.10, 0.20, 0.00]),
+ ('Interstellar', 'sci-fi', 2014, [0.85, 0.20, 0.15, 0.05]),
+ ('Arrival', 'sci-fi', 2016, [0.90, 0.25, 0.05, 0.05]),
+ ('Die Hard', 'action', 1988, [0.10, 0.00, 0.90, 0.15]),
+ ('Mad Max: Fury Road', 'action', 2015, [0.20, 0.05, 0.95, 0.05]),
+ ('John Wick', 'action', 2014, [0.05, 0.00, 0.90, 0.10]),
+ ('Notting Hill', 'romance', 1999, [0.00, 0.90, 0.05, 0.30]),
+ ('Pride & Prejudice', 'romance', 2005, [0.05, 0.95, 0.00, 0.10]),
+ ('La La Land', 'romance / comedy',2016, [0.10, 0.80, 0.05, 0.50]),
+ ('Superbad', 'comedy', 2007, [0.00, 0.10, 0.05, 0.95]),
+ ('The Grand Budapest Hotel', 'comedy', 2014, [0.10, 0.20, 0.10, 0.90]);`,
+ sampleQuery: `-- Nearest neighbours (cosine) to a sci-fi / action taste vector.
+-- HNSW-probed via the index. vec_distance_cosine is allowed in ORDER BY
+-- and WHERE but not in the SELECT projection (recompute client-side if
+-- you need the score).
+SELECT title, genre, year
+FROM movies
+ORDER BY vec_distance_cosine(embedding, [0.85, 0.05, 0.40, 0.05])
+LIMIT 5;
+
+-- Swap the query vector for a rom-com lean and re-run:
+-- ORDER BY vec_distance_cosine(embedding, [0.05, 0.85, 0.05, 0.45])`,
+};
+
+export const DATASETS: Dataset[] = [POKEMON, NORTHWIND, MOVIES];
+
+/** Default editor contents on a cold first visit (no hash, no saved DB). */
+export const WELCOME_SQL = `-- Welcome to the SQLRite playground — the full engine, compiled to
+-- WebAssembly, running entirely in this browser tab. No server.
+--
+-- Run with the Run button or Cmd/Ctrl+Enter. Pick a sample dataset from
+-- the toolbar to get a schema + data + an example query in one click.
+CREATE TABLE greetings (id INTEGER PRIMARY KEY, lang TEXT, text TEXT);
+INSERT INTO greetings (lang, text) VALUES
+ ('en', 'Hello'),
+ ('pt', 'Olá'),
+ ('ja', 'こんにちは'),
+ ('rust', 'println!("hi")');
+
+SELECT id, lang, text FROM greetings ORDER BY lang;`;
+
+export function findDataset(id: string | null | undefined): Dataset | undefined {
+ if (!id) return undefined;
+ return DATASETS.find((d) => d.id === id);
+}
diff --git a/web/src/app/playground/lib/persist.ts b/web/src/app/playground/lib/persist.ts
new file mode 100644
index 0000000..0335a1b
--- /dev/null
+++ b/web/src/app/playground/lib/persist.ts
@@ -0,0 +1,202 @@
+// Browser persistence for the playground.
+//
+// The WASM SDK is in-memory only — there is no `.sqlrite` byte image to
+// stash (see the playground README's "Known limitations"). So instead of
+// persisting the *database*, we persist the *script*: the ordered list of
+// mutating statements (CREATE / INSERT / UPDATE / DELETE / tx control) that
+// the user has successfully run. On reload we replay that log into a fresh
+// in-memory DB, which reconstructs the same state. This is the "script
+// replay" model — cheap, transparent, and downloadable as a plain `.sql`.
+//
+// Storage backend, in priority order:
+// 1. OPFS (Origin Private File System) — the modern browser-local FS.
+// 2. localStorage — fallback for browsers without OPFS write support.
+// 3. none — private-mode / locked-down contexts; the playground still
+// works, it just won't survive a reload.
+
+export type StorageMode = "opfs" | "local" | "none";
+
+const LOG_FILE = "session.sql";
+const EDITOR_FILE = "editor.sql";
+const LOCAL_PREFIX = "sqlrite-playground:";
+
+type KV = {
+ mode: StorageMode;
+ get(name: string): Promise;
+ set(name: string, value: string): Promise;
+ remove(name: string): Promise;
+};
+
+async function getOpfsDir(): Promise {
+ try {
+ if (
+ typeof navigator === "undefined" ||
+ !navigator.storage ||
+ typeof navigator.storage.getDirectory !== "function"
+ ) {
+ return null;
+ }
+ const root = await navigator.storage.getDirectory();
+ // Probe that createWritable exists — Safari shipped getDirectory before
+ // main-thread writable streams, so a successful getDirectory doesn't
+ // guarantee we can write. Create + write + delete a probe file.
+ const probe = await root.getFileHandle("__probe__", { create: true });
+ if (typeof probe.createWritable !== "function") return null;
+ const w = await probe.createWritable();
+ await w.write("ok");
+ await w.close();
+ await root.removeEntry("__probe__").catch(() => {});
+ return root;
+ } catch {
+ return null;
+ }
+}
+
+function localKV(): KV {
+ return {
+ mode: "local",
+ async get(name) {
+ try {
+ return localStorage.getItem(LOCAL_PREFIX + name);
+ } catch {
+ return null;
+ }
+ },
+ async set(name, value) {
+ try {
+ localStorage.setItem(LOCAL_PREFIX + name, value);
+ } catch {
+ /* quota / disabled — best effort */
+ }
+ },
+ async remove(name) {
+ try {
+ localStorage.removeItem(LOCAL_PREFIX + name);
+ } catch {
+ /* ignore */
+ }
+ },
+ };
+}
+
+function noneKV(): KV {
+ return {
+ mode: "none",
+ async get() {
+ return null;
+ },
+ async set() {},
+ async remove() {},
+ };
+}
+
+function opfsKV(root: FileSystemDirectoryHandle): KV {
+ return {
+ mode: "opfs",
+ async get(name) {
+ try {
+ const fh = await root.getFileHandle(name);
+ const file = await fh.getFile();
+ return await file.text();
+ } catch {
+ return null; // ENOENT etc.
+ }
+ },
+ async set(name, value) {
+ const fh = await root.getFileHandle(name, { create: true });
+ const w = await fh.createWritable();
+ await w.write(value);
+ await w.close();
+ },
+ async remove(name) {
+ await root.removeEntry(name).catch(() => {});
+ },
+ };
+}
+
+let kvPromise: Promise | null = null;
+
+async function getKV(): Promise {
+ if (!kvPromise) {
+ kvPromise = (async () => {
+ const root = await getOpfsDir();
+ if (root) return opfsKV(root);
+ // localStorage availability probe.
+ try {
+ const k = `${LOCAL_PREFIX}__probe__`;
+ localStorage.setItem(k, "1");
+ localStorage.removeItem(k);
+ return localKV();
+ } catch {
+ return noneKV();
+ }
+ })();
+ }
+ return kvPromise;
+}
+
+/** Which backend persistence resolved to. Awaitable; cached after first call. */
+export async function storageMode(): Promise {
+ return (await getKV()).mode;
+}
+
+/** The replayable mutating-statement log (newline-joined `.sql`), or null. */
+export async function loadSession(): Promise {
+ return (await getKV()).get(LOG_FILE);
+}
+
+export async function saveSession(sql: string): Promise {
+ return (await getKV()).set(LOG_FILE, sql);
+}
+
+/** Last editor contents — restored on reload when there's no share hash. */
+export async function loadEditor(): Promise {
+ return (await getKV()).get(EDITOR_FILE);
+}
+
+export async function saveEditor(sql: string): Promise {
+ return (await getKV()).set(EDITOR_FILE, sql);
+}
+
+/** Wipes both the session log and the saved editor (the "Reset DB" path). */
+export async function clearAll(): Promise {
+ const kv = await getKV();
+ await kv.remove(LOG_FILE);
+ await kv.remove(EDITOR_FILE);
+}
+
+// ---------------------------------------------------------------------------
+// Share-via-URL-hash. The editor SQL is base64url-encoded into
+// `#sql=…` so a link reproduces exactly what someone typed.
+
+function toBase64Url(text: string): string {
+ const bytes = new TextEncoder().encode(text);
+ let bin = "";
+ for (const b of bytes) bin += String.fromCharCode(b);
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function fromBase64Url(b64url: string): string {
+ const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
+ const bin = atob(b64);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ return new TextDecoder().decode(bytes);
+}
+
+/** Builds a shareable absolute URL with the SQL encoded in the hash. */
+export function buildShareUrl(sql: string): string {
+ const base = `${window.location.origin}${window.location.pathname}`;
+ return `${base}#sql=${toBase64Url(sql)}`;
+}
+
+/** Extracts SQL from a `#sql=…` hash, or null if absent / malformed. */
+export function readShareHash(hash: string): string | null {
+ const m = /[#&]sql=([^&]+)/.exec(hash);
+ if (!m) return null;
+ try {
+ return fromBase64Url(decodeURIComponent(m[1]));
+ } catch {
+ return null;
+ }
+}
diff --git a/web/src/app/playground/lib/sql.ts b/web/src/app/playground/lib/sql.ts
new file mode 100644
index 0000000..ea241c9
--- /dev/null
+++ b/web/src/app/playground/lib/sql.ts
@@ -0,0 +1,137 @@
+// Small SQL utilities for the browser playground: split a multi-statement
+// script into individual statements, classify SELECTs, and render results
+// as CSV. None of this parses SQL semantically — the engine does the real
+// parsing. We only need to (a) chop on top-level `;` and (b) know whether
+// to call `db.query` (row-producing) or `db.exec`.
+
+import type { SqlriteRow } from "./wasm";
+
+/**
+ * Splits a script into individual statements on top-level semicolons,
+ * skipping `;` that appear inside string literals (`'...'`, `"..."`),
+ * line comments (`-- ...`), and block comments (`/* ... */`). Returns
+ * trimmed, non-empty statements with their terminating `;` removed.
+ *
+ * This is deliberately a lexer, not a parser: vector literals (`[0.1,
+ * 0.2]`) and the like contain no `;`, so a quote/comment-aware scan is
+ * enough to keep dataset scripts intact.
+ */
+export function splitStatements(script: string): string[] {
+ const out: string[] = [];
+ let buf = "";
+ let i = 0;
+ const n = script.length;
+
+ while (i < n) {
+ const c = script[i];
+ const next = script[i + 1];
+
+ // Line comment — consume to end of line (keep it in buf so the engine
+ // sees a faithful statement, but never split on a `;` inside it).
+ if (c === "-" && next === "-") {
+ while (i < n && script[i] !== "\n") {
+ buf += script[i];
+ i++;
+ }
+ continue;
+ }
+ // Block comment.
+ if (c === "/" && next === "*") {
+ buf += c;
+ buf += next;
+ i += 2;
+ while (i < n && !(script[i] === "*" && script[i + 1] === "/")) {
+ buf += script[i];
+ i++;
+ }
+ if (i < n) {
+ buf += "*/";
+ i += 2;
+ }
+ continue;
+ }
+ // String literals — single or double quoted. SQL escapes a quote by
+ // doubling it (`''`), which this handles naturally: the closing quote
+ // is consumed, then the next char (another quote) re-opens.
+ if (c === "'" || c === '"') {
+ const quote = c;
+ buf += c;
+ i++;
+ while (i < n) {
+ buf += script[i];
+ if (script[i] === quote) {
+ i++;
+ break;
+ }
+ i++;
+ }
+ continue;
+ }
+ if (c === ";") {
+ const trimmed = buf.trim();
+ if (trimmed.length > 0) out.push(trimmed);
+ buf = "";
+ i++;
+ continue;
+ }
+ buf += c;
+ i++;
+ }
+
+ const tail = buf.trim();
+ if (tail.length > 0) out.push(tail);
+ return out;
+}
+
+/**
+ * True when a statement produces a result set and should go through
+ * `db.query` rather than `db.exec`. SQLRite's row-producing statements
+ * are `SELECT` and `WITH … SELECT`; everything else (DDL/DML/tx control)
+ * is a no-rows `exec`.
+ */
+export function isRowProducing(statement: string): boolean {
+ // Skip leading comments / whitespace before sniffing the keyword.
+ const stripped = statement
+ .replace(/^\s*(--[^\n]*\n|\/\*[\s\S]*?\*\/|\s)+/g, "")
+ .trimStart();
+ return /^(select|with)\b/i.test(stripped);
+}
+
+/** Quotes a single CSV cell per RFC 4180 when it contains `, " \n \r`. */
+function csvCell(value: string | number | boolean | null): string {
+ if (value === null) return "";
+ const s = String(value);
+ if (/[",\n\r]/.test(s)) {
+ return `"${s.replace(/"/g, '""')}"`;
+ }
+ return s;
+}
+
+/** Renders a result set as an RFC-4180 CSV string (header + rows). */
+export function toCSV(columns: string[], rows: SqlriteRow[]): string {
+ const lines: string[] = [];
+ lines.push(columns.map(csvCell).join(","));
+ for (const row of rows) {
+ lines.push(columns.map((col) => csvCell(row[col] ?? null)).join(","));
+ }
+ return lines.join("\r\n");
+}
+
+/** Column names for a result set, preserving projection order. */
+export function columnsOf(rows: SqlriteRow[]): string[] {
+ if (rows.length === 0) return [];
+ return Object.keys(rows[0]);
+}
+
+/** Human label for a JS-side cell type, shown in the results header. */
+export function cellType(value: string | number | boolean | null): string {
+ if (value === null) return "null";
+ switch (typeof value) {
+ case "number":
+ return Number.isInteger(value) ? "int" : "real";
+ case "boolean":
+ return "bool";
+ default:
+ return "text";
+ }
+}
diff --git a/web/src/app/playground/lib/wasm.ts b/web/src/app/playground/lib/wasm.ts
new file mode 100644
index 0000000..c0abe55
--- /dev/null
+++ b/web/src/app/playground/lib/wasm.ts
@@ -0,0 +1,62 @@
+// Runtime loader for the SQLRite WASM SDK.
+//
+// The pkg is wasm-pack's `--target web` output, vendored into
+// `web/public/playground/pkg/` (a pinned copy of `sdk/wasm/pkg/` — see the
+// playground README). We load it with a *runtime* dynamic import of the
+// public path rather than letting the Next bundler process it: wasm-pack's
+// glue uses `import.meta.url` + top-level `fetch` of the sibling `.wasm`,
+// which webpack's wasm handling mangles. The `webpackIgnore` /
+// `turbopackIgnore` magic comments keep the import as a native browser
+// `import()` of `/playground/pkg/sqlrite_wasm.js`, so the glue resolves the
+// `.wasm` next to itself exactly as wasm-pack intends. There is no module
+// on disk for TypeScript to resolve, so the types are declared here to
+// mirror `sdk/wasm/pkg/sqlrite_wasm.d.ts`.
+
+/** A single result row: column-name → typed JS primitive. */
+export type SqlriteRow = Record;
+
+/**
+ * In-memory SQLRite database handle. The WASM build is always in-memory —
+ * there is no file-backed or serialised mode (see the playground README's
+ * "Known limitations").
+ */
+export interface Database {
+ /** CREATE / INSERT / UPDATE / DELETE / BEGIN / COMMIT / ROLLBACK. */
+ exec(sql: string): void;
+ /** A SELECT — array of row objects in projection order. */
+ query(sql: string): SqlriteRow[];
+ /** Column names of a SELECT's projection, without iterating rows. */
+ columns(sql: string): string[];
+ /** Releases the underlying Rust state. */
+ free(): void;
+ readonly inTransaction: boolean;
+ readonly readonly: boolean;
+}
+
+type WasmModule = {
+ default: (module_or_path?: unknown) => Promise;
+ Database: new () => Database;
+};
+
+let modPromise: Promise | null = null;
+
+/**
+ * Loads + initialises the WASM module once per page. Subsequent calls
+ * return the same in-flight / settled promise, so the ~750 KB `.wasm`
+ * is fetched and instantiated exactly once.
+ */
+export async function loadSqlrite(): Promise<{ Database: new () => Database }> {
+ if (!modPromise) {
+ modPromise = (async () => {
+ // @ts-expect-error — runtime ESM import resolved from web/public in the
+ // browser; not a module the bundler/TS can see on disk. The magic
+ // comments keep webpack/turbopack from trying to bundle it.
+ const mod = (await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ "/playground/pkg/sqlrite_wasm.js")) as unknown as WasmModule;
+ // wasm-bindgen start: fetch + instantiate the binary. Everything
+ // throws until this resolves.
+ await mod.default();
+ return mod;
+ })();
+ }
+ return modPromise;
+}
diff --git a/web/src/app/playground/opengraph-image.tsx b/web/src/app/playground/opengraph-image.tsx
new file mode 100644
index 0000000..3aec1e7
--- /dev/null
+++ b/web/src/app/playground/opengraph-image.tsx
@@ -0,0 +1,20 @@
+import { ImageResponse } from "next/og";
+import { OG_CONTENT_TYPE, OG_SIZE, OgFrame } from "@/lib/og";
+
+export const runtime = "edge";
+export const alt = "SQLRite SQL playground — run the engine in your browser";
+export const size = OG_SIZE;
+export const contentType = OG_CONTENT_TYPE;
+
+export default function OgImage() {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size },
+ );
+}
diff --git a/web/src/app/playground/page.tsx b/web/src/app/playground/page.tsx
new file mode 100644
index 0000000..1298fec
--- /dev/null
+++ b/web/src/app/playground/page.tsx
@@ -0,0 +1,154 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Nav } from "@/components/nav";
+import { SITE } from "@/lib/site";
+import PlaygroundLoader from "./PlaygroundLoader";
+
+const TITLE = "SQL Playground";
+const DESCRIPTION =
+ "Run SQLRite — an embedded SQL + vector database in Rust — entirely in your browser. The full engine compiled to WebAssembly: SQL editor, sample datasets, HNSW vector search, CSV export. No install, no server.";
+
+export const metadata: Metadata = {
+ title: TITLE,
+ description: DESCRIPTION,
+ alternates: { canonical: "/playground" },
+ openGraph: {
+ type: "website",
+ siteName: "SQLRite",
+ locale: "en_US",
+ url: `${SITE.url}/playground`,
+ title: `${TITLE} · SQLRite`,
+ description: DESCRIPTION,
+ },
+ twitter: {
+ card: "summary_large_image",
+ site: SITE.twitterHandle,
+ creator: SITE.twitterHandle,
+ title: `${TITLE} · SQLRite`,
+ description: DESCRIPTION,
+ },
+};
+
+// WebApplication structured data — tells search engines this route is an
+// interactive tool, not just an article.
+const webAppJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "WebApplication",
+ name: "SQLRite SQL Playground",
+ description: DESCRIPTION,
+ url: `${SITE.url}/playground`,
+ applicationCategory: "DeveloperApplication",
+ operatingSystem: "Any (WebAssembly)",
+ browserRequirements: "Requires WebAssembly and ES modules.",
+ isAccessibleForFree: true,
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
+ author: {
+ "@type": "Person",
+ name: "Joao Henrique Machado Silva",
+ url: SITE.socials.github,
+ },
+};
+
+export default function PlaygroundPage() {
+ return (
+ <>
+
+
+
+
+
+ playground · wasm
+
+
Run SQLRite in your browser.
+
+ The complete SQLRite engine — B-tree storage, transactions,
+ JOINs, aggregates, BM25 full-text, and HNSW vector search —
+ compiled to WebAssembly and running entirely in this tab. No
+ server, no account, nothing to install. Your data never leaves
+ the browser. Try a sample dataset, or write your own SQL and
+ hit ⌘/Ctrl+Enter.
+
+
+
+
+
+
+
+
+
+
How it works
+
+ The engine is the same Rust crate published as{" "}
+
+ sqlrite-engine
+
+ , built for wasm32-unknown-unknown with{" "}
+
+ @joaoh82/sqlrite-wasm
+
+ . The WASM module is ~750 KB gzipped and fetched once on
+ first load. Because the browser build is in-memory only, the
+ playground persists your SQL session (the statements you
+ run) to{" "}
+ OPFS — replaying
+ it on reload to rebuild the same database. Use{" "}
+ Download .sql to take that script with you.
+
+
+
Known limitations
+
+
+ In-memory engine. The WASM build has no
+ file-backed mode, so there is no binary .sqlrite{" "}
+ export yet — persistence is via SQL-script replay. Binary
+ round-trip is a tracked follow-up.
+
+
+ OPFS support varies. Where OPFS write access
+ isn't available (some private-browsing modes, older
+ Safari), the playground falls back to{" "}
+ localStorage, and if that's blocked too, the
+ session simply isn't persisted across reloads.
+
+
+ No ask (natural-language → SQL).{" "}
+ That feature needs a server-side API key, which a static
+ browser page can't hold. It ships in the REPL, MCP server,
+ and desktop app instead.
+
+
+ Aggregates over JOIN results and a few other surfaces
+ aren't implemented in the engine yet — see{" "}
+
+ the supported-SQL reference
+
+ .
+
+
+
+
+ Want the full architecture write-up? See the{" "}
+
+ playground README
+ {" "}
+ and the other example apps.
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/playground/twitter-image.tsx b/web/src/app/playground/twitter-image.tsx
new file mode 100644
index 0000000..c93da30
--- /dev/null
+++ b/web/src/app/playground/twitter-image.tsx
@@ -0,0 +1,20 @@
+import { ImageResponse } from "next/og";
+import { OG_CONTENT_TYPE, OG_SIZE, OgFrame } from "@/lib/og";
+
+export const runtime = "edge";
+export const alt = "SQLRite SQL playground — run the engine in your browser";
+export const size = OG_SIZE;
+export const contentType = OG_CONTENT_TYPE;
+
+export default function TwitterImage() {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size },
+ );
+}
diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts
index bb93993..889115c 100644
--- a/web/src/app/sitemap.ts
+++ b/web/src/app/sitemap.ts
@@ -11,6 +11,7 @@ const STATIC_ROUTES: Array<{
}> = [
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
{ path: "/docs", changeFrequency: "weekly", priority: 0.9 },
+ { path: "/playground", changeFrequency: "monthly", priority: 0.8 },
{ path: "/blog", changeFrequency: "weekly", priority: 0.8 },
{ path: "/examples", changeFrequency: "monthly", priority: 0.7 },
];
diff --git a/web/src/components/hero.tsx b/web/src/components/hero.tsx
index 1bc254f..9f4c8ca 100644
--- a/web/src/components/hero.tsx
+++ b/web/src/components/hero.tsx
@@ -28,6 +28,9 @@ export function Hero() {
Get started →
+
+ ▸ Try it in your browser
+
RoadmapSDKsBenchmarks
+ Playground
Examples
Docs
Blog
@@ -58,6 +59,7 @@ export function Nav({ variant = "landing" }: NavProps) {
Roadmap
SDKs
Benchmarks
+ Playground
Examples
Docs
Blog
@@ -114,6 +116,9 @@ export function Nav({ variant = "landing" }: NavProps) {
Benchmarks
+
+ Playground
+
Examples
@@ -141,6 +146,9 @@ export function Nav({ variant = "landing" }: NavProps) {
Benchmarks
+
+ Playground
+
Examples
diff --git a/web/src/components/playground-card.tsx b/web/src/components/playground-card.tsx
new file mode 100644
index 0000000..23cdc6a
--- /dev/null
+++ b/web/src/components/playground-card.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+
+// Homepage "try it" card. Sits directly under the hero (above the fold) so
+// the highest-leverage call to action — run SQLRite without installing
+// anything — is the first thing a visitor can click. SQLR-42.
+export function PlaygroundCard() {
+ return (
+
+
+
+
+ try it · zero install
+
Run SQLRite in your browser.
+
+ The full engine compiled to WebAssembly — SQL editor, sample
+ datasets, and HNSW vector search, all running in your tab. No
+ server, no signup, nothing to install.
+
+
+ Open the playground →
+
+
+
+ {`SELECT title, genre, year
+FROM movies
+ORDER BY vec_distance_cosine(
+ embedding, [0.85, 0.05, 0.40, 0.05]
+)
+LIMIT 5;`}
+