Seed and lock
Every read the app depends on, stored under a name when the app starts. The endpoint replays them and generates nothing.
pnpm add @niscorp/vex @niscorp/prism @niscorp/strata zod @electric-sql/pgliteThe database and the engine
Start with a Postgres in the process and two bookings in it. PGlite is Postgres compiled to run wherever Node runs, and Vex talks to it through the same adapter it uses for a real one.
The engine reads the schema when it introspects: tables, columns, keys, and the relations between them. Everything Vex checks a query against comes from that read.
import { PGlite } from '@electric-sql/pglite';
import { createPostgresAdapter, createQueryEngine, handleDiscovery, handleQuery, seedCache } from '@niscorp/vex';
import { createPglitePool } from '@niscorp/vex/pglite';
const db = new PGlite();
await db.exec(`
CREATE TABLE bookings (id text PRIMARY KEY, night text, member text, seats integer);
INSERT INTO bookings VALUES ('B-2040', '2026-10-24', 'Ada', 1), ('B-2041', '2026-10-24', 'Grace', 2);
`);
const engine = createQueryEngine({ adapter: createPostgresAdapter({ pool: createPglitePool(db) }) });
await engine.introspect();Seed the reads
An app does not generate the reads it depends on. It writes them, as documents in Vex’s grammar, and seeds them under names when it starts. seedCache stores each entry protected, so no request can replace it.
The first read takes a value, the night, as $context. The second takes nothing. Neither says who may read what: that is the policy’s, and it is placed into the statement when the read runs.
await seedCache(engine.cache, [
{
fingerprint: 'boxoffice/coming',
dsl: {
from: ['bookings'],
fields: ['bookings.member', 'bookings.seats'],
filter: { eq: ['bookings.night', { $context: 'night' }] },
},
},
{
fingerprint: 'boxoffice/members',
dsl: {
from: ['bookings'],
fields: ['bookings.member'],
distinct: true,
sort: [{ field: 'bookings.member', dir: 'asc' }],
},
},
]);Lock the endpoint
What a server serves is a handler, and this is its configuration. locked: true makes it replay-only: anything stored under a name is answered, and anything that would need a model is refused. The app runs with no model.
const config = { engine, locked: true };GET: what there is
handleDiscovery is what a GET on the endpoint answers: every stored name, what each takes, and what it returns. None of it is written by anyone. The input is read off the document, each $context typed by the column it sits beside.
const discovery = await handleDiscovery(config);
for (const entry of discovery.fingerprints) {
console.log(entry.fingerprint, 'takes', Object.keys(entry.context ?? {}));
}A client, or an agent, learns what to call from the endpoint itself.
POST: a question nobody stored
A POST carries a name and its values, or an intent and a shape. This one has no name, so it would need generation, and the endpoint is locked. The handler answers as a server would: a status, and a JSON body that says why.
const request = { intent: 'Who booked the most seats?', shape: [{ member: '' }] };
const response = await handleQuery(config, request, {});
console.log(response.status, JSON.stringify(response.body));Run it.
Two reads listed with what each takes, and a question nobody stored refused with locked.
npx tsx seed.mtsboxoffice/coming takes [ 'night' ]
boxoffice/members takes []
400 {"error":"locked","message":"This endpoint is replay-only — unknown or changed fingerprints cannot generate here."}