query command reference
manni meta query builds a SQLite database from the metadata of the files you
point it at. It runs one SQL statement over that database, and judges what
the statement did. A read returns rows. An edit becomes per-file changes,
applied through the same writers fill uses. Where
validate judges one file against a schema,
query sees the whole corpus at once. That is what makes cross-file rules
expressible at all: duplicate slugs, dangling author references, a tag used
exactly once.
The engine is Node’s built-in node:sqlite.
Nothing is installed, and it is loaded only when a query actually runs.
This page is the command’s data model and semantics. The flag surface,
meaning every option, its argument, and its default, lives in the CLI
reference, which is drift-checked against
src/cli.ts. For the guided version, see gate on rules that span
files.
The docs table
Section titled “The docs table”One row per input file, built fresh in memory on every run and discarded when
it ends. --db writes a copy out; nothing else
persists.
SELECT * returns a deterministic column order regardless of the order files
were read: the four system columns first, then the data columns sorted by name.
System columns
Section titled “System columns”Four columns are manni meta’s own. They are reserved. A frontmatter key named
exactly _path, _format, _present, or _data is not lifted to a column, so
ordinary metadata can never shadow one. The reservation is those four names, not
the _ prefix. Any other underscore-prefixed key lifts like any other key.
| Column | Type | Value |
|---|---|---|
_path |
TEXT, primary key | The file’s label as manni meta prints it everywhere: relative to the run’s base directory, /-separated on every platform. <stdin> for piped input. |
_format |
TEXT | The extractor that read the file: markdown, mdx, asciidoc, rst, xml, html. |
_present |
INTEGER | 1 when the file carries a metadata block or surface at all, 0 when it does not. WHERE _present = 0 lists the files with no metadata yet. |
_data |
TEXT | The complete extracted metadata as JSON text. This is the escape hatch for nested values, and for a key a system column shadowed. |
A shadowed key stays reachable through _data:
manni meta query "SELECT _path, _data ->> '\$._path' AS shadowed FROM docs" docs/Data columns
Section titled “Data columns”The data columns are the union of top-level metadata keys across the corpus.
They also include any key the statement itself names as a SET or INSERT
target. That is how a key no file has yet gets created. A key absent from a file
is SQL NULL in that row.
Only top-level keys are lifted. Nested values are reached through _data with
SQLite’s JSON functions: ->>, json_extract,
and json_each. Keys that are not bare identifiers are ordinary quoted
identifiers:
manni meta query 'SELECT "sidebar position" FROM docs' docs/Data columns are declared with no type affinity. SQLite stores exactly what
each file held, and never coerces one file’s string into another file’s number.
The consequence worth knowing is that a bound number 2026 never equals a
stored string "2026". See bound parameters.
How values are encoded
Section titled “How values are encoded”| Metadata value | In the table |
|---|---|
| String | The string. |
| Number | The number. A non-finite number (NaN, Infinity) is NULL. |
| Boolean | 1 / 0, because node:sqlite refuses to bind a boolean. |
| Array or object | JSON text, which json_each and ->> query directly. |
null, or key absent |
NULL. The two are told apart by _data. |
The encoding is symmetric, and a write restores the value to the type the file had. See type restoration.
Encrypted values
Section titled “Encrypted values”A column whose schema marks it
x-manni-encrypt
holds what the page holds, the ciphertext. SELECT returns it, as manni meta get does. A WHERE owner = 'platform' therefore matches nothing on pages whose
owner is encrypted. Equal values encrypt to equal ciphertexts, so comparing one
page’s owner column with another’s does find the pages that share a value.
An UPDATE or INSERT that writes a marked column writes it encrypted. Each
written file’s schema set is resolved the way validate resolves it: config
schemas:, overrides, the file’s $schema, and -s. A value that is already
a ciphertext is written as it is. Every report prints (encrypted) in place of
the value, the json change objects included:
docs/auth.md: owner: (encrypted) -> (encrypted)A write that needs a key when none is available asks for one on a terminal, and
refuses off one with exit 2. See When a write needs a
key. A
--dry-run or --check never asks, because it writes nothing.
Functions available to a statement
Section titled “Functions available to a statement”Beyond everything stock SQLite provides, two functions are registered per run:
| Function | Returns |
|---|---|
lineFor(path, key) |
The 1-based source line the key sits on in that file. It is NULL for an unknown path, a key the extractor cannot place, or a non-string argument. This is how a finding carries a line. |
explicit_null() |
A per-run sentinel that writes a literal key: null into a file. Needed because SET k = NULL means remove the key. |
Collections as views
Section titled “Collections as views”Every collection becomes a
read-only SQL view over docs, named for the collection. A rule can then
say FROM authors instead of re-spelling the collection’s globs in SQL:
collections: - name: authors paths: ["authors/**"]
meta: overrides: - collection: authors schemas: [./schemas/author.json]manni meta query "SELECT d._path, d.author FROM docs d LEFT JOIN authors a ON a.slug = d.author WHERE d.author IS NOT NULL AND a._path IS NULL"The rules that define membership:
- Membership is the collection’s own globs. A file belongs to a collection
when it matches one of that collection’s
paths:and none of itsexclude:, relative to the config file’s directory. Nothing about the schema a file is judged by affects which view it appears in. - Views may overlap. A file two collections both match appears in both. The
config said it is in both, and that is what
FROM authorsreads as. - Every declared collection has a view. It does not matter whether an
override points at it, whether
--collectionselected it, or whether any file matched. An empty collection is an empty view rather than a missing table, so naming one is never a SQL error. - Views are read-only.
UPDATE authors …is refused by SQLite, and manni meta completes the refusal with the write-through spelling:UPDATE docs … WHERE _path IN (SELECT _path FROM "authors").
Views are built lazily, so a plain read that never names a collection does no
membership work at all. They are built eagerly for a --db
export, and for statements that observe the catalog.
Those are sqlite_master, sqlite_schema, PRAGMA, and
CREATE/DROP/ALTER, where a missing view could not be detected and retried.
The derived table
Section titled “The derived table”With derive: in config, a
second table is available. derived holds what the evidence says each
managed field should be, one row per file. The evidence is git history, the
CODEOWNERS file, the GitHub or GitLab review history, and the output of any
command the config names. It is built only when the
statement names derived as a table (after FROM, JOIN, UPDATE, and the
like, quoted or not). That is because building it spawns git, with the
github or gitlab source gh or glab, and with the command source the
configured programs. A statement that never names it pays nothing, and the
word inside a string literal or a comment does not count. A spelling the text
search misses is caught by SQLite’s own no such table: derived, which
triggers the same build.
Only the columns the statement can read are derived. A statement that names
owner alone consults the codeowners source and nothing else; one that
selects *, or reads _sources, derives every column and consults every
source derive.sources allows. A column the statement never names is NULL
in the view.
provenance is the exception, because it costs a git blame per page. It is
derived only when the statement names provenance or derive.fields lists
it, so SELECT * and SELECT count(*) show it NULL otherwise.
| Column | Type | Value |
|---|---|---|
_path |
TEXT, primary key | The file’s label, exactly as in docs, so the two tables join on it. |
created |
TEXT | The derived value, or NULL when the evidence has no answer. |
last-updated |
TEXT | As above. Quote the identifier, because of the hyphen: d."last-updated". |
authors |
TEXT | A JSON array, as a list is encoded in docs. |
owner |
TEXT | A JSON array, even for one owner, because a CODEOWNERS line can name several. |
reviewed-by |
TEXT | A JSON array. |
last-reviewed |
TEXT | A date. |
provenance |
TEXT | A JSON array of pins, one per range a machine wrote, each {"generated-by", "lines", "integrity"}. lines counts body lines, as the stamp does: a number for one line, a string such as "6-8" for a range. NULL when no line has machine evidence. Its _sources evidence is uncommitted, blame 4a6f60b, or blame 4a6f60b, 2 commits when several commits wrote the machine lines. |
one per key in derive.commands |
TEXT | The command’s trimmed stdout. A list or object is JSON text, as in docs. verified-against from jq -r .version package.json is 1.4.2; from a script printing {"name":"operator","version":"1.4.2"} it is that object’s JSON text. |
_sources |
TEXT | JSON text, {field: {source, evidence}}, saying where each non-null value came from and why. For a command column the source is command and the evidence is the argv, {path} already substituted. |
Every built-in field column is present whether or not the field is managed,
so one query shape serves every config. The command columns follow, one per
key in derive.commands,
so their set depends on the config. Quote a hyphenated name, as for
last-updated. A statement that names a command column derives only that
column and runs only that command. A read of owner still spawns nothing.
A field whose sources are excluded by
derive.sources, or that the statement never names, is NULL. A source a
named column needs that cannot answer is an error (exit 2) naming the fix,
exactly as for validate and derive, never an empty column.
The pages whose stamp disagrees with the evidence, and why:
manni meta query "SELECT d._path, d.\"last-updated\", d._sources ->> '\$.\"last-updated\".evidence' AS why FROM derived d JOIN docs USING (_path) WHERE docs.\"last-updated\" IS NOT d.\"last-updated\""derived is a view, so a write to it is refused by SQLite and manni meta
completes the refusal:
cannot modify derived because it is a view; a collection or the derived table is read-only.
The value it holds is not stored anywhere. It is recomputed from the
evidence on every run, and the only way to change it is to change the
evidence. For the same reason a --db export
never carries it. The view and its backing table are dropped before the file
is closed, so an export cannot freeze a derived value. A managed field in
docs is read-only too, for a different reason, given under the DML
constraints.
Because a query builds the derived view and its _derived_rows table beside
docs, a collection may
not take either name, in any case. The config is refused when it loads (exit 2).
The resolved table
Section titled “The resolved table”resolved is the third table, beside docs and derived. It holds the
resolved frontmatter of every file, one row each. A resolved value is the
asserted one when the document carries the key, and the derived one
otherwise. _origin says which of the two came back.
The three tables answer three different questions. docs is what the file
says. derived is what the evidence says. resolved is what to believe.
resolved is a view over the other two, so it cannot disagree with them
and costs no second read of the corpus. It is built under the same rule
derived follows. A statement that never names it pays nothing, and a
statement that names it derives only the columns it can read. So
SELECT owner FROM resolved consults codeowners alone, and
SELECT * FROM resolved consults every source
derive.sources allows.
| Column | Type | Value |
|---|---|---|
_path |
TEXT, primary key | The file’s label, exactly as in docs and derived, so all three join on it. |
every column docs has |
as in docs |
The asserted value when the document carries the key, the derived value when it does not, and NULL when neither side has one. The system columns _format, _present and _data stay in docs, so a statement that needs them joins on _path. |
every derivable field docs lacks |
TEXT | The derived value, or NULL when no source answered. A list or object is JSON text, as in docs. |
_origin |
TEXT | JSON text, {field: "asserted"} or {field: "derived"}, carrying only the fields that resolved to something. A field neither side has is absent from the object. |
_sources |
TEXT | JSON text, {field: {source, evidence}}, exactly as in derived. |
Presence is decided by _data, not by NULL. A column is NULL both when
the key is absent and when the document writes key: null, so the view asks
_data which of the two it is. That is the same rule the system
columns already state for docs. A document that writes
owner: null has asserted a value, so resolved keeps the null and
_origin says asserted.
Where a document asserts a value and the evidence disagrees, the asserted one
wins, because that is what the page publishes. The drift is still visible.
validate files it as derived:stale, and the join below asks for it
directly.
SELECT _path, owner, _origin ->> '$.owner' AS origin FROM resolved;SELECT _path FROM resolved WHERE _origin ->> '$.owner' = 'derived';SELECT d._path FROM docs d JOIN derived x USING (_path) WHERE d."last-updated" IS NOT x."last-updated";The first prints each page’s owner and where it came from. The second lists
the pages CODEOWNERS covers that nobody wrote an owner into. The third is
the drift question, and it stays a docs to derived join, because that is
the comparison resolved deliberately does not make.
provenance is a list, so json_each turns it into one row per range. Every
range on every page, with the machine that wrote it:
$ manni meta query "SELECT _path, json_extract(e.value, '\$.generated-by') AS machine, json_extract(e.value, '\$.lines') AS lines FROM resolved, json_each(resolved.provenance) AS e"_path machine linesdocs/limits.md claude-fable-5 8docs/limits.md claude-fable-5 10docs/limits.md claude-sonnet-5 12docs/limits.md Claude Opus 5 134 rowsThe lines column counts body lines, because that is what the record stores.
A range attributed by hand after its lines were committed appears here and not
in derived. The commit that wrote those lines names no machine.
Every write to resolved is refused, and the refusal names both ways
forward:
$ manni meta query "UPDATE resolved SET owner = 'x'"manni meta query: SQL error: cannot modify resolved because it is a view; the resolved table is read-only — it is `docs` and the evidence joined, so write to docs, or stamp the evidence with manni meta derive. exit 2A --db export never carries it. resolved is
dropped before the file closes, along with derived and its backing table,
because half of every row is recomputed from the evidence on every run. A
collection may not be
called resolved either, and the config is refused when it loads (exit 2).
The SQL vocabulary
Section titled “The SQL vocabulary”One statement per run. Text after the first ; is refused rather than silently
dropped, because prepare() would compile only the first statement and ignore
the rest.
Statements are judged by their effects on the disposable projection, never by parsing their syntax. A read leaves no diff and returns rows. A statement that changes a cell, adds a row, removes one, or changes the column set has done something to the corpus. That effect is mapped back into file space.
The split, in one line. DML edits the rows, which are the files. DDL edits the table definition, which is the schema.
DML statements edit files
Section titled “DML statements edit files”| Statement | Meaning in file space |
|---|---|
SELECT … |
Rows out. Nothing is written, no schema is resolved, no network is used. |
UPDATE docs SET k = v |
Set key k in every matching file. A key no file has yet widens the table and is created. |
UPDATE docs SET k = NULL |
Remove k from matching files. Absent already is a no-op. |
UPDATE docs SET k = explicit_null() |
Write a literal k: null, the rare case SET k = NULL cannot express. |
UPDATE docs SET b = a, a = NULL |
Rename a key. The original value is carried over verbatim, never round-tripped through its JSON-text projection. |
UPDATE docs SET _path = '…' |
Move or rename the file. The body is byte-preserved; the extension may not change. |
INSERT INTO docs (_path, …) VALUES (…) |
Create a file with that front matter and an empty body. |
DELETE FROM docs WHERE … |
Strip the front matter block. The file and its body survive, because SQL never deletes a file. |
Constraints on a DML statement:
-
System columns other than
_pathare read-only. Changing_format,_present, or_datais an error naming the column and the file. -
A
_pathmove may not be combined with cell edits in one statement, so each has its own preview. -
A new path must be usable, meaning relative, with no
..segment, inside the corpus, and not the corpus root itself. AnINSERTor rename onto a_pathalready loaded is refused by the table’s primary key. -
A write cannot touch
<stdin>, because there is no file behind it. -
A key a manifest owns is written to the manifest. Its value is in every row, and a write to it lands in the document’s manifest entry, not the page. The manifest edits are planned with the page edits, so a refusal anywhere in the statement writes nothing.
Statement On a manifest-owned key UPDATE docs SET k = vSets kin the document’s entry, creating the entry if it is missing.UPDATE docs SET k = NULLRemoves kfrom the entry, and the entry once it is empty.UPDATE docs SET k = explicit_null()Writes k: nullin the entry.UPDATE docs SET b = a, a = NULLCarries the value across page and manifest, whichever side owns each name. UPDATE docs SET _path = '…'Renames the document’s entry in a path-joined manifest. INSERT INTO docs …Owned keys go to a new entry, and the rest to the new page. DELETE FROM docs WHERE …Strips the block and removes the document’s entry. ALTER TABLE docs DROP COLUMN kAlso removes kfrom each document’s entry.keys:is unchanged.An encrypted column writes its ciphertext into the manifest. Six writes still refuse, before any file is written:
- A key a URL manifest owns:
"owner" is owned by manifest https://…/owners.yaml, which is fetched and cannot be written; set it in that repository. ALTER TABLE … RENAME COLUMNof an owned key, because the manifest’skeys:would need renaming:"docs/auth.md": "jira" is owned by manifest docs-meta.yaml; edit the manifest instead.- An
UPDATEof the join field on a document that has an entry in a manifest joined by a field. It refuses with"docs/auth.md": "id" is the field manifest docs-meta.yaml joins on, and this document has an entry; change the manifest first.A_pathmove is fine there, because the page carries its join value with it. A write to an owned key on a page with no join value refuses too:"docs/new.md": "jira" is owned by manifest docs-meta.yaml, which joins on "id", and this document has no id; set id first. - An
UPDATEorINSERTthat gives a document a join value naming an entry it did not already match, because the write would take over that entry:"docs/noid.md": "id" "auth-guide" names the entry of another document in manifest docs-meta.yaml; choose another value. - A write to a key owned by a manifest of a collection
--collectionleaves out. The rows were read without that manifest, so the statement never saw its values:"docs/auth.md": "owner" is owned by manifest docs-meta.yaml of collection b, which --collection leaves out; include it or edit the manifest. - A
_pathmove or aDELETEof a page that such a manifest names. The move would leave a path entry behind. ADELETEwould keep the entry, or strip the join value it matches:"docs/x.md": manifest b-meta.yaml of collection b names it, which --collection leaves out; include it or rename the entry first.ADELETEendsremove the entry first.
- A key a URL manifest owns:
-
A key its schema prefers in external metadata, marked
x-manni-location: external, with no manifest to hold it, is offered one. On a terminal the statement asks once per collection whether to run relocate for those keys, and on yes the write lands in the manifest. Off a terminal, on a no, or under--dry-run, the value is written to the page and one warning per collection says so:manni: wrote reviewer to 2 pages in collection site; the schema prefers external metadata, and no manifest owns it. Run manni meta relocate to move it. -
A key config
derive.fieldsmanages is read-only. Its value is computed from evidence, andmanni meta deriveis the one command that writes it. ASET, remove, rename,INSERTorDELETEthat would touch it refuses at plan time, before any file is written. The refusal reads"last-updated" is managed by derive; run manni meta derive instead. -
The
derivedtable is a view. A write to it refuses withcannot modify derived because it is a view; a collection or the derived table is read-only. There is nothing behind it to write to. -
The
resolvedtable is a view too. A write to it refuses withcannot modify resolved because it is a view; the resolved table is read-only, and the message goes on to name both ways forward. Write todocs, or stamp the evidence withmanni meta derive.
Format limits on a write
Section titled “Format limits on a write”| Operation | Works on |
|---|---|
| Set a key, remove a key, rename a key | Any writable format. Element-backed keys in HTML and XML refuse rather than half-apply. |
DELETE (strip the block) |
Formats whose metadata lives in a fenced front matter block: markdown, MDX, AsciiDoc, reStructuredText. HTML and XML have no block to strip and refuse by name. |
INSERT (create a file) |
Formats whose writer can build a block from nothing: markdown and MDX. Every other writer needs an existing block, <head>, or root element and refuses. |
UPDATE … SET _path (move) |
Any format, because the file is moved rather than rewritten. |
Type restoration
Section titled “Type restoration”A value written back is restored to the type the file held for that key. 1/0
goes back to a boolean, JSON text back to an array or object, and a number stays
a number. For a key no file had, the column’s dominant type across the
corpus decides. When the corpus is tied, or the key is new everywhere, the value
is written as SQL handed it back. A DDL BOOLEAN add always
restores real booleans.
A value the target type cannot hold refuses by file and key. That covers a non-boolean for a boolean key, or invalid JSON for an array key. An unquoted number for a string key, or a BLOB, does the same.
Statements that are refused
Section titled “Statements that are refused”| Statement | Why |
|---|---|
ATTACH … |
It creates a database file of its own, outside the table the effect gate watches. |
VACUUM … |
VACUUM INTO writes its target before the engine could refuse. |
DROP TABLE docs |
Refused as the accident-shaped spelling of two real statements; the message names both DELETE FROM docs WHERE … and ALTER TABLE docs DROP COLUMN. |
Anything after a ; |
A second statement would be compiled away silently. |
The refusal is on the statement’s first real token, and leading comments do not disguise it.
Writes apply by default
Section titled “Writes apply by default”A mutating statement applies. There is no --write flag. This is the
polarity most people guess wrong, so it is worth stating flatly: running
manni meta query "UPDATE docs SET draft = false WHERE draft IS NULL" docs/ edits
your files.
The polarity matches fill, so one flag name means “show, don’t do” across
every command that writes:
| Invocation | What happens |
|---|---|
manni meta query "UPDATE …" docs/ |
The edit is applied. |
manni meta query --dry-run "UPDATE …" docs/ |
The exact per-file diff is printed. Nothing is touched. |
manni meta query --check "UPDATE …" docs/ |
--check implies the dry run. Pending changes are counted as findings and the exit code carries the verdict. Nothing is touched. |
manni meta query -f csv "UPDATE …" docs/ |
Refused (exit 2), because a diff has no tabular shape. The run was forced dry first, so the refusal is truthful and nothing landed. |
Because --check never mutates, every query --check gate in CI is a read-only
step by construction. That includes the ones whose statement is an UPDATE,
which is how a backfill becomes a drift gate.
Application is all-or-nothing. Phase one computes every file’s new content. Any refusal aborts before a single byte lands. That covers an unwritable format, a bad restoration, or a file that changed on disk since it was read. Phase two writes atomically. A half-applied bulk edit would leave the corpus in a state no statement describes.
DDL edits the schema
Section titled “DDL edits the schema”An ALTER TABLE docs statement changes the corpus and the schema that governs
it in one step. That turns a rollout that used to be two commits into one
statement.
| Statement | Effect on the schema | Effect on the files |
|---|---|---|
ALTER TABLE docs ADD COLUMN k <type> |
properties.k gains the mapped type. |
None, without a DEFAULT. |
… ADD COLUMN k <type> NOT NULL |
k also joins required. |
n/a |
… ADD COLUMN k <type> DEFAULT v |
n/a | v is backfilled into every file. |
… ADD COLUMN k TEXT CHECK (k IN ('a','b')) |
properties.k gains enum: ["a", "b"]. |
n/a |
ALTER TABLE docs DROP COLUMN k |
properties.k and its required entry are removed. |
k is removed from every file. |
ALTER TABLE docs RENAME COLUMN a TO b |
The property is renamed, carrying its whole subschema. | The key is renamed in every file, values verbatim. |
One DDL action per statement, which is SQLite’s own ALTER grammar. DDL needs
at least one loaded file, because the backfill and the reconciliation both run
over the corpus.
Which schema an ALTER edits
Section titled “Which schema an ALTER edits”DDL is the one part of query that resolves schemas. It does so from disk and
the bundled built-ins only, under the same trust boundary
validate uses. A document’s $schema
cannot point the edit outside the repository.
- The corpus must resolve to one schema set. A run split across override
groups refuses, naming each group and its glob. Scope the run to one group, or
name the set with
-s. - A corpus on the built-in default set refuses. There is no schema of yours
to evolve. The message points at the
UPDATEspellings, which cover every data-only case. - A local schema file is edited in place, with
propertiesandrequiredonly, your indent and line endings preserved, and git as the review surface. A vendored file’sintegrity:pin is refreshed in the same write. - A built-in forks. Built-ins are immutable, so the statement writes
schemas/<name>-<version>.local.jsonbeside the config, with$idsuffixed+local. It repoints every reference that named the built-in. That is the config entry (either spelling, comments intact) and any in-file$schema, string or list. Every file the statement touches, the config included, appears in the preview. - A URL reference refuses with “vendor it first”
(
manni meta schemas vendor). DDL cannot inspect, let alone edit, a schema a server owns.
Where ownership is unclear, DDL refuses rather than guesses:
| Situation | Refusal |
|---|---|
| The set names several local schema files | DDL cannot tell which to evolve. |
| The set names several built-ins and no local file | DDL cannot tell which to fork. |
ADD of a key the target already declares |
Its subschema, constraints included, would be overwritten. Edit the file. |
ADD of a key another schema in the set declares or requires |
Two contracts on one key. |
DROP/RENAME of a key no schema in the set declares |
Nothing to edit. |
DROP/RENAME of a key several schemas declare |
A statement edits one schema. Evolve them separately. |
RENAME onto a name already declared in the set |
The existing declaration would be overwritten. |
A DEFAULT the declared type or format cannot hold |
The corpus would fail the schema it just gained. |
The type bridge
Section titled “The type bridge”An ALTER’s declared column type becomes the JSON Schema property. The mapping
is checked in this order, and the order is load-bearing. A bare affinity rule
would let /INT/ swallow json-pointer:
| Declared type | Property |
|---|---|
| A name equal (case-insensitively) to a format the validator enforces | { "type": "string", "format": "<name>" } |
DATETIME, TIMESTAMP |
{ "type": "string", "format": "date-time" }, a closed alias pair of exactly two |
BOOLEAN, BOOL |
{ "type": "boolean" } |
Anything matching INT |
{ "type": "integer" } |
Anything matching CHAR, CLOB, or TEXT |
{ "type": "string" } |
Anything matching REAL, FLOA, DOUB, NUMERIC, or DEC |
{ "type": "number" } |
| Anything else | No type. The property is added unconstrained. |
The format set is derived from the validator’s own registration rather than a
parallel list, so a formats upgrade widens it for free. It currently holds
byte, date, date-time, duration, email, hostname, ipv4, ipv6,
iso-date-time, iso-time, json-pointer, json-pointer-uri-fragment,
regex, relative-json-pointer, time, uri, uri-reference, uri-template,
url, and uuid. A hyphenated name is written as a quoted type:
ADD COLUMN updated "date-time".
BOOLEAN columns accept only 0, 1, true, or false as a DEFAULT, and
the backfill writes real booleans into the files. The 1/0 encoding is a
bind-layer detail, not a file-layer one.
A CHECK becomes an enum, in exactly one shape:
CHECK (<the-new-column> IN (<literals>)), with the literals all strings or all
numbers, agreeing with the declared type. A mixed list, an expression, another
column, an AND, or a second CHECK refuses, and names the hand edit to the
schema file instead. A constraint silently dropped from a schema would be worse.
The preview always prints the whole property the schema will gain, so a near-miss is visible before anything is written:
schema schemas/house.json: + reviewed_on (string, format date, required)docs/one.md: reviewed_on: (unset) -> 2026-08-26docs/two.md: reviewed_on: (unset) -> 2026-08-263 changes across 3 files — dry run; run again without --dry-run to applyADD COLUMN due "DUE-DATE" maps to nothing and shows as an unconstrained
+ due, which is the tell that the type name was wrong.
Naming the DDL target directly
Section titled “Naming the DDL target directly”-s <ref> (repeatable) names the schema set the statement’s DDL evolves. Use it
when resolution is ambiguous, or when you simply know which contract you are
evolving. Ambiguity means several local schemas in one set, or a corpus split
across override groups:
manni meta query -s ./schemas/house.json \ "ALTER TABLE docs ADD COLUMN reviewed TEXT NOT NULL DEFAULT 'pending'" docs/The flag is CLI precedence for the DDL planner only. The per-file resolution
walk is skipped and the deduped -s refs are the set, unanimous by
construction. Unlike validate’s -s, it does not reshape the corpus: named
collection views and plain reads keep following the config’s resolution. Every
guard in the section above still runs inside the named set.
Because the flag speaks only to DDL, three situations refuse rather than let it quietly mean nothing:
| Situation | Refusal |
|---|---|
| The statement produced no schema-evolving effects | Exit 2, before any file or schema write. Judged after execution, since DDL is effect-judged, so the refusal lands on the plan side of the all-or-nothing line. A --db export target, written before the statement ran, persists and reflects the statement; the message says so. |
There is no SQL at all (a bare --db export) |
Exit 2. Nothing can evolve without a statement. |
A -s-named fork nothing would resolve afterwards |
Exit 2 with nothing written. If no config schemas: entry and no loaded file’s $schema names the built-in, the fork would be orphaned. validate would keep using the un-evolved built-in. |
Rows as findings
Section titled “Rows as findings”--check reinterprets the result: rows are findings. Any returned row exits
1; an empty result exits 0. Pending changes count the same way, which is
what makes a mutating statement a read-only drift gate. The pretty verdict
line shows ✓ or ✗.
With -f github, -f sarif, or -f junit, the rows render as annotations,
SARIF results, or JUnit failures. They use the same column convention the
config’s checks:
entries use:
| Column | Role |
|---|---|
path |
Required. The file the finding attaches to. |
line |
The 1-based source line. A non-integer or non-positive value is dropped. Usually lineFor(_path, 'key'). |
key |
The metadata field at fault. It becomes the finding’s instancePath, and with it part of the finding’s baseline identity. |
message |
The prose. |
| anything else | Folded into the message as col=value pairs, NULL cells omitted. When nothing is left, the message is check matched. |
Findings from a query --check run carry the rule id check:query/check, and
JUnit testcases ship under the classname manni.query rather than
manni.validate:
::error file=docs/alpha.md,line=3::[check:query] /slug duplicate slug alphaThree things are an error (exit 2). A findings format without --check. A
--check result with no path column. A findings format on a statement that
produced changes rather than rows.
Bound parameters
Section titled “Bound parameters”Bind a runtime value; never splice it into the SQL text, where one apostrophe breaks the statement:
manni meta query --param author="O'Brien" \ "SELECT _path FROM docs WHERE author = \$author" docs/--param name=valuebinds the value as a string. The statement refers to it with any of SQLite’s named-parameter spellings:$name,:name, or@name. (:is the shell-friendliest.)--param name:=jsonparses the value as JSON for a deliberate typed bind.n:=5is a number,d:=truea boolean (as1/0, the projection’s encoding), andv:=nullNULL.- The split happens at the first separator and everything after it is the
value verbatim, so
--param msg=a=bbindsa=b.:=is checked before=. - Parameter names are letters, digits, and underscores, starting with a letter or underscore. Anything else is refused, because a name the scanner cannot see could never be bound.
- Parameters work anywhere a value goes, in reads,
--checkgates, andUPDATEs. Anonymous?placeholders are not supported from the CLI, because flag order is not a contract to rely on.
Strings are the default because metadata is mostly strings, and the data columns
have no type affinity. A bound number 2026 would never equal a stored string
"2026". Mind the shell when a typed value is a string that looks like a
number. --param 'v:="5"' binds the string 5, while --param v:=5 binds the
number.
Two refusals (exit 2) keep a typo from becoming a silently-green gate:
- A parameter the SQL references with nothing bound. Unbound would bind NULL
and match nothing, and a zero-row
--checkis a passing gate. - A
--paramthe statement never references, reported by the engine as an unknown named parameter.
One bare name under two prefixes ($p and :p in one statement) is refused.
Both spellings are named, rather than left to the engine’s generic “conflicting
names”.
Output formats
Section titled “Output formats”| Format | Renders | Gate |
|---|---|---|
pretty |
An aligned table (header, then values; SQL NULL prints as (null)) and a row count. For an edit, the per-file diff and the mode’s verdict. |
Default. |
json |
The bare array, with row objects for a read and change objects for an edit. The --check verdict travels in the exit code, not the envelope. |
n/a |
csv |
Result rows as CSV. | Refuses a statement that produced changes, and a --db-only export. |
github |
One ::error annotation per row. |
Needs --check and a path column. |
sarif |
One SARIF result per row. | Needs --check and a path column. |
junit |
One JUnit <failure> per row. |
Needs --check and a path column. |
There is no -q/--quiet. get uses it to hide files and validate to hide
passes, and a query result has no analogous noise. The rows are exactly what was
asked for.
The change objects in json
Section titled “The change objects in json”An edit’s json output is an array of change objects. Every object carries
file and written. The rest of the shape says which kind of change it is.
- A cell set (
key,from,to). - A deletion (
key,from,deleted). - A key rename (
key,renamedFrom,to). - A stripped block (
cleared,from). - A created file (
created,to). - A moved file (
renamed). - A schema edit (
schema,op,key, and the mappedtype/format/enum/required). - A config side effect (
config,key,from,to).
In any of these, from is omitted when the file had no previous value. A
change that lands in an external-metadata manifest also carries manifest,
the manifest as the run reports it. The pretty line ends with the same name
in brackets: docs/auth.md: owner: (unset) -> platform [docs-meta.yaml].
[ { "file": "docs/beta.md", "key": "title", "from": "Beta", "to": "X", "written": false }, { "file": "docs/auth.md", "key": "owner", "to": "platform", "written": false, "manifest": "docs-meta.yaml" }]The CSV dialect
Section titled “The CSV dialect”-f csv is the single-table hop into a spreadsheet or a pandas one-liner;
--db remains the heavyweight export. The dialect, chosen once:
- A header row always. A zero-row result is the header alone, which a script can tell apart from a step that failed and printed nothing.
- LF line endings, a deliberate divergence from RFC 4180, which specifies
CRLF. LF is the right default for scripts and CI. A consumer that insists on
CRLF, such as Excel double-clicking a
.csvon Windows, should re-terminate the lines. Quoting itself is RFC 4180, so a field containing a comma, quote, or newline is wrapped in double quotes, and quotes double. - SQL
NULLis an empty field. - Arrays and objects stay JSON text, the same encoding
jsonand the--dbexport use.
-f csv --check is legal: a check’s rows are still rows, the exit code carries
the verdict, and the header-only output is the passing gate.
Exporting the database
Section titled “Exporting the database”--db <path> writes the built table, collection views included, to a SQLite
file for any other tool to open. The derived and
resolved tables are the two things left out. Both
are dropped before the file closes, along with derived’s backing table,
because their values are recomputed from the evidence on every run. A frozen
copy would be the stale stamp they exist to catch. With no SQL, the export is
the whole job:
manni meta query --db docs.db docs/Wrote docs.db (4 files, 11 columns)With SQL as well, the rows own stdout and the export note goes to stderr as a
diagnostic. In json, an export-only run prints { path, files, columns }.
The file is a regenerated artifact of your corpus. Rebuild it whenever you like, and do not edit it expecting the files to notice. A SQLite database or an empty file at that path is overwritten. Anything else is refused, since anything else is somebody’s data. Missing parent directories are created.
Datasette is the natural front-end, and its
Datasette Lite build runs entirely in the browser.
sqlite3, DuckDB, and Grafana read the format just as well.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
The statement ran. With --check: it returned no rows and no pending changes. For a mutating statement: it applied cleanly. |
1 |
Only from --check, when rows or pending changes came back. Because --check implies the dry run, its findings are always still pending. |
2 |
Operational. SQL that cannot be prepared, a refused statement, a flag that would mean nothing, and every write refusal. A broken statement is a usage error, not a finding. |