diff --git a/apps/playground/index.html b/apps/playground/index.html
new file mode 100644
index 00000000..651b75af
--- /dev/null
+++ b/apps/playground/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+ SafeQL Playground
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/playground/package.json b/apps/playground/package.json
new file mode 100644
index 00000000..ea793e7f
--- /dev/null
+++ b/apps/playground/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@ts-safeql/playground",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "vue-tsc -b"
+ },
+ "dependencies": {
+ "@electric-sql/pglite": "^0.5.1",
+ "@shikijs/monaco": "^4.2.0",
+ "@ts-safeql/eslint-plugin": "workspace:*",
+ "@ts-safeql/generate": "workspace:*",
+ "@ts-safeql/shared": "workspace:*",
+ "@ts-safeql/sql-ast": "workspace:*",
+ "@typescript-eslint/parser": "catalog:",
+ "eslint": "catalog:",
+ "eslint-linter-browserify": "^10.4.1",
+ "fp-ts": "^2.16.9",
+ "libpg-query": "catalog:",
+ "monaco-editor": "^0.55.1",
+ "shiki": "^4.2.0",
+ "vue": "^3.5.13"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "@vitejs/plugin-vue": "^5.2.3",
+ "postgres": "catalog:",
+ "typescript": "catalog:",
+ "vite": "^6.4.3",
+ "vue-tsc": "^2.2.8"
+ }
+}
diff --git a/apps/playground/public/_headers b/apps/playground/public/_headers
new file mode 100644
index 00000000..592cbbda
--- /dev/null
+++ b/apps/playground/public/_headers
@@ -0,0 +1,6 @@
+# Cross-origin isolation is required for SharedArrayBuffer (the Atomics bridge to the DB worker).
+# Netlify / Cloudflare Pages read this file; Vercel uses vercel.json; the dev/preview servers
+# get the same headers from the crossOriginIsolation plugin in vite.config.ts.
+/*
+ Cross-Origin-Opener-Policy: same-origin
+ Cross-Origin-Embedder-Policy: require-corp
diff --git a/apps/playground/src/App.vue b/apps/playground/src/App.vue
new file mode 100644
index 00000000..6a181b15
--- /dev/null
+++ b/apps/playground/src/App.vue
@@ -0,0 +1,315 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Running SafeQL…
+
{{ lintError }}
+
No SafeQL issues found.
+
+
+ {{ diagnostic.ruleId ?? "error" }} · Line {{ diagnostic.line }}, column
+ {{ diagnostic.column }}
+
+ {{ diagnostic.message }}
+
+
+
+
+
+
+
+
+ {{ configError }}
+
+
+
+
+
+
diff --git a/apps/playground/src/components/MonacoEditor.vue b/apps/playground/src/components/MonacoEditor.vue
new file mode 100644
index 00000000..a5b83c44
--- /dev/null
+++ b/apps/playground/src/components/MonacoEditor.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
diff --git a/apps/playground/src/lib/defaults.ts b/apps/playground/src/lib/defaults.ts
new file mode 100644
index 00000000..0ce78df1
--- /dev/null
+++ b/apps/playground/src/lib/defaults.ts
@@ -0,0 +1,37 @@
+export const DEFAULT_SCHEMA = `CREATE TYPE role AS ENUM ('owner', 'admin', 'member');
+
+CREATE TABLE "user" (
+ id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ email text NOT NULL UNIQUE,
+ display_name text,
+ role role NOT NULL DEFAULT 'member',
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE post (
+ id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ author_id integer NOT NULL REFERENCES "user" (id),
+ title text NOT NULL,
+ body text,
+ published_at timestamptz
+);`;
+
+export const DEFAULT_CODE = `const feed = await sql\`
+ select
+ post.title,
+ post.published_at,
+ author.display_name,
+ author.role
+ from post
+ join "user" author on author.id = post.author_id
+ order by post.published_at desc nulls last
+\`;`;
+
+export const DEFAULT_CONFIG = `{
+ "targets": [{ "tag": "sql" }],
+ "fieldTransform": null,
+ "nullAsOptional": false,
+ "nullAsUndefined": false,
+ "overrides": { "types": {} },
+ "skipTypeAnnotations": false
+}`;
diff --git a/apps/playground/src/lib/eslint-engine.ts b/apps/playground/src/lib/eslint-engine.ts
new file mode 100644
index 00000000..098ec338
--- /dev/null
+++ b/apps/playground/src/lib/eslint-engine.ts
@@ -0,0 +1,94 @@
+// Linter only — importing from the "eslint" barrel pulls in the ESLint class (fs globbing,
+// glob-parent) which can't run in the browser. eslint-linter-browserify is the Linter alone.
+import { Linter } from "eslint-linter-browserify";
+import type { ESLint, Linter as LinterTypes } from "eslint";
+import * as tsParser from "@typescript-eslint/parser";
+import { rules } from "@ts-safeql/eslint-plugin";
+import type { PlaygroundConfig } from "./playground-config";
+import { createTsProgram } from "./ts-vfs";
+
+// typescript-eslint's RuleModule and ESLint core's Plugin type don't line up structurally;
+// this is the standard ESLint-API boundary cast.
+const safeqlPlugin = { rules } as unknown as ESLint.Plugin;
+const tseslintParser = tsParser as unknown as LinterTypes.Parser;
+
+export interface EngineDiagnostic {
+ line: number;
+ column: number;
+ endLine: number;
+ endColumn: number;
+ message: string;
+ ruleId: string | null;
+ // ESLint autofix as character offsets into the source, when the rule provides one.
+ fix?: { from: number; to: number; text: string };
+}
+
+export interface LintInput {
+ code: string;
+ databaseUrl: string;
+ config: PlaygroundConfig;
+}
+
+const linter = new Linter();
+
+// Map the playground's config to the rule's connection options. fieldTransform + overrides ride
+// along to `generate` via the worker params the rule builds, so the whole config takes effect.
+function toRuleConnection(databaseUrl: string, config: PlaygroundConfig) {
+ return {
+ databaseUrl,
+ targets: [
+ {
+ tag: config.tag,
+ fieldTransform: config.fieldTransform,
+ skipTypeAnnotations: config.skipTypeAnnotations,
+ },
+ ],
+ overrides: config.overrides,
+ nullAsOptional: config.nullAsOptional,
+ nullAsUndefined: config.nullAsUndefined,
+ };
+}
+
+// Reused across lints so TypeScript can incrementally rebuild instead of re-parsing lib files.
+let lastProgram: import("typescript").Program | undefined;
+
+export function lintWithRealRule(input: LintInput): EngineDiagnostic[] {
+ const { program, filename } = createTsProgram(input.code, lastProgram);
+ lastProgram = program;
+
+ const messages = linter.verify(
+ input.code,
+ {
+ files: ["**/*.ts"],
+ languageOptions: {
+ parser: tseslintParser,
+ parserOptions: {
+ programs: [program],
+ project: false,
+ ecmaVersion: 2020,
+ sourceType: "module",
+ },
+ },
+ plugins: { "@ts-safeql": safeqlPlugin },
+ rules: {
+ "@ts-safeql/check-sql": [
+ "error",
+ { connections: [toRuleConnection(input.databaseUrl, input.config)] },
+ ],
+ },
+ },
+ filename,
+ );
+
+ return messages.map((message) => ({
+ line: message.line,
+ column: message.column,
+ endLine: message.endLine ?? message.line,
+ endColumn: message.endColumn ?? message.column,
+ message: message.message,
+ ruleId: message.ruleId,
+ fix: message.fix
+ ? { from: message.fix.range[0], to: message.fix.range[1], text: message.fix.text }
+ : undefined,
+ }));
+}
diff --git a/apps/playground/src/lib/monaco-quickfix.ts b/apps/playground/src/lib/monaco-quickfix.ts
new file mode 100644
index 00000000..175d6d40
--- /dev/null
+++ b/apps/playground/src/lib/monaco-quickfix.ts
@@ -0,0 +1,70 @@
+import * as monaco from "monaco-editor";
+import type { EngineDiagnostic } from "./eslint-engine";
+
+// True module scope (a .ts module, unlike a Vue