From 2319f058f3bdf0422ca57850254671ccfe65f63a Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Thu, 11 Jun 2026 14:52:39 +0300 Subject: [PATCH] feat: SafeQL playground Add an in-browser playground (apps/playground) that runs the genuine @ts-safeql/check-sql ESLint rule entirely in the browser: write a query and see SafeQL's inferred result types and inline lint diagnostics live. The rule drives an async PGlite-backed `generate` from inside its sync ESLint context via a SharedArrayBuffer + Atomics bridge across two workers, with a small browser-compat shim layer for the node builtins it loads. Registers apps/* in the pnpm workspace and adds a root `playground` script. --- apps/playground/index.html | 21 + apps/playground/package.json | 36 ++ apps/playground/public/_headers | 6 + apps/playground/src/App.vue | 315 +++++++++++++++ .../src/components/MonacoEditor.vue | 125 ++++++ apps/playground/src/lib/defaults.ts | 37 ++ apps/playground/src/lib/eslint-engine.ts | 94 +++++ apps/playground/src/lib/monaco-quickfix.ts | 70 ++++ apps/playground/src/lib/monaco-shiki.ts | 40 ++ apps/playground/src/lib/pglite-sql.ts | 139 +++++++ apps/playground/src/lib/playground-config.ts | 103 +++++ apps/playground/src/lib/real-lint-client.ts | 113 ++++++ apps/playground/src/lib/safeql-theme.ts | 47 +++ apps/playground/src/lib/share-url.ts | 101 +++++ apps/playground/src/lib/ts-vfs.ts | 111 +++++ apps/playground/src/main.ts | 5 + apps/playground/src/postgres-socket.d.ts | 7 + apps/playground/src/shims/eslint-browser.ts | 25 ++ .../src/shims/eslint-unsupported.ts | 13 + apps/playground/src/shims/libpg-query.ts | 123 ++++++ apps/playground/src/shims/node-crypto.ts | 26 ++ apps/playground/src/shims/node-empty.ts | 34 ++ apps/playground/src/shims/node-globals.ts | 38 ++ apps/playground/src/shims/node-os.ts | 24 ++ apps/playground/src/shims/node-url.ts | 14 + apps/playground/src/shims/node-util.ts | 30 ++ apps/playground/src/shims/path-stub.ts | 104 +++++ apps/playground/src/shims/postgres-errors.ts | 18 + apps/playground/src/shims/postgres.ts | 10 + apps/playground/src/shims/synckit.ts | 33 ++ apps/playground/src/style.css | 269 ++++++++++++ apps/playground/src/worker/db.worker.ts | 144 +++++++ .../playground/src/worker/real-lint.worker.ts | 103 +++++ apps/playground/tsconfig.json | 20 + apps/playground/vercel.json | 12 + apps/playground/vite.config.ts | 118 ++++++ package.json | 3 +- pnpm-lock.yaml | 382 ++++++++++++++++-- pnpm-workspace.yaml | 1 + 39 files changed, 2880 insertions(+), 34 deletions(-) create mode 100644 apps/playground/index.html create mode 100644 apps/playground/package.json create mode 100644 apps/playground/public/_headers create mode 100644 apps/playground/src/App.vue create mode 100644 apps/playground/src/components/MonacoEditor.vue create mode 100644 apps/playground/src/lib/defaults.ts create mode 100644 apps/playground/src/lib/eslint-engine.ts create mode 100644 apps/playground/src/lib/monaco-quickfix.ts create mode 100644 apps/playground/src/lib/monaco-shiki.ts create mode 100644 apps/playground/src/lib/pglite-sql.ts create mode 100644 apps/playground/src/lib/playground-config.ts create mode 100644 apps/playground/src/lib/real-lint-client.ts create mode 100644 apps/playground/src/lib/safeql-theme.ts create mode 100644 apps/playground/src/lib/share-url.ts create mode 100644 apps/playground/src/lib/ts-vfs.ts create mode 100644 apps/playground/src/main.ts create mode 100644 apps/playground/src/postgres-socket.d.ts create mode 100644 apps/playground/src/shims/eslint-browser.ts create mode 100644 apps/playground/src/shims/eslint-unsupported.ts create mode 100644 apps/playground/src/shims/libpg-query.ts create mode 100644 apps/playground/src/shims/node-crypto.ts create mode 100644 apps/playground/src/shims/node-empty.ts create mode 100644 apps/playground/src/shims/node-globals.ts create mode 100644 apps/playground/src/shims/node-os.ts create mode 100644 apps/playground/src/shims/node-url.ts create mode 100644 apps/playground/src/shims/node-util.ts create mode 100644 apps/playground/src/shims/path-stub.ts create mode 100644 apps/playground/src/shims/postgres-errors.ts create mode 100644 apps/playground/src/shims/postgres.ts create mode 100644 apps/playground/src/shims/synckit.ts create mode 100644 apps/playground/src/style.css create mode 100644 apps/playground/src/worker/db.worker.ts create mode 100644 apps/playground/src/worker/real-lint.worker.ts create mode 100644 apps/playground/tsconfig.json create mode 100644 apps/playground/vercel.json create mode 100644 apps/playground/vite.config.ts 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 @@ + + + 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