dirsql 0.3.70 → 0.3.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ # Search documents by meaning
2
+
3
+ Ask a question in plain language and get the closest documents back — even
4
+ when they share no keywords with it. Three pieces you already have compose
5
+ into semantic search: a SQLite vector extension for the distance math, an
6
+ [`on-file`](../reference/hooks.md#on-file) command to embed each file at
7
+ index time, and a [`pre-query`](../reference/hooks.md#pre-query) command to
8
+ embed each question at query time.
9
+
10
+ ## How the pieces fit
11
+
12
+ 1. **[`[[dirsql.extension]]`](../reference/config.md#dirsql-extension)**
13
+ loads [`sqlite-vec`](https://github.com/asg017/sqlite-vec), making
14
+ `vec_distance_cosine()` callable in queries.
15
+ 2. **`on-file`** runs an embedding command once per matched file; the
16
+ vector lands in an `embedding` column next to the text.
17
+ 3. **`pre-query`** receives each raw `POST /query` body, embeds the
18
+ question, and prints the nearest-neighbor SQL to run.
19
+
20
+ `dirsql` never sees a model — both hooks are commands you own, so any
21
+ embedding model works. This guide uses
22
+ [`model2vec`](https://github.com/MinishLab/model2vec), a small, fast,
23
+ CPU-only embedding library (its `potion-base-8M` model downloads ~30 MB
24
+ from Hugging Face on first use).
25
+
26
+ ## 1. The embedding scripts
27
+
28
+ Suppose short notes live in `notes/*.md`:
29
+
30
+ ```
31
+ notes/pasta.md # boiling spaghetti, olive oil, garlic
32
+ notes/branches.md # git feature branches and pull requests
33
+ notes/tomatoes.md # planting tomato seedlings after the last frost
34
+ ```
35
+
36
+ Next to them, `embed.py` turns one file into one row carrying its text and
37
+ its embedding (a JSON array, stored as TEXT — `sqlite-vec` accepts JSON
38
+ vectors directly):
39
+
40
+ ```python
41
+ """Embed one file's text; print a dirsql row array on stdout."""
42
+ import json
43
+ import sys
44
+
45
+ from model2vec import StaticModel
46
+
47
+ path = sys.argv[1]
48
+ text = open(path, encoding="utf-8").read()
49
+ model = StaticModel.from_pretrained("minishlab/potion-base-8M")
50
+ vector = model.encode([text])[0]
51
+ print(json.dumps([{"text": text, "embedding": json.dumps([round(float(x), 6) for x in vector])}]))
52
+ ```
53
+
54
+ And `search.py` turns a `{"q": "..."}` request body into SQL, embedding the
55
+ question with the same model:
56
+
57
+ ```python
58
+ """Turn a {"q": "..."} request body into a nearest-neighbor SQL query."""
59
+ import json
60
+ import sys
61
+
62
+ from model2vec import StaticModel
63
+
64
+ body = json.loads(sys.argv[1])
65
+ model = StaticModel.from_pretrained("minishlab/potion-base-8M")
66
+ vector = model.encode([body["q"]])[0]
67
+ needle = json.dumps([round(float(x), 6) for x in vector])
68
+ print(
69
+ "SELECT _path, ROUND(vec_distance_cosine(embedding, '%s'), 3) AS distance "
70
+ "FROM notes ORDER BY distance LIMIT 3" % needle
71
+ )
72
+ ```
73
+
74
+ ::: warning The hook owns SQL safety
75
+ Whatever SQL `pre-query` prints is executed as-is. Here the interpolated
76
+ value is a numeric vector the script itself produced; never splice raw
77
+ request text into SQL. See [`pre-query`](../reference/hooks.md#pre-query).
78
+ :::
79
+
80
+ ## 2. Wire them up in `.dirsql.toml`
81
+
82
+ ```toml
83
+ [dirsql]
84
+ pre-query = "uv run --with model2vec python search.py {args}"
85
+ hook-timeout = 300 # headroom for the first-run model download
86
+
87
+ [[dirsql.extension]]
88
+ path = "sqlite_vec" # Python module name; see note below
89
+ entrypoint = "sqlite3_vec_init"
90
+
91
+ [[table]]
92
+ ddl = "CREATE TABLE notes (_path TEXT, text TEXT, embedding TEXT)"
93
+ glob = "notes/*.md"
94
+ on-file = "uv run --with model2vec python embed.py {path}"
95
+ ```
96
+
97
+ The extension is named by package: the Python launcher resolves the
98
+ installed `sqlite_vec` module to its bundled loadable. Naming rules per
99
+ runtime — and the literal-path alternative that works everywhere — are in
100
+ [Load a SQLite extension](./load-extension.md).
101
+
102
+ ## 3. Start the server and ask questions
103
+
104
+ Launch with `sqlite-vec` available to the launcher's environment:
105
+
106
+ ```bash
107
+ uvx --with sqlite-vec dirsql
108
+ ```
109
+
110
+ The initial scan runs `embed.py` once per note. Then ask:
111
+
112
+ ```bash
113
+ curl -s http://localhost:7117/query \
114
+ -H 'content-type: application/json' \
115
+ -d '{"q": "how do I cook pasta?"}'
116
+ ```
117
+
118
+ ```json
119
+ [{"_path":"notes/pasta.md","distance":0.315},{"_path":"notes/tomatoes.md","distance":0.881},{"_path":"notes/branches.md","distance":0.92}]
120
+ ```
121
+
122
+ ```bash
123
+ curl -s http://localhost:7117/query \
124
+ -H 'content-type: application/json' \
125
+ -d '{"q": "reviewing code on github"}'
126
+ ```
127
+
128
+ ```json
129
+ [{"_path":"notes/branches.md","distance":0.51},{"_path":"notes/pasta.md","distance":1.033},{"_path":"notes/tomatoes.md","distance":1.074}]
130
+ ```
131
+
132
+ Neither question shares a keyword with its top note — "cook" appears
133
+ nowhere in `pasta.md`, "github" nowhere in `branches.md`. The distance
134
+ ranking is doing the work.
135
+
136
+ Because `pre-query` is set, the request body is *not* the usual
137
+ `{"sql": …}` — the raw body goes to your script, which decides what SQL
138
+ runs ([hook interactions](../reference/http-api.md#hook-interactions)).
139
+
140
+ ## Recomputing vs. caching
141
+
142
+ Embeddings are recomputed on every startup, because the database is a
143
+ derived, ephemeral view of the files
144
+ ([how `dirsql` thinks](../explanation.md)). Once the model is cached this
145
+ is fast for small trees; for large ones, enable persistence so unchanged
146
+ files keep their stored embeddings across restarts —
147
+ [Keep the index across restarts](./persist.md). Editing a note re-embeds
148
+ just that file, automatically.
@@ -0,0 +1,60 @@
1
+ # Skip files you don't want indexed
2
+
3
+ Drafts, build output, and editor droppings don't belong in your tables.
4
+ [`ignore`](../reference/config.md#dirsql-keys) globs exclude files entirely —
5
+ from the initial scan and from watch events alike.
6
+
7
+ ## 1. Add `ignore` patterns
8
+
9
+ Suppose finished notes live in `notes/`, but drafts and scratch files hide
10
+ among them:
11
+
12
+ ```
13
+ notes/final.md
14
+ notes/drafts/wip.md
15
+ notes/scratch.md.tmp
16
+ ```
17
+
18
+ Exclude the noise in `.dirsql.toml`:
19
+
20
+ ```toml
21
+ [dirsql]
22
+ ignore = ["notes/drafts/**", "**/*.tmp"]
23
+
24
+ [[table]]
25
+ ddl = "CREATE TABLE notes (_path TEXT)"
26
+ glob = "notes/**/*"
27
+ ```
28
+
29
+ Patterns match against root-relative paths, the same way table globs do. An
30
+ ignored file never reaches any table — even one whose glob would match it.
31
+
32
+ ## 2. Confirm what made it in
33
+
34
+ Start the server (`npx dirsql` / `uvx dirsql`) and check:
35
+
36
+ ```bash
37
+ curl -s http://localhost:7117/query \
38
+ -H 'content-type: application/json' \
39
+ -d '{"sql":"SELECT _path FROM notes ORDER BY _path"}'
40
+ ```
41
+
42
+ ```json
43
+ [{"_path":"notes/final.md"}]
44
+ ```
45
+
46
+ ## Notes
47
+
48
+ - `ignore` lives in a config file, so it needs one:
49
+ [zero-config mode](../reference/cli.md#zero-config-mode) indexes
50
+ everything with no ignores.
51
+ - The top-level `.dirsql/` directory is always excluded, ignore list or
52
+ not — it is reserved for `dirsql`'s own metadata
53
+ ([config reference](../reference/config.md#dirsql-keys)).
54
+ - Narrow table globs are the other half of the story: a file matching no
55
+ table's glob contributes no rows either. Use `ignore` for things that
56
+ should never be looked at; use precise globs to shape what each table
57
+ sees ([Define tables for your files](./define-tables.md)).
58
+ - Embedding `dirsql` instead? The SDK constructor takes the same patterns
59
+ via its `ignore` parameter
60
+ ([SDK reference](../reference/sdk.md#constructor)).
@@ -0,0 +1,132 @@
1
+ # CLI
2
+
3
+ The `dirsql` binary has two modes:
4
+
5
+ | Invocation | Behavior |
6
+ |---|---|
7
+ | `dirsql` (no subcommand) | Start a long-lived HTTP server exposing a SQL view of a directory. See [HTTP API](./http-api.md). |
8
+ | `dirsql init` | Generate a starter `.dirsql.toml` by running `claude` over a directory. |
9
+
10
+ ## Installation
11
+
12
+ ::: code-group
13
+
14
+ ```bash [npm]
15
+ npx dirsql
16
+ ```
17
+
18
+ ```bash [PyPI]
19
+ uvx dirsql
20
+ ```
21
+
22
+ ```bash [Cargo]
23
+ # The `cli` feature is opt-in; this installs the binary only.
24
+ cargo install dirsql --features cli
25
+ dirsql
26
+ ```
27
+
28
+ :::
29
+
30
+ The npm launcher requires **Node ≥ 20.11**.
31
+
32
+ ## Server mode
33
+
34
+ ```bash
35
+ dirsql
36
+ # Running at localhost:7117
37
+ ```
38
+
39
+ On startup the server prints `Running at <host>:<port>` to stdout. It runs
40
+ until it receives `SIGINT` (Ctrl-C) or `SIGTERM`, then drains in-flight
41
+ requests, closes open `/events` streams, and exits.
42
+
43
+ ### Flags
44
+
45
+ | Flag | Default | Description |
46
+ |---|---|---|
47
+ | `--config <path>` | `./.dirsql.toml` | Path to the [config file](./config.md). The index is rooted at the directory containing this file (unless the config sets `[dirsql].root`). When the file does not exist, the server runs in [zero-config mode](#zero-config-mode). |
48
+ | `--host <addr>` | `localhost` | Bind address. |
49
+ | `--port <n>` | `7117` | TCP port to bind. |
50
+ | `--extension <path>` | none | Load a SQLite extension by literal path, overriding the config's `[[dirsql.extension]]` entries. Repeatable. Format: `<path>` or `<path>::<entrypoint>`. Internal plumbing for the pip/npm launchers, which resolve package-name extensions and pass the resolved paths here — not intended for direct use. When any `--extension` is present, the config file's own extension entries are not loaded. |
51
+ | `--version` | | Print the version and exit. |
52
+ | `--help` | | Print usage and exit. |
53
+
54
+ ### Defaults
55
+
56
+ - Per-query timeout: **30 seconds**. A query exceeding it returns
57
+ `408 Request Timeout`.
58
+ - Command hooks (`on-file`, `pre-query`, `post-query`) default to a
59
+ **30-second** timeout each, overridable with the config key
60
+ [`[dirsql].hook-timeout`](./config.md#dirsql-keys).
61
+
62
+ ### Zero-config mode
63
+
64
+ When the config file named by `--config` does not exist, the server indexes
65
+ the directory that would have contained it (the current directory for the
66
+ default `./.dirsql.toml`) with a single table named `files`:
67
+
68
+ - Glob: `**/*` — every file under the root, at any depth, no ignores.
69
+ - One row per file, with all seven
70
+ [virtual columns](./columns.md): `_path`, `_basename`, `_dir`, `_ext`,
71
+ `_size`, `_mtime`, `_ctime`.
72
+
73
+ ```bash
74
+ curl -s localhost:7117/query -H 'content-type: application/json' \
75
+ -d '{"sql":"SELECT _basename, _size FROM files ORDER BY _size DESC LIMIT 5"}'
76
+ ```
77
+
78
+ A config file, when present, fully overrules this default. A *missing*
79
+ config is not an error; only a config that exists but fails to load is (see
80
+ below).
81
+
82
+ ### Degraded mode
83
+
84
+ When the config file exists but cannot be resolved or loaded (unreadable
85
+ path, invalid TOML, schema errors), the server still starts and binds, but
86
+ every request to `/query` and `/events` returns `503 Service Unavailable`
87
+ with a JSON body describing the failure:
88
+
89
+ ```json
90
+ {"error": "failed to load config: ..."}
91
+ ```
92
+
93
+ ### Exit codes
94
+
95
+ | Code | Meaning |
96
+ |---|---|
97
+ | `0` | Clean shutdown after `SIGINT` / `SIGTERM`. |
98
+ | `1` | Failed to bind `host:port`, or an error during shutdown. |
99
+
100
+ ## `dirsql init`
101
+
102
+ Generates a `.dirsql.toml` by running the `claude` CLI over the target
103
+ directory. The generated config contains only filesystem-fact tables
104
+ (`[[table]]` entries whose columns come from [glob captures and virtual
105
+ columns](./columns.md)) — never content-derived columns.
106
+
107
+ ```bash
108
+ dirsql init
109
+ ```
110
+
111
+ ### Flags
112
+
113
+ | Flag | Default | Description |
114
+ |---|---|---|
115
+ | `--root <path>` | current directory | Directory to scan. |
116
+ | `--output <path>` | `<root>/.dirsql.toml` | Where to write the generated config. |
117
+ | `--force` | off | Overwrite the output file if it already exists. |
118
+
119
+ ### Requirements and failure modes
120
+
121
+ `init` requires `claude` on `PATH`, signed in; there is no separate API key.
122
+ All failures exit `1` with a message on stderr:
123
+
124
+ | Condition | Behavior |
125
+ |---|---|
126
+ | Output file exists and `--force` not passed | Fails before invoking `claude` (no LLM call is made). |
127
+ | `claude` not found on `PATH` | Fails with a pointer to the Claude Code install docs. |
128
+ | `claude` exits non-zero | Fails with `claude`'s stderr; no partial config is written. |
129
+ | `claude` produces non-UTF-8 output | Fails; nothing is written. |
130
+
131
+ On success, `claude`'s stdout is written verbatim to the output path and
132
+ `init` exits `0`.
@@ -0,0 +1,68 @@
1
+ # Virtual columns and glob captures
2
+
3
+ Every table — config-defined or programmatic — gets filesystem facts merged
4
+ onto its rows automatically: seven reserved **virtual columns** derived from
5
+ the file's path and stat metadata, plus one column per **`{name}` capture**
6
+ in the table's glob.
7
+
8
+ Facts are **opt-in by DDL**: only facts whose name appears as a column in
9
+ the table's `CREATE TABLE` are populated; the rest are silently dropped.
10
+ Declaring them requires nothing else.
11
+
12
+ ## Virtual columns
13
+
14
+ | Column | Type | Value |
15
+ |---|---|---|
16
+ | `_path` | TEXT | The file's path relative to the scan root (e.g. `posts/hello.md`). |
17
+ | `_basename` | TEXT | The filename, including extension (`hello.md`). |
18
+ | `_dir` | TEXT | The parent directory relative to the root (`posts`); the empty string for files directly under the root. |
19
+ | `_ext` | TEXT | The file extension without the leading dot (`md`). Original case is preserved — `Photo.JPG` yields `JPG`; use `LOWER(_ext)` for case-insensitive matching. `NULL` when the file has no extension. |
20
+ | `_size` | INTEGER | File size in bytes. |
21
+ | `_mtime` | INTEGER | Last-modified time, Unix seconds. |
22
+ | `_ctime` | INTEGER | Creation (birth) time, Unix seconds. `NULL` when the platform or filesystem cannot supply it. |
23
+
24
+ A fact that cannot be computed (an unreadable file's `_size`/`_mtime`/
25
+ `_ctime`, a missing extension's `_ext`) is absent from the row: `NULL` in
26
+ the default relaxed mode, a missing-column error for a
27
+ [`strict`](./config.md#table) table that declares it.
28
+
29
+ ```sql
30
+ SELECT _basename, _size
31
+ FROM posts
32
+ WHERE _mtime > strftime('%s', '2024-01-01')
33
+ ORDER BY _mtime DESC;
34
+ ```
35
+
36
+ ## Glob captures
37
+
38
+ A `{name}` segment in a table's glob captures part of each matched path as
39
+ a TEXT column named `name`:
40
+
41
+ ```toml
42
+ [[table]]
43
+ ddl = "CREATE TABLE comments (thread_id TEXT, _basename TEXT, _mtime INTEGER)"
44
+ glob = "_comments/{thread_id}/*.jsonl"
45
+ ```
46
+
47
+ A file at `_comments/abc123/2024-05-05.jsonl` produces a row with
48
+ `thread_id = "abc123"`.
49
+
50
+ - A capture name must be a valid identifier: a letter or underscore
51
+ followed by letters, digits, or underscores (`[a-zA-Z_][a-zA-Z0-9_]*`).
52
+ - A capture matches **within a single path segment** — one or more
53
+ characters, never a `/`. For matching purposes, `{name}` behaves like
54
+ `*`.
55
+ - A glob may contain multiple captures (`{year}/{month}/*.jpg`).
56
+ - Like virtual columns, a capture populates a column only when the DDL
57
+ declares a column of the same name.
58
+
59
+ ## Precedence
60
+
61
+ Values produced by a table's own row source — an `on-file` command's JSON
62
+ output or an SDK `extract` callback's return value — **win** over
63
+ auto-injected facts of the same name. An extract that explicitly emits
64
+ `_path` is honored.
65
+
66
+ Injection order per row: virtual columns first, then glob captures, then
67
+ the row source's own values, each layer overwriting the previous, all
68
+ filtered to the DDL's declared columns.
@@ -0,0 +1,166 @@
1
+ # Configuration file (`.dirsql.toml`)
2
+
3
+ `.dirsql.toml` is a TOML file with one optional `[dirsql]` section, zero or
4
+ more `[[dirsql.extension]]` entries, and zero or more `[[table]]` entries.
5
+ An empty file is valid. A missing `[dirsql]` section behaves as an
6
+ all-defaults one. Unknown keys are ignored.
7
+
8
+ The [CLI](./cli.md) loads `./.dirsql.toml` by default (`--config <path>`
9
+ overrides). The [SDKs](./sdk.md) load a config via the `config` constructor
10
+ parameter.
11
+
12
+ **Path resolution.** Relative paths in the config (`root`, `persist_path`,
13
+ `[[dirsql.extension]]` `path`) resolve against the config file's parent
14
+ directory. The scan root defaults to that same directory when `root` is not
15
+ set.
16
+
17
+ ## `[dirsql]` keys
18
+
19
+ | Key | Type | Default | Description |
20
+ |---|---|---|---|
21
+ | `root` | string | config file's parent directory | Directory to index. Relative values resolve against the config file's parent. An explicit `root` passed to an SDK constructor overrides this (a warning is emitted on stderr). |
22
+ | `ignore` | array of strings | `[]` | Glob patterns matched against root-relative paths. Matched files are skipped entirely — excluded from the initial scan and from watch events. |
23
+ | `persist` | boolean | `false` | Keep the SQLite index on disk between runs. When `false`, the index is ephemeral: rebuilt from your files on every startup and discarded on exit. |
24
+ | `persist_path` | string | `<root>/.dirsql/cache.db` | Location of the on-disk cache. Relative values resolve against the config file's parent. Ignored unless `persist = true`. |
25
+ | `pre-query` | string | none | Server-wide command hook: the raw `POST /query` request body is passed to this command as `{args}`, and the plain-text SQL it prints is executed instead of parsing the body as `{"sql": …}`. CLI server only; the SDKs ignore it. Must be non-empty. See [Command hooks](./hooks.md#pre-query). |
26
+ | `post-query` | string | none | Server-wide command hook: each successful `POST /query` result set is handed to this command (as a JSON array on stdin, and as `{args}` up to 96 KiB), and the JSON body it prints is returned instead of the bare row array. CLI server only; the SDKs ignore it. Must be non-empty. See [Command hooks](./hooks.md#post-query). |
27
+ | `hook-timeout` | integer (seconds) | `30` | One global per-run timeout for every command hook — `on-file`, `pre-query`, and `post-query` alike. Positive whole seconds; zero and negative values are a config error. See [Command hooks](./hooks.md#timeout). |
28
+
29
+ The top-level `.dirsql/` directory under the root is always excluded from
30
+ scanning, whether or not it appears in `ignore` — it is reserved for
31
+ `dirsql`'s own metadata (the persist cache lives there by default). Only the
32
+ top-level `.dirsql/` is reserved; a nested `sub/.dirsql/` is an ordinary
33
+ directory.
34
+
35
+ ```toml
36
+ [dirsql]
37
+ root = "../data"
38
+ ignore = ["node_modules/**", ".git/**"]
39
+ persist = true
40
+ persist_path = ".dirsql/cache.db" # the default; shown for illustration
41
+ hook-timeout = 300
42
+ ```
43
+
44
+ ## `[[dirsql.extension]]`
45
+
46
+ Each entry declares a SQLite extension to load at startup. Extensions are
47
+ loaded onto the connection before any `CREATE TABLE` runs; loading is
48
+ enabled only for the duration of each load and disabled again afterwards, so
49
+ the SQL `load_extension()` function is never exposed to queries.
50
+
51
+ | Key | Required | Description |
52
+ |---|---|---|
53
+ | `path` | yes (non-empty) | The extension's shared library. Either a **file path** (`.so` / `.dylib` / `.dll`; relative paths resolve against the config file's parent directory) or a bare **package name** (no path separator, no loadable-file suffix). The package name is runtime-specific — see [package-name resolution](#package-name-resolution) below. |
54
+ | `entrypoint` | no | Init-symbol override. When omitted, SQLite derives the entry point from the filename (`sqlite3_<filename>_init`); set this when that default does not match (e.g. `sqlite-vec`'s entry point is `sqlite3_vec_init`). |
55
+
56
+ ```toml
57
+ [[dirsql.extension]]
58
+ path = "./ext/vec0.dylib"
59
+ entrypoint = "sqlite3_vec_init"
60
+ ```
61
+
62
+ ### Package-name resolution
63
+
64
+ A `path` naming a package is resolved from the
65
+ installed package in the runtime environment: the Python launcher and SDK
66
+ use `importlib`, the Node launcher and SDK use `require.resolve` against
67
+ `node_modules`. Resolution is file-first (a same-named local file wins) and
68
+ errors when the package contains zero or multiple loadables for the current
69
+ platform. The **standalone Rust binary and the Rust SDK are
70
+ file-path-only** — they have no interpreter to resolve package names with.
71
+
72
+ The name must be what the runtime's resolver knows, which is not always the
73
+ name you installed:
74
+
75
+ - **Python** resolves the **importable module name** — underscores, not the
76
+ pip distribution name. `pip install sqlite-vec` is loaded as
77
+ `path = "sqlite_vec"`; `path = "sqlite-vec"` does not resolve.
78
+ - **Node** resolves the package whose install actually **contains the
79
+ loadable**. Meta-packages that split binaries per platform resolve via the
80
+ platform package — e.g. `npm install sqlite-vec` is loaded as
81
+ `path = "sqlite-vec-linux-x64"` (matching your platform), not
82
+ `path = "sqlite-vec"`, whose meta-package ships no loadable.
83
+
84
+ Extensions add **functions** callable in queries and in a table's DDL. An
85
+ extension-backed **virtual table** cannot be declared as a `[[table]]` —
86
+ `dirsql` tables are per-file row tables, so a `CREATE VIRTUAL TABLE` DDL is
87
+ rejected; call the extension's functions in queries instead.
88
+
89
+ ## `[[table]]`
90
+
91
+ Each entry maps a glob pattern to a SQL table. Every matched file produces
92
+ rows whose columns come from filesystem facts — [glob captures and virtual
93
+ columns](./columns.md) — plus, when `on-file` is set, the output of a
94
+ per-file command.
95
+
96
+ | Key | Required | Description |
97
+ |---|---|---|
98
+ | `ddl` | yes | A SQLite `CREATE TABLE` statement. The table name is parsed from it. Only columns declared here are populated; auto-injected facts not in the DDL are dropped. |
99
+ | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). First matching table wins when a file matches several globs. |
100
+ | `strict` | no (default `false`) | When `true`, rows whose keys do not exactly match the declared columns are rejected with an error: extra keys error, and every declared column must be supplied (by the command/extract output, a glob capture, or a virtual column). When `false`, extra keys are dropped and missing columns become `NULL`. |
101
+ | `on-file` | no | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. See [Command hooks](./hooks.md#on-file). |
102
+
103
+ Without `on-file`, a table produces exactly one row per matched file, built
104
+ entirely from filesystem facts. Content interpretation (frontmatter, JSON
105
+ fields, CSV parsing) is out of scope for plain config tables — use
106
+ `on-file`, or a programmatic [SDK table](./sdk.md#table) with an `extract`
107
+ callback.
108
+
109
+ ```toml
110
+ [[table]]
111
+ ddl = "CREATE TABLE comments (thread_id TEXT, _basename TEXT, _mtime INTEGER)"
112
+ glob = "_comments/{thread_id}/*.jsonl"
113
+
114
+ [[table]]
115
+ ddl = "CREATE TABLE papers (paper_id TEXT, title TEXT)"
116
+ glob = "**/meta.json"
117
+ on-file = "uv run python extract_papers.py {path}"
118
+ strict = true
119
+ ```
120
+
121
+ ### `on-file` row mapping
122
+
123
+ The command prints a JSON array of objects; each object becomes one row.
124
+ JSON values map to SQLite as: `null` → `NULL`; `true`/`false` → `1`/`0`; an
125
+ integral number → `INTEGER`, any other number → `REAL`; a string → `TEXT`; a
126
+ nested array or object → its JSON text as `TEXT`.
127
+
128
+ Filesystem facts are still merged onto every `on-file` row; a column emitted
129
+ by the command wins over a same-named fact. Output that is not a JSON array
130
+ of objects is a per-file failure: the file is skipped with a stderr warning
131
+ and the scan continues (see [failure semantics](./hooks.md#failure-semantics)).
132
+
133
+ ## Parse errors
134
+
135
+ Loading fails (the CLI enters [degraded mode](./cli.md#degraded-mode); the
136
+ SDKs raise/reject) when:
137
+
138
+ - The TOML is malformed.
139
+ - A `[[table]]` entry omits `ddl` or `glob`.
140
+ - A `[[dirsql.extension]]` entry omits `path`, or `path` is empty.
141
+ - `on-file`, `pre-query`, or `post-query` is present but empty/whitespace.
142
+ - `hook-timeout` is zero or negative.
143
+
144
+ ## Full example
145
+
146
+ ```toml
147
+ [dirsql]
148
+ ignore = ["node_modules/**", ".git/**", "dist/**"]
149
+ persist = true
150
+ pre-query = "uv run python to_sql.py {args}"
151
+ post-query = "jq -c '{results: .}'"
152
+ hook-timeout = 120
153
+
154
+ [[dirsql.extension]]
155
+ path = "sqlite_vec" # Python module name; on Node use the
156
+ # platform package, e.g. sqlite-vec-linux-x64
157
+ entrypoint = "sqlite3_vec_init"
158
+
159
+ [[table]]
160
+ ddl = "CREATE TABLE comments (thread_id TEXT, _basename TEXT, _mtime INTEGER)"
161
+ glob = "_comments/{thread_id}/*.jsonl"
162
+
163
+ [[table]]
164
+ ddl = "CREATE TABLE documents (_path TEXT, _basename TEXT, _size INTEGER)"
165
+ glob = "**/index.md"
166
+ ```