jevQL
September 18, 2026 ยท View on GitHub
jevQL
Semantic SQL for vanilla PostgreSQL. One extra family of functions, jev(), works in any query, from the CLI, from a shared HTTP and MCP node, or from the Go, TypeScript and Python SDKs.
SELECT name, city, jev_prob(people, 'could work from home') AS p
FROM people
WHERE jev(people, 'could work from home')
AND country = 'PT'
ORDER BY p DESC
LIMIT 20;
The database only ever sees ordinary SQL. jev_* calls are evaluated by the
CLI using TypeSafe's System One model (Jev). No
CREATE EXTENSION, no superuser, no wire-protocol proxy.
Install
Homebrew (macOS and Linux):
brew install kylemclaren/tap/jevql
Prebuilt binaries for macOS and Linux (arm64 and amd64) are attached to each
GitHub release as
jevql_<version>_<os>_<arch>.tar.gz with a checksums.txt.
From source, with Go 1.23+ and a C compiler (libpg_query is bundled via
pg_query_go and needs CGO):
CGO_ENABLED=1 go install github.com/kylemclaren/jevql/cmd/jevql@latest
The first build compiles libpg_query and takes about a minute. On macOS,
xcode-select --install provides the compiler. If the build complains about
CGO, make sure CGO_ENABLED=1 is set and that cc is on your PATH.
Use
export TYPESAFE_API_KEY=tsk_...
export DATABASE_URL=postgres://user:pass@localhost:5432/app
jevql # REPL
jevql -c "SELECT * FROM people WHERE jev(people, 'could work from home')"
jevql -f query.sql
jevql --explain -c "..." # plan + cost estimate, no API calls
jevql "postgres://..." -c "SELECT 1" # plain statements pass straight through
Run jevql with nothing configured and it asks for a database URL and a
TypeSafe API key (hidden input) and offers to save them to
~/.config/jevql/env (mode 0600). That file holds KEY=VALUE lines and
only fills in environment variables that are not already set, so exported
variables and flags always win. TYPESAFE_API_URL can be added there too.
Inside the REPL, \set TYPESAFE_API_KEY tsk_... sets the key for the session.
Statements without jev_* are sent to Postgres unchanged. Any psql
connection style works: a URI, -h/-p/-U/-d, PG* environment variables, or
DATABASE_URL.
Things to try
Any table with a text column is fair game. A few that are more fun than "could work from home":
-- Meetings that could have been an email
SELECT title, organizer
FROM meetings
WHERE jev(meetings, 'could have been an email')
AND starts_at > now();
-- Commit messages that are really apologies
SELECT left(sha, 7), message
FROM commits
WHERE jev(commits, 'is apologising for something')
ORDER BY committed_at DESC
LIMIT 20;
-- What is the reviewer actually upset about?
SELECT jev_choice(reviews, 'what is the reviewer really upset about?',
ARRAY['food', 'service', 'price', 'parking', 'other people']) AS gripe,
count(*)
FROM reviews
WHERE stars <= 2
GROUP BY 1
ORDER BY 2 DESC;
-- Rank the wine list by how insufferable the tasting note is
SELECT name, price,
jev_score(wines, 'how pretentious is the tasting note?',
ARRAY['plain', 'flowery', 'insufferable']) AS pretension
FROM wines
ORDER BY pretension DESC
LIMIT 5;
-- Job posts that are sales roles in disguise
SELECT title, company
FROM jobs
WHERE jev(jobs, 'is really a sales job despite the title')
AND posted_at > now() - interval '7 days';
-- Support tickets where the customer already turned it off and on again
SELECT id, subject
FROM tickets
WHERE jev(tickets, 'the customer says they already restarted it')
AND status = 'open';
-- Weeknight-feasible vegetarian dinners, best guesses first
SELECT name, jev_prob(recipes, 'can be cooked on a weeknight in under 40 minutes') AS p
FROM recipes
WHERE jev((name, ingredients), 'is vegetarian')
ORDER BY p DESC
LIMIT 10;
-- Pull requests by reviewer mood
SELECT jev_choice(prs, 'what is the overall tone of the review comments?',
ARRAY['nitpicky', 'blocking', 'rubber stamp', 'genuinely helpful']) AS mood,
count(*)
FROM prs
WHERE merged_at IS NULL
GROUP BY 1;
-- Slack messages that are passive-aggressive, by author
SELECT author, count(*) AS incidents
FROM messages
WHERE jev((author, text), 'is passive-aggressive')
AND sent_at > now() - interval '30 days'
GROUP BY author
ORDER BY incidents DESC;
Every one of these judges only the rows the SQL lets through, so the date and status filters matter for the bill. --explain shows the count before you pay.
SQL surface
The first argument is a FROM alias (people, or p in FROM people p).
All of that relation's columns are packed into a JSON row object and sent to
TypeSafe. To send fewer columns use the column-list form or --columns:
jev( (name, job_title, bio), 'could work from home' )
| Call | Returns |
|---|---|
jev(alias, 'condition') | boolean, p >= threshold |
jev(alias, 'condition', 0.7) | boolean with an explicit threshold |
jev_prob(alias, 'condition') | float8 in 0..1 |
jev_choice(alias, 'question', ARRAY['a','b']) | text |
jev_score(alias, 'question', ARRAY['lo','mid','hi']) | float8 weighted level index |
jev_score_norm(...) | float8 in 0..1 |
jev_confidence(alias, 'question' [, 'noul'|'choice'|'score', ARRAY[...]]) | float8 |
jev_eval(alias, 'question' [, kind, ARRAY[...]]) | raw answer JSON |
Threshold precedence: explicit argument, then --threshold, then
JEV_THRESHOLD, then 0.5. NOT jev(...) is supported as p < threshold.
What v1 supports
A single SELECT (a WITH is fine as long as the CTE has no jev_*):
jev()as anANDterm inWHERE,NOT jev()too.jev_*as a whole SELECT-list expression, and inORDER BY.GROUP BYoverjev_choice(...)(or any mix of jev / column keys) withcount(*),count(x),sum,avg,min,max. Aggregation happens in the CLI, sosum/avgneed numeric columns.ORDER BY,LIMIT,OFFSET. They are pushed to Postgres when the collected rows are exactly the final rows (no jev filter, no grouping, no jev sort key); otherwise they run in the CLI after judging.- Joins.
jev(p, ...)andjev(c, ...)on different aliases in one query.
It errors with a message rather than guessing on: jev_* in
INSERT/UPDATE/DELETE/DDL, in a CTE or subquery, in HAVING, under a window
function, OR jev(...), comparisons like jev_prob(...) > 0.7 (use
jev(alias, q, 0.7)), SELECT DISTINCT, set operations, and tables with
bytea columns unless you narrow the columns.
How it runs
- Collect. The statement is parsed with libpg_query (no regexes). Every non-jev predicate stays in the SQL that goes to Postgres; jev predicates, GROUP BY and jev-dependent ORDER BY/LIMIT are stripped. The SELECT list is extended with the columns needed to build row objects.
- Judge. Row objects are canonicalised, deduplicated and looked up in a
local sqlite cache. Misses are packed
--batch-sizerows per request per question and sent with--concurrencyworkers. Ctrl-C cancels in flight. - Project. Booleans, probabilities, choices and scores are attached in process, then filter, group, sort, limit, and print.
--explain prints the collect SQL, the row count after SQL filters, batch
count, a rough token estimate and cost. It makes no TypeSafe requests.
Flags
-c string run one statement and exit
-f file run file and exit
-h host -p port -U user -d dbname -w / -W
--api-key else TYPESAFE_API_KEY
--api-url default https://api.typesafe.ai/v1/systemone (or TYPESAFE_API_URL)
--model default jev-latest
--threshold default 0.5 (or JEV_THRESHOLD)
--batch-size default 40
--concurrency default 6
--max-rows default 2500 abort before any HTTP call if collect exceeds this
--max-chars default 0 abort if row objects exceed this many chars (0 = off)
--cache path, default ~/.cache/jevql/cache.db
--no-cache
--explain plan + cost estimate, no TypeSafe calls
--timing like psql \timing
--csv / --json output format (default aligned table)
--expanded / -x like psql \x
-v verbose: collect SQL, batches, tokens, cost
--columns a,b columns to send for alias-form jev(alias, ...)
REPL meta commands: \q \timing \x \d [rel] \dt \dn \l \i file
\set JEV_THRESHOLD 0.7 \cache [stats|clear] \explain <query> help.
Other backslash commands print "not implemented" instead of being sent to
the server.
Exit codes: 0 ok, 1 SQL or usage error, 2 TypeSafe API or budget error.
After a jev query the REPL (or -v) prints a footer:
jev: 129 judged, 4 req, 82 cache hits, 21.0k in tokens, \$0.0009, 1.02s
Cache
Answers are cached in sqlite (modernc.org/sqlite, pure Go) keyed by
sha256(model + kind + question + options + canonical_row_json). Canonical
JSON has sorted keys, RFC 3339 timestamps in UTC, no whitespace, and NULL
columns included as null. There is no expiry in v1; use \cache clear or
--no-cache.
Security and honesty
- Row contents go to TypeSafe. Every column of the judged alias (or the
column list you give) is sent over HTTPS to the API. Do not use this on
data you cannot share with TypeSafe. Use the column-list form or
--columnsto send the minimum. - The API key comes from
TYPESAFE_API_KEY,--api-key, the interactive prompt, or~/.config/jevql/env, and is never printed. The saved config file stores the key and the database URL (including its password) in plain text with mode0600; delete it if that is not acceptable. Point--api-urlat a proxy if your environment injects credentials. - This is not a Postgres extension. The server never learns
jev(); if you sendWHERE jev(...)through psql or JDBC it will fail with "function jev(...) does not exist" until it goes through this CLI or a real extension such aspg-jev. - Every jev query is a full scan of the post-SQL-filter row set. Put cheap,
indexed predicates in SQL first, and use
--explainto see how many rows and dollars a query costs before running it.--max-rows(default 2500) aborts before any HTTP call. - The cache file holds row-content hashes and TypeSafe answers. It is created
with mode
0600in~/.cache/jevql, but it is not encrypted; delete it with\cache clearif that matters. - This is not a wire-compatible psql replacement for GUI tools or
applications. Cost is estimated at $0.042 per million input tokens; the
usagefigures in the footer come from the API.
SDKs
The parser, rewriter, cache and TypeSafe client live in this Go module. Every
SDK reuses them rather than re-implementing SQL analysis. The wire format
shared by jevql serve, jevql --json-table and the SDKs is in sdk/PROTOCOL.md.
Go (in process)
import jevql "github.com/kylemclaren/jevql/sdk/go"
client, err := jevql.New(ctx, jevql.Options{DatabaseURL: os.Getenv("DATABASE_URL")})
defer client.Close(ctx)
rows, err := client.QueryMaps(ctx,
"SELECT name, jev_prob(people, 'could work from home') AS p FROM people WHERE country = 'PT' ORDER BY p DESC LIMIT 5")
for _, r := range rows {
fmt.Println(r["name"], r["p"])
}
TypeScript (npm i jevql)
The package bundles the jevql engine for your platform and starts a private one on localhost the first time you query. Nothing else to install.
import { Jevql } from "jevql"
const db = new Jevql() // embedded engine; reads DATABASE_URL, TYPESAFE_API_KEY
// const db = new Jevql({ url: "http://127.0.0.1:7433", token: "..." }) // a shared jevql serve
const tickets = await db.queryObjects(
"SELECT id, subject FROM tickets WHERE jev(tickets, 'is about billing') AND status = 'open'",
)
await db.close()
Python (pip install jevql)
from jevql import Jevql
with Jevql() as db: # embedded engine; or Jevql(url=..., token=...)
for row in db.query_dicts("SELECT title FROM movies WHERE jev(movies, 'a safe pick for a first date')"):
print(row["title"])
For a team, run one shared engine and point every client at it:
jevql serve --listen 0.0.0.0:7433 --token secret # behind your own TLS proxy
For an open demo node drop the token with --insecure (or JEVQL_INSECURE=1)
and fence it with --cors <origins>, --rate-limit <per minute per IP>,
--max-rows <n> and a read-only database role. That is how the public
playground at https://jevql.fly.dev/playground is served; deploy/ has a
Dockerfile and fly.toml for your own node.
MCP
jevql mcp is an MCP server for agents: tools query, explain, judge,
list_tables and describe_table, a jevql://sql-surface resource and a
semantic-query prompt. Read-only unless started with --allow-writes.
claude mcp add jevql -- jevql mcp # Claude Code
jevql serve --token secret # also mounts the same server at /mcp over HTTP
Development
go build ./cmd/jevql
go test ./... # parse, rewrite (golden), typesafe mock, cache
PGTEST_URL=postgres://... go test ./internal/exec/ # loads testdata/people.sql into schema jevql_test
go test ./internal/rewrite/ -update # regenerate testdata/golden
Layout:
cmd/jevql/ main
internal/app/ flags, connection, REPL, meta commands, spinner
internal/parse/ pg_query walk, jev call extraction, statement analysis
internal/rewrite/ collect SQL builder and column layout
internal/exec/ two-pass execution: collect, judge, project
internal/typesafe/ HTTP client, batching, response parsing
internal/cache/ sqlite answer cache
internal/psqlout/ aligned / expanded / csv / json output
internal/canon/ canonical JSON for row objects
internal/stats/ the jev footer
testdata/ people.sql, queries/*.sql, golden/*.collect.sql
Prior art: realZachi/pg-jev (the
extension whose function names this mirrors) and
EugeneBoondock/jevsql (a
two-pass client).
Releasing
Tag a version and push it: git tag v0.2.0 && git push origin v0.2.0. The
release workflow builds native CGO binaries for darwin/linux on arm64/amd64,
attaches them plus checksums.txt to a GitHub release, and regenerates
Formula/jevql.rb in kylemclaren/homebrew-tap
using scripts/brew-formula.sh. That step needs a HOMEBREW_TAP_TOKEN
repository secret with push access to the tap.