Skip to content

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.

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.

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:

Terminal window
manni meta query "SELECT _path, _data ->> '\$._path' AS shadowed FROM docs" docs/

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:

Terminal window
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.

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.

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.

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.

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:

manni.config.yaml
collections:
- name: authors
paths: ["authors/**"]
meta:
overrides:
- collection: authors
schemas: [./schemas/author.json]
Terminal window
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 its exclude:, 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 authors reads as.
  • Every declared collection has a view. It does not matter whether an override points at it, whether --collection selected 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.

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:

Terminal window
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).

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 lines
docs/limits.md claude-fable-5 8
docs/limits.md claude-fable-5 10
docs/limits.md claude-sonnet-5 12
docs/limits.md Claude Opus 5 13
4 rows

The 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 2

A --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).

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.

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 _path are read-only. Changing _format, _present, or _data is an error naming the column and the file.

  • A _path move 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. An INSERT or rename onto a _path already 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 = v Sets k in the document’s entry, creating the entry if it is missing.
    UPDATE docs SET k = NULL Removes k from the entry, and the entry once it is empty.
    UPDATE docs SET k = explicit_null() Writes k: null in the entry.
    UPDATE docs SET b = a, a = NULL Carries 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 k Also removes k from 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 COLUMN of an owned key, because the manifest’s keys: would need renaming: "docs/auth.md": "jira" is owned by manifest docs-meta.yaml; edit the manifest instead.
    • An UPDATE of 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 _path move 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 UPDATE or INSERT that 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 --collection leaves 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 _path move or a DELETE of a page that such a manifest names. The move would leave a path entry behind. A DELETE would 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. A DELETE ends remove the entry first.
  • 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.fields manages is read-only. Its value is computed from evidence, and manni meta derive is the one command that writes it. A SET, remove, rename, INSERT or DELETE that 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 derived table is a view. A write to it refuses with cannot 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 resolved table is a view too. A write to it refuses with cannot modify resolved because it is a view; the resolved table is read-only, and the message goes on to name both ways forward. Write to docs, or stamp the evidence with manni meta derive.

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.

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.

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.

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.

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.

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 UPDATE spellings, which cover every data-only case.
  • A local schema file is edited in place, with properties and required only, your indent and line endings preserved, and git as the review surface. A vendored file’s integrity: pin is refreshed in the same write.
  • A built-in forks. Built-ins are immutable, so the statement writes schemas/<name>-<version>.local.json beside the config, with $id suffixed +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.

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-26
docs/two.md: reviewed_on: (unset) -> 2026-08-26
3 changes across 3 files — dry run; run again without --dry-run to apply

ADD COLUMN due "DUE-DATE" maps to nothing and shows as an unconstrained + due, which is the tell that the type name was wrong.

-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:

Terminal window
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.

--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 alpha

Three 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.

Bind a runtime value; never splice it into the SQL text, where one apostrophe breaks the statement:

Terminal window
manni meta query --param author="O'Brien" \
"SELECT _path FROM docs WHERE author = \$author" docs/
  • --param name=value binds 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:=json parses the value as JSON for a deliberate typed bind. n:=5 is a number, d:=true a boolean (as 1/0, the projection’s encoding), and v:=null NULL.
  • The split happens at the first separator and everything after it is the value verbatim, so --param msg=a=b binds a=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, --check gates, and UPDATEs. 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 --check is a passing gate.
  • A --param the 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”.

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.

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 mapped type/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"
}
]

-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 .csv on 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 NULL is an empty field.
  • Arrays and objects stay JSON text, the same encoding json and the --db export 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.

--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:

Terminal window
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.

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.