query command reference
docmeta 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 docmeta’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 docmeta 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:
docmeta 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:
docmeta 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.
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. |
Named collections as views
Section titled “Named collections as views”Every overrides[] entry that
carries a name: becomes a read-only SQL view over docs. A rule can then
say FROM authors instead of re-spelling the group’s glob in SQL:
overrides: - name: authors files: "authors/**" schemas: [./schemas/author.json]docmeta 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 resolution winner, not the glob match. A view holds exactly the files that override won schema resolution for. Because resolution is first-match-wins, views are disjoint. A file two named globs both match belongs to the earlier one only. The later view simply does not contain it.
- A file whose own
$schemaoutranks the override is not a member, since it is not judged by that group’s schema. A stderr line says so, rather than leaving it to be discovered. - A file whose resolution fails is a member of no view. Labeling never
gates. A refused or malformed
$schemademotes that file out of the views, and the query still runs.validateis where that refusal becomes a finding. - An empty group is an empty view, not an error.
- Views are read-only.
UPDATE authors …is refused by SQLite, and docmeta 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 pays no
resolution walk 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 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.
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
docmeta 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 |
|---|---|
docmeta query "UPDATE …" docs/ |
The edit is applied. |
docmeta query --dry-run "UPDATE …" docs/ |
The exact per-file diff is printed. Nothing is touched. |
docmeta 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. |
docmeta 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”
(
docmeta 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:
docmeta 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 docmeta.query rather than
docmeta.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:
docmeta 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.
[ { "file": "docs/beta.md", "key": "title", "from": "Beta", "to": "X", "written": false }]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. With no SQL, the export is the whole job:
docmeta 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. |