MindQL
MindQL is a small declarative query language for the engrava thought-graph. It provides a human-readable syntax for retrieving and counting rows, plus a read-only SQL passthrough.
Syntax
[EXPLAIN] FIND <table> [WHERE <bool-expr>] [ORDER BY <sort> ...] [LIMIT <n>] [OFFSET <n>]
[EXPLAIN] COUNT <table> [WHERE <bool-expr>]
[EXPLAIN] SELECT <raw read-only SQL>
- The command verb (
FIND,COUNT,SELECT) is case-insensitive. FINDandCOUNTrequire a table name as the second token.- A simple
WHEREcondition isfield operator value. Quoting decides how the value is typed, not whether it is allowed: a single-quoted value is kept verbatim as a string, so a zero-padded identifier likesource = '007'matches the stored string'007', whereas an unquoted value becomes anintorfloatwhen it parses as one —created_cycle = 7is coerced to the integer7— and otherwise stays a string. An unquoted value ends at the first whitespace, so a value containing spaces must be quoted. A single-quoted value may contain a literal quote by doubling it, SQL-style:source = 'O''Brien'matches the stored stringO'Brien, in a condition or inside anIN (...)list. - A boolean keyword (
AND,OR) is recognised only as a whole word outside a quoted value, so an unquoted value that happens to end or start with one (source = vendor,source = ORACLE) is never mistaken for the operator. - Operators:
=,!=,>,<,>=,<=, andIN (...). - Conditions combine with
AND,OR, and parentheses (see Boolean expressions). FINDadditionally supportsORDER BYandOFFSET; any read query may be prefixed withEXPLAINto see the compiled SQL without running it.COUNTalso parses a trailingLIMITclause, but ignores it — see COUNT for the full behavior.
Queryable tables
| Token(s) | Table |
|---|---|
thoughts, thought |
thought |
edges, edge |
edge |
embeddings, embedding |
embedding |
actions, action |
action |
Commands
FIND
Retrieve rows from a table.
FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 10
FIND thoughts WHERE lifecycle_status = 'ACTIVE' AND priority = 'P1'
FIND thoughts WHERE priority IN ('P1', 'P2') ORDER BY created_cycle DESC LIMIT 20
FIND thoughts ORDER BY created_cycle DESC LIMIT 10 OFFSET 20
FIND edges WHERE edge_type = 'ASSOCIATED' LIMIT 5
Filterable thought columns include thought_type, lifecycle_status,
priority, essence, content, source, confidence, visibility,
confirmation_count, created_cycle, updated_cycle, and thought_id.
A column outside the per-table allowlist raises MindQLParseError.
Default row cap. A FIND with no LIMIT clause is capped at 100 rows
when it runs, so an unqualified FIND thoughts can never trigger an unbounded
scan. The cap is applied at execution, not at parse time — parse("FIND thoughts")
leaves query.limit as None, and the executor substitutes the default only if
no explicit LIMIT is present. An explicit LIMIT always overrides the default;
COUNT queries are unaffected (they aggregate and never materialise the rows).
Returns: matching rows as dicts.
ORDER BY
FIND accepts an ORDER BY clause with one or more sort keys, each with an
optional direction (ASC default, or DESC, case-insensitive):
FIND thoughts ORDER BY created_cycle DESC
FIND thoughts WHERE priority = 'P1' ORDER BY priority ASC, created_cycle DESC LIMIT 10
Every sort field must be in the per-table column allowlist. Sort fields are
column identifiers, not values — they are validated against the allowlist
and never bound as parameters, so an unknown field raises MindQLParseError.
ORDER BY is emitted before LIMIT/OFFSET. It is rejected on COUNT (which
returns a scalar) and is not used with the raw SELECT passthrough.
OFFSET / pagination
FIND accepts an OFFSET <n> clause (n >= 0) for pagination, combined with
LIMIT and ORDER BY:
FIND thoughts ORDER BY created_cycle DESC LIMIT 20 OFFSET 40
SQLite requires a LIMIT for OFFSET, so an OFFSET with no explicit LIMIT
applies the default row cap as the limit. OFFSET raises a parse
error on COUNT; on the SELECT passthrough it is not rejected, because the
statement is handed to SQLite unchanged. For stable pages, pair OFFSET with
an ORDER BY so the row order is deterministic.
WHERE operators
IN
The IN operator matches a column against a set of values:
FIND thoughts WHERE thought_type IN ('BELIEF', 'OBSERVATION')
FIND thoughts WHERE created_cycle IN (1, 2, 3)
Each value follows the same quoting rule as a comparison value: single-quoted
stays a verbatim string, and an unquoted value becomes an int or
float when it parses as one, otherwise it stays a string. An empty
IN () is a parse error. Every value is bound as a ? placeholder (never
interpolated), and the field must be in the per-table allowlist.
Boolean expressions: AND, OR, parentheses
Conditions (simple comparisons, IN, and valid-time predicates) combine with
AND, OR, and parenthesised grouping, using SQLite-standard precedence:
AND binds tighter than OR:
FIND thoughts WHERE priority = 'P1' OR lifecycle_status = 'ACTIVE'
FIND thoughts WHERE priority = 'P1' OR priority = 'P2' AND source = 'x'
FIND thoughts WHERE (priority = 'P1' OR priority = 'P2') AND lifecycle_status = 'ACTIVE'
Because AND binds tighter, the second example groups as
priority = 'P1' OR (priority = 'P2' AND source = 'x'). Use parentheses (as in
the third example) to group an OR before an AND. A WHERE that uses only
simple comparisons and/or valid-time predicates joined by AND (no OR, no
parentheses, no IN) is read as a flat list of conditions joined by AND; a
leading, trailing, or doubled AND raises MindQLParseError.
Parenthesised grouping may nest up to 256 levels deep; nesting deeper than
that raises MindQLParseError.
COUNT
Count rows matching the filters. COUNT rejects ORDER BY and OFFSET with a
parse error; a LIMIT clause parses but has no effect — the executor drops it
and returns the count over all matching rows.
COUNT thoughts WHERE lifecycle_status = 'ACTIVE'
COUNT thoughts WHERE thought_type = 'OBSERVATION'
COUNT edges
Returns: the count is exposed on MindQLResult.count.
SELECT
SELECT is a read-only SQL passthrough — it runs the statement verbatim,
so it needs a full FROM clause and standard SQL syntax. The underlying
tables are thought, edge, embedding, and action.
SELECT thought_id, essence FROM thought WHERE lifecycle_status = 'ACTIVE'
SELECT thought_id, priority, essence FROM thought WHERE thought_type = 'BELIEF' LIMIT 20
Only statements that begin with SELECT are permitted; anything else is
rejected. The passthrough is also restricted to a single statement: once
a statement-ending ; appears, only whitespace and comments may follow it, and
anything else is rejected (MindQLParseError: Only a single SELECT statement is allowed). A ; inside a comment, a string literal (as in SELECT ';' AS s) or
a quoted identifier is not a separator.
Bound parameters. The passthrough can carry bound parameters, set
programmatically on the query object (never parsed from the MQL text). When
MindQLQuery.select_params is set, the statement runs with those values bound
to its ? placeholders — the preferred way to pass untrusted values into a
passthrough:
from engrava import MindQLQuery, MindQLCommand, MindQLExecutor
query = MindQLQuery(
command=MindQLCommand.SELECT,
raw_sql="SELECT thought_id FROM thought WHERE priority = ?",
select_params=("P1",),
)
result = await MindQLExecutor(conn).execute(query)
When select_params is None (the default, and what parse() produces) the
statement runs with no bound parameters, exactly as before.
The executor re-validates a hand-built query. A
MindQLQueryis public API and can be constructed directly, as above, so validation is not left toparse(): every identifier and keyword that has to be interpolated is checked again where the query runs. The executor validates the target table against its allowlist (defaulting tothought), every column named in aWHEREcomparison, anINlist or anORDER BYkey against that table’s column allowlist, the sort direction againstASC/DESCand the boolean joiner againstAND/OR(either case accepted), andlimit/offsetas non-negative, non-boolean integers. Anything outside those sets raisesMindQLParseErrorbefore a statement is built. Each check also emits the module’s own string — or, for the row bounds, a rebuilt plainint— rather than the object you passed, so astrorintsubclass cannot pass the membership check and then render different text through__format__.
Valid-time predicates
FIND and COUNT against the thoughts and edges tables accept four opt-in
valid-time predicates in the WHERE clause, for querying when a fact was
true in the world (the second time axis — see
The Bi-temporal Model for the full semantics):
FIND thoughts WHERE valid_now
FIND edges WHERE valid_at '2026-01-01T00:00:00+00:00'
FIND thoughts WHERE priority = 'P1' AND valid_within '2026-01-01T00:00:00+00:00' '2026-02-01T00:00:00+00:00'
FIND thoughts WHERE valid_between '2026-01-01T00:00:00+00:00' '2026-12-31T00:00:00+00:00'
valid_nowtakes no argument;valid_attakes one ISO-8601 timestamp;valid_withinandvalid_betweentake two.- They combine with ordinary conditions via
AND. valid_now/valid_at/valid_withinare NULL-tolerant (a record with an openvalid_from/valid_untilbound stays in the result);valid_betweenrequires real bounds on both ends and therefore excludes open-bound rows.- A query that uses no temporal predicate behaves exactly as before.
Valid time is predicate-only, not a filterable column. Query valid time only through the four predicates above.
valid_fromandvalid_untilare not in the per-table column allowlist, so an ordinary comparison such asWHERE valid_from = '2026-01-01T00:00:00+00:00'is rejected when the query runs (MindQLParseError: Column 'valid_from' not allowed for table 'thought') — usevalid_at/valid_within/valid_betweeninstead.
The semantics, the open-interval (NULL = ±∞) rule, and invalidate are
documented in full on The Bi-temporal Model.
Python API
Use store.execute_mindql() to run a parsed query on the store’s own
connection and read the result:
from engrava import parse
result = await store.execute_mindql(
parse("FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 10")
)
for row in result.rows:
print(row["essence"])
CLI Usage
# Find observations
engrava --db my.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"
# Count active thoughts
engrava --db my.db query "COUNT thoughts WHERE lifecycle_status = 'ACTIVE'"
# Select specific fields (raw SQL passthrough)
engrava --db my.db query "SELECT thought_id, essence FROM thought LIMIT 5"
Output formats: --format table (default), --format json, --format csv.
For the exhaustive package reference — including EXPLAIN, extension commands,
and the full Python API — see the OSS
mindql.md docs.