dirsql 0.4.25 → 0.4.27

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.
@@ -65,6 +65,50 @@ Add one `[[table]]` entry per table — each with its own `glob`, `ddl`, and
65
65
  table — each table is an independent view. See
66
66
  [`[[table]]`](../reference/config.md#table) for the remaining key, `strict`.
67
67
 
68
+ ## Add indexes and full-text search
69
+
70
+ `ddl` is a SQL batch, not a single statement — SQLite runs the whole thing. So
71
+ a table can arrive with its own index, and with an FTS5 index kept current by
72
+ two triggers:
73
+
74
+ ```toml
75
+ [[table]]
76
+ name = "posts"
77
+ glob = "posts/**/*.md"
78
+ on-file = "python3 extract.py {path}"
79
+ ddl = '''
80
+ CREATE TABLE posts (title TEXT, slug TEXT, body TEXT);
81
+ CREATE INDEX posts_slug ON posts(slug);
82
+
83
+ CREATE VIRTUAL TABLE posts_fts
84
+ USING fts5(body, content='posts', content_rowid='rowid');
85
+ CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
86
+ INSERT INTO posts_fts(rowid, body) VALUES (new.rowid, new.body);
87
+ END;
88
+ CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN
89
+ INSERT INTO posts_fts(posts_fts, rowid, body)
90
+ VALUES ('delete', old.rowid, old.body);
91
+ END;
92
+ '''
93
+ ```
94
+
95
+ ```bash
96
+ dirsql query "SELECT slug FROM posts JOIN posts_fts ON posts.rowid = posts_fts.rowid
97
+ WHERE posts_fts MATCH 'recursion'" -c ./.dirsql.toml
98
+ ```
99
+
100
+ ```json
101
+ [{"slug":"again"}]
102
+ ```
103
+
104
+ dirsql writes rows with plain `INSERT` and `DELETE`, so those triggers hold
105
+ through the initial load and every [watcher](./react-to-changes.md) event — you
106
+ maintain nothing. `name` still has to be the row table (`posts`); the virtual
107
+ table lives beside it under its own name. The batch runs once, when the table
108
+ is created, and editing any part of it rebuilds a
109
+ [persistent cache](./persist.md) from scratch. Full rules:
110
+ [Batch `ddl`](../reference/config.md#batch-ddl).
111
+
68
112
  ## Going further
69
113
 
70
114
  - The parser mechanics — placeholders, stdout protocol, per-file failure
@@ -76,9 +76,11 @@ interpreter to resolve package names with
76
76
 
77
77
  ## Notes
78
78
 
79
- - Extensions add **functions**. An extension-backed virtual table cannot be
80
- declared as a `[[table]]` — `dirsql` tables are per-file row tables
81
- ([reference](../reference/config.md#dirsql-extension)).
79
+ - Extensions add **functions**, and virtual tables a table's `ddl` batch can
80
+ create. A `[[table]]`'s own `name` may not be a virtual table — that one is
81
+ the per-file row table `dirsql` inserts into — so declare the virtual table
82
+ alongside it in the same batch
83
+ ([reference](../reference/config.md#batch-ddl)).
82
84
  - Loading happens before any table DDL runs, and the SQL
83
85
  `load_extension()` function is never exposed to queries
84
86
  ([reference](../reference/config.md#dirsql-extension)).
@@ -95,10 +95,12 @@ LIMIT 10
95
95
 
96
96
  ::: tip Top-k is `LIMIT k`
97
97
  If you know `sqlite-vec` you may reach for its `MATCH … AND k = 10` idiom.
98
- That syntax belongs to `sqlite-vec`'s `vec0` virtual table, which `dirsql`
99
- does not use — `dirsql` tables are per-file row tables. For plain
100
- expressions, `sqlite-vec`'s own documented pattern is exactly what this
101
- guide uses: `ORDER BY vec_distance_cosine(...) LIMIT k`.
98
+ That syntax belongs to `sqlite-vec`'s `vec0` virtual table, which the table
99
+ above does not use: a `[[table]]`'s own `name` is always a per-file row table.
100
+ For plain expressions, `sqlite-vec`'s own documented pattern is exactly what
101
+ this guide uses: `ORDER BY vec_distance_cosine(...) LIMIT k`. To get the `vec0`
102
+ idiom instead, declare the `vec0` table alongside the row table in the same
103
+ [`ddl` batch](../reference/config.md#batch-ddl) and fill it from a trigger.
102
104
  :::
103
105
 
104
106
  ## Repeat runs are cheap
@@ -9,9 +9,9 @@ The `dirsql` binary has these modes:
9
9
  | `dirsql query "<sql>"` | Explicit synonym for the default one-shot query. |
10
10
  | `dirsql server` | Start a long-lived HTTP server exposing a SQL view of a directory. See [HTTP API](./http-api.md). |
11
11
  | `dirsql init` | Generate a `.dirsql.toml`. |
12
+ | `dirsql` (bare) | Open a [REPL](#the-repl) over the current directory, reading statements until EOF. |
12
13
 
13
- Bare `dirsql` with no SQL is a usage error pointing at `dirsql server` — it
14
- does **not** start the server.
14
+ Bare `dirsql` does **not** start the server — that is `dirsql server`.
15
15
 
16
16
  ## Installation
17
17
 
@@ -47,6 +47,186 @@ dirsql "SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5"
47
47
  pipeline, same flags, same output. See that section for config discovery,
48
48
  `--persist`, `--on-file`, hooks, and exit codes.
49
49
 
50
+ ## The REPL
51
+
52
+ `dirsql` with no subcommand and no SQL reads statements until EOF:
53
+
54
+ ```bash
55
+ dirsql
56
+ # dirsql 0.2.7 — this directory is a database.
57
+ #
58
+ # SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5;
59
+ # SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%';
60
+ #
61
+ # `exit`, `quit`, or Ctrl-D to leave.
62
+ #
63
+ # dirsql> SELECT count(*) AS files FROM './';
64
+ # files
65
+ # -----
66
+ # 128
67
+ #
68
+ # 1 row
69
+ # dirsql>
70
+ ```
71
+
72
+ Statements go through the same pipeline as [`dirsql query`](#dirsql-query) and
73
+ `POST /query`, so a statement typed at the prompt and one passed on the command
74
+ line return identical rows. Every config flag the default mode takes — `-c`,
75
+ `--persist`, `--no-ignore`, `--on-file` — applies unchanged:
76
+
77
+ ```bash
78
+ dirsql -c .dirsql.toml --persist
79
+ ```
80
+
81
+ The index is built **once**, before the first prompt: statements share one scan
82
+ rather than re-walking the directory each time, and the live watcher keeps it
83
+ fresh between them. Files the scan had to skip are named on stderr once, up
84
+ front.
85
+
86
+ ### Output format
87
+
88
+ Rows go where they are useful: a **table** when stdout is a terminal, the
89
+ **JSON array** when it is piped or redirected. `SELECT * FROM './'` in a
90
+ 5000-file tree should not put a 5000-element JSON array in front of a person,
91
+ and `dirsql "…" | jq` should not have to parse a table.
92
+
93
+ `--format` overrides that, in both directions, and is valid in the REPL and in
94
+ [`dirsql query`](#dirsql-query) alike:
95
+
96
+ | Value | Renders |
97
+ |---|---|
98
+ | `auto` (default) | Table if stdout is a terminal, JSON otherwise. |
99
+ | `table` | Always a table — including into a pipe or a file. |
100
+ | `json` | Always the JSON array — including at a terminal. |
101
+
102
+ ```bash
103
+ dirsql "SELECT basename, size FROM './' ORDER BY basename" --format table
104
+ # basename size
105
+ # -------- ----
106
+ # a.md 6
107
+ # bb.md 10
108
+ #
109
+ # 2 rows
110
+ ```
111
+
112
+ There is no `.mode`: dirsql has no dot-commands to extend (see
113
+ [Leaving](#leaving)), and a flag serves the one-shot query too. `dirsql server`
114
+ does not take `--format` — its transport is JSON over HTTP.
115
+
116
+ **`auto` keys on stdout, not stdin.** `dirsql > rows.json` typed at a terminal
117
+ is still headed for a file, and the file gets JSON.
118
+
119
+ Table rendering is deliberately plain: aligned columns, a rule under the
120
+ header, a row count, and `NULL` spelled out so it cannot be confused with an
121
+ empty string. Two things happen to a value on its way into a cell, both
122
+ because a `content` column holds a whole file: **newlines, tabs and other
123
+ control characters are escaped** (`\n`, `\t`, `\u{…}`) so one row cannot span
124
+ several lines, and **anything longer than 60 characters is truncated with
125
+ `…`** so one column cannot set the width of every row. `--format json`
126
+ returns the values unaltered.
127
+
128
+ Laying the table out to the terminal's width, and paging a long result, are
129
+ both out of scope; pipe to `less` for the latter.
130
+
131
+ ### Where a statement ends
132
+
133
+ At its semicolon — the same rule `sqlite3` uses, and **SQLite's own tokenizer**
134
+ decides where that semicolon is. So a statement can be laid out over as many
135
+ lines as it needs, and a `;` inside a string literal, a comment, or a
136
+ `BEGIN … END` body is not mistaken for the end of one:
137
+
138
+ ```
139
+ dirsql> SELECT basename, size
140
+ ...> FROM './'
141
+ ...> ORDER BY size DESC
142
+ ...> LIMIT 5;
143
+ ```
144
+
145
+ The `...>` prompt says the statement is still open. `exit`, `quit`, and a blank
146
+ line are not SQL, so they are taken as typed rather than waiting for a
147
+ terminator.
148
+
149
+ ### Editing and history
150
+
151
+ The prompt is a full line editor ([reedline](https://github.com/nushell/reedline)),
152
+ with the emacs bindings a shell prompt has:
153
+
154
+ | Key | Does |
155
+ |---|---|
156
+ | ↑ / ↓ | Walk back and forth through history. |
157
+ | Ctrl-R | Reverse-search history; type to narrow, Enter to accept. |
158
+ | Ctrl-A / Ctrl-E | Jump to the start / end of the line. |
159
+ | Alt-B / Alt-F | Move back / forward a word. |
160
+ | Ctrl-W, Ctrl-K, Ctrl-Y | Kill the previous word, kill to end of line, yank it back. |
161
+ | Ctrl-C | Abandon the line and return to a fresh prompt. **Does not exit.** |
162
+ | Ctrl-D | Leave. |
163
+
164
+ History is kept in one file for every directory — a query worked out in one
165
+ project is worth recalling in the next, the same way `sqlite3` keeps a single
166
+ `~/.sqlite_history`. It holds the last 1000 statements, at
167
+ `$XDG_DATA_HOME/dirsql/history`, falling back to
168
+ `~/.local/share/dirsql/history` (`%APPDATA%\dirsql\history` on Windows). If
169
+ none of those resolve, history is kept in memory for the session only.
170
+
171
+ ### Terminal vs. pipe
172
+
173
+ The prompt, banner, editor, and history exist only when **stdin is a terminal**.
174
+ From a pipe or a redirect there is none of that, and the terminator rule does
175
+ not apply either: a redirected script is not being typed, so there is no
176
+ continuation prompt to hang it off. **One statement per line, no `;` needed:**
177
+
178
+ ```bash
179
+ printf "SELECT 1 AS n\nSELECT 2 AS n\n" | dirsql
180
+ # [{"n":1}]
181
+ # [{"n":2}]
182
+
183
+ dirsql < queries.sql > rows.jsonl
184
+ ```
185
+
186
+ Blank lines do nothing in either mode.
187
+
188
+ ### Leaving
189
+
190
+ `exit`, `quit` (either case), or Ctrl-D. There are no dot-commands: the `.`
191
+ prefix exists in `sqlite3` to namespace meta-commands against SQL, and with no
192
+ meta-commands there is nothing to namespace. Schema questions are ordinary SQL:
193
+
194
+ ```sql
195
+ SELECT name FROM sqlite_master WHERE type = 'table';
196
+ ```
197
+
198
+ ### Errors
199
+
200
+ A statement that fails prints its diagnostic — the same string the HTTP
201
+ `{"error": …}` body carries — to stderr, and **the session continues**. This is
202
+ the one behavioral difference from `dirsql query`, which exits `1` on the first
203
+ failure:
204
+
205
+ ```
206
+ dirsql> SELECT nope FROM missing;
207
+ dirsql: SQLite error: no such table: missing
208
+ dirsql> SELECT 1 AS n;
209
+ n
210
+ -
211
+ 1
212
+
213
+ 1 row
214
+ ```
215
+
216
+ A config that cannot be loaded is different in kind: it fails identically for
217
+ every statement, so it is reported once and exits `1` before the first prompt.
218
+
219
+ ### Exit codes
220
+
221
+ | Code | Meaning |
222
+ |---|---|
223
+ | `0` | Clean EOF (Ctrl-D, `exit`, `quit`, or the end of a piped script) — **including when statements failed**. Matches interactive `sqlite3`; use [`dirsql query`](#dirsql-query) when a script needs a statement's exit status. |
224
+ | `1` | The index could not be built (a bad `-c`, an unresolvable `--on-file`), or stdin could not be read. Nothing was executed. |
225
+
226
+ `23` (partial scan) is not produced here: skipped files are reported before the
227
+ first prompt, and a session's exit code describes the session rather than one
228
+ scan.
229
+
50
230
  ## `dirsql server`
51
231
 
52
232
  ```bash
@@ -204,6 +384,12 @@ Errors print the same diagnostic the HTTP `{"error": …}` body carries —
204
384
  config failures, SQL errors, rejected reads, hook failures, timeouts — to
205
385
  stderr, with exit code `1`.
206
386
 
387
+ #### `--format {auto,table,json}`
388
+
389
+ How to render the result rows — the same flag [the REPL](#output-format)
390
+ takes, with the same `auto` default. A one-shot query is usually piped, so
391
+ `auto` usually means JSON; `--format table` is there for the times it is not.
392
+
207
393
  ### Exit codes
208
394
 
209
395
  | Code | Meaning |
@@ -87,10 +87,11 @@ name you installed:
87
87
  `path = "sqlite-vec-linux-x64"` (matching your platform), not
88
88
  `path = "sqlite-vec"`, whose meta-package ships no loadable.
89
89
 
90
- Extensions add **functions** callable in queries and in a table's DDL. An
91
- extension-backed **virtual table** cannot be declared as a `[[table]]` —
92
- `dirsql` tables are per-file row tables, so a `CREATE VIRTUAL TABLE` DDL is
93
- rejected; call the extension's functions in queries instead.
90
+ Extensions add **functions** callable in queries and in a table's DDL, and
91
+ **virtual tables** a table's [`ddl` batch](#batch-ddl) can create. What a
92
+ `[[table]]`'s own `name` may not be is a virtual table: that one is the
93
+ per-file row table `dirsql` inserts into. Create the virtual table alongside
94
+ it, under its own name.
94
95
 
95
96
  ## `[[dirsql.function]]`
96
97
 
@@ -167,7 +168,7 @@ what its required `on-file` command emits — dirsql injects nothing (see
167
168
  | Key | Required | Description |
168
169
  |---|---|---|
169
170
  | `name` | yes | The table's SQL name — the name you query it by. Declared, never derived from `ddl`: dirsql does not read the DDL text. The `ddl` must create a table by this name; if it doesn't, loading fails. |
170
- | `ddl` | yes | A SQLite `CREATE TABLE` statement, run verbatim. Only the columns declared here are kept; keys the `on-file` command emits that are not declared are dropped. |
171
+ | `ddl` | yes | A SQL batch, run verbatim — any number of statements. It must create a table called `name`; that table holds the file rows, and only the columns it declares are kept (keys the `on-file` command emits that are not declared are dropped). The rest of the batch is yours: indexes, virtual tables, triggers. See [Batch `ddl`](#batch-ddl). |
171
172
  | `glob` | yes | Glob pattern matched against root-relative paths. Every table whose glob matches a file receives that file's rows — a file can populate multiple tables. A `{name}` segment is rewritten to `*` (it matches one path segment but captures nothing). |
172
173
  | `on-file` | **yes** | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. A `[[table]]` with no `on-file` is a load error (see [parse errors](#parse-errors)). See [Command hooks](./hooks.md#on-file). |
173
174
  | `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 `on-file` output. When `false`, extra keys are dropped and missing columns become `NULL`. |
@@ -194,6 +195,72 @@ on-file = "uv run python extract_papers.py {path}"
194
195
  strict = true
195
196
  ```
196
197
 
198
+ ### Batch `ddl`
199
+
200
+ `ddl` is handed to SQLite whole, so a table declaration is not limited to one
201
+ statement:
202
+
203
+ ```toml
204
+ [[table]]
205
+ name = "messages"
206
+ glob = "sessions/*/messages/*.json"
207
+ on-file = "jq -c '.' {path}"
208
+ ddl = '''
209
+ CREATE TABLE messages (session TEXT, idx INT, role TEXT, text TEXT);
210
+ CREATE INDEX messages_session ON messages(session);
211
+
212
+ CREATE VIRTUAL TABLE messages_fts
213
+ USING fts5(text, content='messages', content_rowid='rowid');
214
+ CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
215
+ INSERT INTO messages_fts(rowid, text) VALUES (new.rowid, new.text);
216
+ END;
217
+ CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
218
+ INSERT INTO messages_fts(messages_fts, rowid, text)
219
+ VALUES ('delete', old.rowid, old.text);
220
+ END;
221
+ '''
222
+ ```
223
+
224
+ ```bash
225
+ dirsql query "SELECT text FROM messages_fts WHERE messages_fts MATCH 'deploy'" \
226
+ -c ./.dirsql.toml
227
+ ```
228
+
229
+ Those two triggers are all a keyword index needs. dirsql writes file rows with
230
+ plain `INSERT` and `DELETE` — an update is a delete and an insert in one
231
+ transaction, and there is no `UPDATE` path on user rows — so triggers you
232
+ declare here stay current through the initial load and every
233
+ [watcher](../howto/react-to-changes.md) event. The same shape with a `vec0`
234
+ virtual table and an [`embed()`](#dirsql-function) call in the trigger gives
235
+ you stored vectors.
236
+
237
+ **dirsql never reads the batch.** After it runs, SQLite's own catalog
238
+ (`pragma_table_list`) settles what it produced:
239
+
240
+ - No table called `name` → a load error that lists what the batch *did*
241
+ create, so a typo is obvious.
242
+ - `name` is a **virtual** table → a load error. The declared table is the one
243
+ dirsql inserts file rows into, so it has to be a real row table. Create the
244
+ virtual table alongside it, under its own name.
245
+ - `name` is **`WITHOUT ROWID`** → a warning. Internal row bookkeeping is keyed
246
+ on rowid, so these will be rejected in a future release.
247
+
248
+ The whole batch runs in one transaction: if any statement fails, none of them
249
+ took effect, and the error is SQLite's own, prefixed with the config entry —
250
+ `table 'messages': SQLite error: near "(": syntax error`. Context, never
251
+ interpretation.
252
+
253
+ Two consequences of `ddl` running **once, when the table is created**:
254
+
255
+ - **Rows the batch inserts itself are not file-tracked.** No file owns them, so
256
+ they survive file deletions — and they vanish on any rebuild.
257
+ - **Editing `ddl` at all rebuilds a
258
+ [persistent cache](../howto/persist.md).** The config hash covers the entire
259
+ batch, so a new index, a different FTS5
260
+ tokenizer or a changed embedding model id forces a full sweep and re-ingest.
261
+ That is the only invalidation lane: dirsql tracks no ownership of what the
262
+ batch made.
263
+
197
264
  ### `on-file` row mapping
198
265
 
199
266
  The command prints a JSON array of objects; each object becomes one row.
@@ -247,13 +314,24 @@ SDKs raise/reject) when:
247
314
  - A `[[table]]` entry omits `name`, `ddl`, or `glob` (or `name` is
248
315
  empty/whitespace).
249
316
  - A `[[table]]` entry's `ddl` runs but creates no table by its `name`. The
250
- error carries the entry's name and points at the fix:
317
+ error carries the entry's name, lists what the batch did create, and points
318
+ at the fix:
251
319
 
252
- > `table 'messages': its `ddl` ran but created no table called 'messages'. Set `name` to the table the `ddl` creates.`
320
+ > `table 'messages': its `ddl` ran but created no table called 'messages' (it created: mesages, mesages_fts). Set `name` to the table the `ddl` creates.`
253
321
 
254
322
  dirsql asks SQLite's catalog rather than interpreting the DDL, so quoted
255
323
  (`CREATE TABLE "messages"`), schema-qualified (`main.messages`) and
256
324
  `IF NOT EXISTS` forms all match a plain `name = "messages"`.
325
+ - A `[[table]]` entry's `name` names a **virtual** table. The declared table
326
+ holds the file rows, so it must be a real row table:
327
+
328
+ > `table 'messages': its `ddl` created a virtual table called 'messages'. The declared table holds the file rows, so it must be a real row table; create the virtual table alongside it, under its own name.`
329
+
330
+ - A `[[table]]` entry's `ddl` is rejected by SQLite. Nothing the batch did
331
+ takes effect, and SQLite's own message is passed through under the entry's
332
+ name:
333
+
334
+ > `table 'messages': SQLite error: near "(": syntax error`
257
335
  - A `[[table]]` entry omits `on-file` (or it is empty/whitespace). The error
258
336
  names the offending glob and points at the fix:
259
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirsql",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "description": "Ephemeral SQL index over a local directory",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/thekevinscott/dirsql",
@@ -213,10 +213,10 @@
213
213
  ]
214
214
  },
215
215
  "optionalDependencies": {
216
- "@dirsql/lib-linux-x64-gnu": "0.4.25",
217
- "@dirsql/lib-linux-arm64-gnu": "0.4.25",
218
- "@dirsql/lib-darwin-x64": "0.4.25",
219
- "@dirsql/lib-darwin-arm64": "0.4.25",
220
- "@dirsql/lib-win32-x64-msvc": "0.4.25"
216
+ "@dirsql/lib-linux-x64-gnu": "0.4.27",
217
+ "@dirsql/lib-linux-arm64-gnu": "0.4.27",
218
+ "@dirsql/lib-darwin-x64": "0.4.27",
219
+ "@dirsql/lib-darwin-arm64": "0.4.27",
220
+ "@dirsql/lib-win32-x64-msvc": "0.4.27"
221
221
  }
222
222
  }