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