Vex · guide

A search with a cursor

One stored search answers everyone, everyone matching, and the next page, by which keys the caller sends.

Install it
pnpm add @niscorp/vex @niscorp/prism @niscorp/strata zod @electric-sql/pglite
Step 1 of 3

The database and the engine

Five members in a table, and an engine that has read the schema. The search below is the only read in this file, and it is asked four ways.

The database and the engine
import { PGlite } from '@electric-sql/pglite';
import { createPostgresAdapter, createQueryEngine, seedCache } from '@niscorp/vex';
import { createPglitePool } from '@niscorp/vex/pglite';

const db = new PGlite();
await db.exec(`
  CREATE TABLE members (id text PRIMARY KEY, name text);
  INSERT INTO members VALUES
    ('ada', 'Ada'), ('alan', 'Alan'), ('grace', 'Grace'), ('linus', 'Linus'), ('mia', 'Mia');
`);
const engine = createQueryEngine({ adapter: createPostgresAdapter({ pool: createPglitePool(db) }) });
await engine.introspect();
Step 2 of 3

One search, stored once

One entry answers everyone, everyone matching a search, and the next page of either. What makes that possible is optional: a condition that is there only when the caller sends its key, and removed before the query compiles otherwise. It is not there, rather than there and matching everything, so no sentinel like % is needed.

There is no offset in the grammar. A page is a cursor: the last id seen, sent back as after. The sort by id keeps the order total, so no row straddles a page.

One search, stored once
await seedCache(engine.cache, [{
  fingerprint: 'members/search',
  dsl: {
    from: ['members'],
    fields: ['members.id', 'members.name'],
    filter: {
      and: [
        { optional: { key: 'q', then: { ilike: ['members.name', { $context: 'q' }] } } },
        { optional: { key: 'after', then: { gt: ['members.id', { $context: 'after' }] } } },
      ],
    },
    sort: [{ field: 'members.id', dir: 'asc' }],
    limit: 2,
  },
}]);
Step 3 of 3

Four questions, one entry

Each call sends a different set of keys to the same stored document. Nothing sent is everyone, two at a time. after is the next page. q is a search, and the two compose into the next page of a search.

Four questions, one entry
for (const context of [{}, { after: 'alan' }, { q: '%a%' }, { q: '%a%', after: 'alan' }]) {
  const response = await engine.execute({ fingerprint: 'members/search', context });
  console.log(JSON.stringify(context), '→', JSON.stringify(response.result));
}
Then

Run it.

The first two members, the two after Alan, the first two whose name has an a in it, and the two after Alan among those.

Run it
npx tsx search.mts
It prints
{} → [{"id":"ada","name":"Ada"},{"id":"alan","name":"Alan"}]
{"after":"alan"} → [{"id":"grace","name":"Grace"},{"id":"linus","name":"Linus"}]
{"q":"%a%"} → [{"id":"ada","name":"Ada"},{"id":"alan","name":"Alan"}]
{"q":"%a%","after":"alan"} → [{"id":"grace","name":"Grace"},{"id":"mia","name":"Mia"}]