Writes and a batch
A booking and its seats in one transaction, a change to one booking, and what the lint refuses before anything is seeded.
pnpm add @niscorp/vex @niscorp/prism @niscorp/strata zod @electric-sql/pgliteThe database and the engine
Two empty tables. The booking’s id is given by the database, which is what the batch below is about.
import { PGlite } from '@electric-sql/pglite';
import { createPostgresAdapter, createQueryEngine, handleQuery, lintMutation, seedCache } from '@niscorp/vex';
import { createPglitePool } from '@niscorp/vex/pglite';
const db = new PGlite();
await db.exec(`
CREATE TABLE bookings (id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, member text, seats integer);
CREATE TABLE seats (booking_id integer REFERENCES bookings(id), seat text);
`);
const engine = createQueryEngine({ adapter: createPostgresAdapter({ pool: createPglitePool(db) }) });
await engine.introspect();Two writes, seeded
There is no generation path for writes. A mutation is written by a developer, linted at seed, stored protected, and replayed by name over the same wire a read uses. The grammar has five operations, and two are used here.
The first entry is a batch, and a batch runs in one transaction. $returned reads a column of the row an earlier statement wrote, which is how each seat takes the id the database gave the booking. If that statement wrote no row, or several, the batch fails and rolls back whole.
await seedCache(engine.cache, [
{
fingerprint: 'boxoffice/book',
mutation: [
{ op: 'insert', table: 'bookings', values: { member: { $context: 'member' }, seats: { $context: 'seats' } } },
{
op: 'insertEach',
table: 'seats',
items: { $context: 'seatList' },
values: { booking_id: { $returned: 'bookings.id' }, seat: { $item: 'seat' } },
},
],
},
{
fingerprint: 'boxoffice/setSeats',
mutation: {
op: 'update',
table: 'bookings',
set: { seats: { $context: 'seats' } },
where: { eq: ['bookings.id', { $context: 'id' }] },
},
},
]);Replay them by name
A write is replayed like a read: a name and its values. The handler answers a status and a JSON body, and resultOf picks the rows out of it.
The policy here allows everything. In an app it is compiled from the charter, and identity columns are the engine’s to stamp, over whatever a document says.
const policy = { default: 'allow' as const, entities: {} };
const config = { engine, locked: true, mutations: { client: db, policy } };
const resultOf = (response: { body: unknown }): string => JSON.stringify((response.body as { result: unknown }).result);
const booking = { member: 'Grace', seats: 2, seatList: [{ seat: 'C4' }, { seat: 'C5' }] };
const inserted = await handleQuery(config, { fingerprint: 'boxoffice/book', context: booking }, {});
console.log(resultOf(inserted));
const updated = await handleQuery(config, { fingerprint: 'boxoffice/setSeats', context: { id: 1, seats: 3 } }, {});
console.log(resultOf(updated));What the lint refuses
An update or delete whose where binds no caller value is bounded by nothing but the policy. lintMutation names that, and seedCache runs it on every mutation, so an unkeyed write never ships.
console.log(lintMutation({ op: 'delete', table: 'bookings', where: { eq: ['bookings.seats', 0] } }));Run it.
The batch answered with every row it wrote, the update with the row it changed, and the lint named what it would refuse.
npx tsx writes.mts[{"id":1,"member":"Grace","seats":2},{"booking_id":1,"seat":"C4"},{"booking_id":1,"seat":"C5"}]
{"id":1,"member":"Grace","seats":3}
[
'delete on "bookings" has no $context-keyed WHERE — the write is not caller-bounded'
]