A search with a cursor
One stored search answers everyone, everyone matching, and the next page, by which keys the caller sends.
pnpm add @niscorp/vex @niscorp/prism @niscorp/strata zod @electric-sql/pgliteThe 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.
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();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.
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,
},
}]);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.
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));
}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.
npx tsx search.mts{} → [{"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"}]