dirsql 0.4.12 → 0.4.14

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.
package/dist/cli/main.js CHANGED
@@ -1,23 +1,75 @@
1
- // Launcher entry: resolve the bundled `dirsql` binary and forward argv,
2
- // exit code, and signals to it. The launcher is a transparent forwarder —
3
- // every argv (including any subcommand) goes straight to the Rust binary,
4
- // which owns subcommand dispatch and clap-rejects unknown ones.
5
- import { spawnSync } from "node:child_process";
1
+ // Launcher entry: run the CLI in-process through the napi addon.
2
+ //
3
+ // The launcher is a transparent forwarder — every argv (including any
4
+ // subcommand) goes straight to the core's `runCli`, which owns subcommand
5
+ // dispatch and clap-rejects unknown ones. Nothing is spawned: the same
6
+ // `@dirsql/lib-*` addon the SDK loads carries the CLI, so a package ships one
7
+ // copy of the core instead of two (#739).
8
+ import { mainInProcess } from "bin-shim";
9
+ import { loadNativeCore } from "../load-native-core.js";
6
10
  import { die } from "./die.js";
7
- import { resolveBinary } from "./resolve-binary.js";
8
11
  import { withResolvedExtensions } from "./resolve-config-extensions.js";
12
+ /**
13
+ * Resolve `runCli` off the same addon the SDK loads.
14
+ *
15
+ * bin-shim can resolve an addon itself, but its default naming is
16
+ * `@{scope}/lib-{platform}-{arch}`, which cannot express the ABI suffix
17
+ * dirsql's packages carry (`@dirsql/lib-linux-x64-gnu`). Reusing
18
+ * `loadNativeCore` keeps one resolution rule for the SDK and the CLI —
19
+ * including its dev fallback to a locally built `dirsql.node` — so a
20
+ * monorepo checkout and a published install take the same path.
21
+ */
22
+ function resolveRunCli() {
23
+ const core = loadNativeCore();
24
+ if (typeof core.runCli !== "function") {
25
+ throw new Error("dirsql: the native addon has no callable `runCli` export; " +
26
+ "it was built without the `cli` feature.");
27
+ }
28
+ return core.runCli;
29
+ }
30
+ /**
31
+ * Keep Ctrl-C fatal while the CLI runs in-process.
32
+ *
33
+ * The core installs tokio signal handlers for `dirsql server`, and
34
+ * signal-hook (which tokio uses) *chains*: it runs its own actions and then
35
+ * the handler installed before it. CPython installs `default_int_handler` at
36
+ * startup, so the pip launcher gets a terminating disposition for free —
37
+ * bare Node leaves `SIG_DFL`, which signal-hook does not emulate, so the
38
+ * signal is swallowed and the process becomes SIGKILL-only (measured: a
39
+ * probe ignored SIGTERM for 143 seconds).
40
+ *
41
+ * Registering a JS listener first gives signal-hook a real prior handler to
42
+ * chain to. This cannot be fixed after the fact: `removeAllListeners` only
43
+ * drops JS listeners and cannot touch a disposition installed by native
44
+ * code, and `unregister_signal` leaves the OS handler in place. One listener
45
+ * is the whole remedy — no `unsafe`, no `sigaction`.
46
+ */
47
+ function keepSignalsFatal() {
48
+ // While the core is running, its own handler drives shutdown and this
49
+ // listener is the tail of the chain; it matters for signals arriving
50
+ // outside that window, where exiting is exactly right.
51
+ process.on("SIGINT", () => process.exit(130));
52
+ process.on("SIGTERM", () => process.exit(143));
53
+ }
9
54
  export async function main(argv = process.argv.slice(2)) {
10
- const binary = resolveBinary();
11
- // Resolve any package-name extensions in a TOML config here (the binary
55
+ keepSignalsFatal();
56
+ // Resolve any package-name extensions in a TOML config here (the core
12
57
  // can't) and pass them as `--extension` flags; a no-op otherwise.
13
- const result = spawnSync(binary, await withResolvedExtensions(argv), {
14
- stdio: "inherit",
15
- });
16
- if (result.error) {
17
- die(result.error.message, 1);
58
+ const resolved = await withResolvedExtensions(argv);
59
+ let code;
60
+ try {
61
+ code = await mainInProcess({
62
+ argv: resolved,
63
+ binaryName: "dirsql",
64
+ // Resolution is ours (see `resolveRunCli`), so bin-shim never consults
65
+ // its own naming; `from` only satisfies the shared options type.
66
+ from: import.meta.url,
67
+ runCli: resolveRunCli(),
68
+ });
18
69
  }
19
- if (result.signal) {
20
- process.kill(process.pid, result.signal);
70
+ catch (e) {
71
+ die(e instanceof Error ? e.message : String(e), 1);
72
+ return;
21
73
  }
22
- process.exit(result.status ?? 1);
74
+ process.exit(code);
23
75
  }
@@ -5,8 +5,6 @@ export interface Platform {
5
5
  nodePlatform: NodeJS.Platform;
6
6
  /** Node `process.arch` value for this target. */
7
7
  nodeArch: NodeJS.Architecture;
8
- /** CLI sub-package name (`@dirsql/cli-<slug>`). */
9
- name: string;
10
8
  /** napi library sub-package name (`@dirsql/lib-<slug>`). */
11
9
  libName: string;
12
10
  /** Wheel-style `os` constraint for the sub-package's package.json. */
@@ -15,14 +13,8 @@ export interface Platform {
15
13
  cpu: string[];
16
14
  /** libc constraint (Linux only). */
17
15
  libc?: string[];
18
- /** Archive extension cargo-dist emits for this target. */
19
- ext: "tar.xz" | "zip";
20
- /** Whether the binary has a `.exe` suffix on this platform. */
21
- exe?: boolean;
22
16
  }
23
17
  export declare const PLATFORMS: readonly Platform[];
24
- /** Node `${platform}-${arch}` → `@dirsql/cli-*` sub-package name. */
25
- export declare function nodeTriples(): Record<string, string>;
26
18
  /** Node `${platform}-${arch}` → `@dirsql/lib-*` napi sub-package name. */
27
19
  export declare function libTriples(): Record<string, string>;
28
20
  /**
package/dist/platforms.js CHANGED
@@ -1,82 +1,61 @@
1
1
  // Single source of truth for the target platforms `dirsql` publishes.
2
2
  //
3
- // Every target triple generates two npm sub-packages:
3
+ // Every target triple generates ONE npm sub-package: `@dirsql/lib-<slug>`,
4
+ // holding the napi-rs `.node` addon. It backs both layers — the TypeScript
5
+ // SDK loads it via `loadNativeCore()`, and since #739 the `dirsql` CLI runs
6
+ // in-process through its `runCli` export rather than spawning a binary. The
7
+ // second family, `@dirsql/cli-<slug>`, shipped a redundant copy of the same
8
+ // core and is gone.
4
9
  //
5
- // 1. `@dirsql/cli-<slug>` — holds the standalone `dirsql` CLI binary
6
- // (from cargo-dist). Consumed at runtime by `src/cli/resolveBinary.ts`
7
- // when a user runs the `dirsql` CLI.
8
- // 2. `@dirsql/lib-<slug>` — holds the napi-rs `.node` addon used by the
9
- // TypeScript SDK. Consumed at runtime by `loadNativeCore()` in
10
- // `src/index.ts` when a user `import`s from `dirsql`.
10
+ // The sub-packages are `optionalDependencies` of the main `dirsql` package,
11
+ // so npm/pnpm install only the one matching the host's OS/arch.
11
12
  //
12
- // Both sub-package sets use `optionalDependencies` on the main `dirsql`
13
- // package so npm/pnpm install only the one matching the host's OS/arch.
14
- //
15
- // `nodeTriples()` / `libTriples()` return `${process.platform}-${process.arch}`
16
- // → sub-package-name maps for the respective layer.
13
+ // `libTriples()` returns a `${process.platform}-${process.arch}` →
14
+ // sub-package-name map.
17
15
  export const PLATFORMS = [
18
16
  {
19
17
  triple: "x86_64-unknown-linux-gnu",
20
18
  nodePlatform: "linux",
21
19
  nodeArch: "x64",
22
- name: "@dirsql/cli-linux-x64-gnu",
23
20
  libName: "@dirsql/lib-linux-x64-gnu",
24
21
  os: ["linux"],
25
22
  cpu: ["x64"],
26
23
  libc: ["glibc"],
27
- ext: "tar.xz",
28
24
  },
29
25
  {
30
26
  triple: "aarch64-unknown-linux-gnu",
31
27
  nodePlatform: "linux",
32
28
  nodeArch: "arm64",
33
- name: "@dirsql/cli-linux-arm64-gnu",
34
29
  libName: "@dirsql/lib-linux-arm64-gnu",
35
30
  os: ["linux"],
36
31
  cpu: ["arm64"],
37
32
  libc: ["glibc"],
38
- ext: "tar.xz",
39
33
  },
40
34
  {
41
35
  triple: "x86_64-apple-darwin",
42
36
  nodePlatform: "darwin",
43
37
  nodeArch: "x64",
44
- name: "@dirsql/cli-darwin-x64",
45
38
  libName: "@dirsql/lib-darwin-x64",
46
39
  os: ["darwin"],
47
40
  cpu: ["x64"],
48
- ext: "tar.xz",
49
41
  },
50
42
  {
51
43
  triple: "aarch64-apple-darwin",
52
44
  nodePlatform: "darwin",
53
45
  nodeArch: "arm64",
54
- name: "@dirsql/cli-darwin-arm64",
55
46
  libName: "@dirsql/lib-darwin-arm64",
56
47
  os: ["darwin"],
57
48
  cpu: ["arm64"],
58
- ext: "tar.xz",
59
49
  },
60
50
  {
61
51
  triple: "x86_64-pc-windows-msvc",
62
52
  nodePlatform: "win32",
63
53
  nodeArch: "x64",
64
- name: "@dirsql/cli-win32-x64-msvc",
65
54
  libName: "@dirsql/lib-win32-x64-msvc",
66
55
  os: ["win32"],
67
56
  cpu: ["x64"],
68
- ext: "zip",
69
- exe: true,
70
57
  },
71
58
  ];
72
- /** Node `${platform}-${arch}` → `@dirsql/cli-*` sub-package name. */
73
- export function nodeTriples() {
74
- const out = {};
75
- for (const p of PLATFORMS) {
76
- out[`${p.nodePlatform}-${p.nodeArch}`] = p.name;
77
- }
78
- return out;
79
- }
80
59
  /** Node `${platform}-${arch}` → `@dirsql/lib-*` napi sub-package name. */
81
60
  export function libTriples() {
82
61
  const out = {};
@@ -79,8 +79,6 @@ SQLite value mapping is under
79
79
 
80
80
  - The command re-runs on every startup and on every change to a matched
81
81
  file. If it is expensive, [keep the index across restarts](./persist.md).
82
- - The flagship use of `on-file` — computing embeddings — is
83
- [Search documents by meaning](./search-by-meaning.md).
84
82
  - Embedding `dirsql` in a program instead? The SDK's `on_file` callback
85
83
  fills the same role in-process — see
86
84
  [Embed `dirsql` in your application](./embed.md).
@@ -85,5 +85,3 @@ interpreter to resolve package names with
85
85
  - Embedding `dirsql` in a program? The SDK constructor takes the same
86
86
  specs via its `extensions` parameter
87
87
  ([SDK reference](../reference/sdk.md#constructor)).
88
- - The payoff use case — `sqlite-vec` powering semantic search — is
89
- [Search documents by meaning](./search-by-meaning.md).
@@ -24,8 +24,7 @@ dirsql-embeddings/
24
24
  └── dirsql_embeddings/
25
25
  ├── __init__.py
26
26
  ├── dirsql.toml # the config fragment
27
- ├── embed.py # on-file hook
28
- └── search.py # pre-query hook
27
+ └── embed.py # on-file hook
29
28
  ```
30
29
 
31
30
  The entry point maps a **source label** (the name) to the **module that
@@ -55,8 +54,7 @@ structurally **identical to a user config**. It may declare
55
54
  [`[[table]]`](../reference/config.md#table) (with
56
55
  [`on-file`](../reference/hooks.md#on-file)),
57
56
  [`[[dirsql.extension]]`](../reference/config.md#dirsql-extension),
58
- [`ignore`](../reference/config.md#dirsql-keys),
59
- [`pre-query`/`post-query`](../reference/hooks.md#pre-query), and
57
+ [`ignore`](../reference/config.md#dirsql-keys), and
60
58
  [`hook-timeout`](../reference/hooks.md#timeout).
61
59
 
62
60
  There are **no plugin-specific keys and no plugin-specific restrictions**. The
@@ -96,17 +94,14 @@ matter most for a published plugin:
96
94
  ## Worked example: an embeddings plugin
97
95
 
98
96
  Here is the whole plugin — vector search over a directory of notes, buildable
99
- in about fifty lines. It composes the same three pieces as
100
- [Search documents by meaning](./search-by-meaning.md): the
97
+ in about forty lines. It composes two pieces: the
101
98
  [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension for the
102
- distance math, an `on-file` command to embed each file, and a `pre-query`
103
- command to embed the question.
99
+ distance math, and an `on-file` command to embed each file.
104
100
 
105
101
  The fragment, `src/dirsql_embeddings/dirsql.toml`:
106
102
 
107
103
  ```toml
108
104
  [dirsql]
109
- pre-query = "uv run --with model2vec python search.py {args}"
110
105
  hook-timeout = 300 # headroom for the first-run model download
111
106
 
112
107
  [[dirsql.extension]]
@@ -140,46 +135,23 @@ row = {"path": os.path.relpath(path, root), "text": text,
140
135
  print(json.dumps([row]))
141
136
  ```
142
137
 
143
- `search.py` turns a `{"q": "..."}` request body into nearest-neighbor SQL:
144
-
145
- ```python
146
- """Turn a {"q": "..."} request body into a nearest-neighbor SQL query."""
147
- import json
148
- import sys
149
-
150
- from model2vec import StaticModel
151
-
152
- body = json.loads(sys.argv[1])
153
- model = StaticModel.from_pretrained("minishlab/potion-base-8M")
154
- vector = model.encode([body["q"]])[0]
155
- needle = json.dumps([round(float(x), 6) for x in vector])
156
- print(
157
- "SELECT path, ROUND(vec_distance_cosine(embedding, '%s'), 3) AS distance "
158
- "FROM notes ORDER BY distance LIMIT 3" % needle
159
- )
160
- ```
161
-
162
- ::: warning The hook owns SQL safety
163
- Whatever SQL `pre-query` prints is executed as-is. Here the interpolated
164
- value is a numeric vector the script itself produced; never splice raw request
165
- text into SQL. See [`pre-query`](../reference/hooks.md#pre-query).
166
- :::
167
-
168
- The relative `embed.py` / `search.py` above resolve against the fragment
169
- directory, which is convenient during development. For a published plugin,
170
- promote them to console scripts (`[project.scripts]` → `embed-file`,
171
- `embed-search`) so the commands carry their own interpreter and dependencies
172
- and no longer depend on `uv run --with`.
138
+ The relative `embed.py` above resolves against the fragment directory, which
139
+ is convenient during development. For a published plugin, promote it to a
140
+ console script (`[project.scripts]` → `embed-file`) so the command carries
141
+ its own interpreter and dependencies and no longer depends on
142
+ `uv run --with`.
173
143
 
174
144
  Once the package is installed alongside the launcher, its `notes` table is
175
145
  queryable with no config edits:
176
146
 
177
147
  ```bash
178
- uvx --with dirsql-embeddings dirsql query '{"q": "how do I cook pasta?"}'
148
+ uvx --with dirsql-embeddings dirsql query \
149
+ "SELECT path FROM notes ORDER BY vec_distance_cosine(embedding, '[0.1, ...]') LIMIT 3"
179
150
  ```
180
151
 
181
152
  The launcher discovers the installed plugin, composes its fragment, and the
182
- `pre-query` hook turns the question into vector-distance SQL.
153
+ `notes` table (with `vec_distance_cosine()` from the loaded extension) is
154
+ available to the query.
183
155
 
184
156
  ## SDK-style convention: expose the config
185
157
 
@@ -230,8 +202,8 @@ Discovery is deliberately narrow. Know exactly who does what:
230
202
  [convention above](#sdk-style-convention-expose-the-config)).
231
203
  - **Name collisions are a hard error.** Because fragments compose like any
232
204
  [multiple configs](../reference/config.md#composing-multiple-configs), two
233
- plugins (or a plugin and your config) defining a table of the same name — or
234
- the same query hook — fail loudly, naming the conflict. It is never a silent
205
+ plugins (or a plugin and your config) defining a table of the same name
206
+ fail loudly, naming the conflict. It is never a silent
235
207
  last-writer-wins.
236
208
 
237
209
  ::: warning Config flags are subcommand-local
@@ -78,8 +78,8 @@ Config flags are subcommand-local: pass them after `server`
78
78
 
79
79
  - Per-query timeout: **30 seconds**. A query exceeding it returns
80
80
  `408 Request Timeout`.
81
- - Command hooks (`on-file`, `pre-query`, `post-query`) default to a
82
- **30-second** timeout each, overridable with the config key
81
+ - `on-file` command hooks default to a **30-second** timeout each,
82
+ overridable with the config key
83
83
  [`[dirsql].hook-timeout`](./config.md#dirsql-keys).
84
84
 
85
85
  ### Configless mode
@@ -166,8 +166,7 @@ uses**, so behavior is identical to `POST /query` by construction:
166
166
  (`--persist=/path`) so it does not swallow the SQL argument.
167
167
  - **`--no-ignore`** is honored: path-tables in the query scan files a
168
168
  `.gitignore` would hide. See [Skip rules](./path-tables.md#skip-rules).
169
- - **Hooks** ([`pre-query`](./hooks.md#pre-query) /
170
- [`post-query`](./hooks.md#post-query)) and the
169
+ - **`on-file` hooks** and the
171
170
  [`[dirsql].hook-timeout`](./config.md#dirsql-keys) apply identically.
172
171
  - The **30-second query timeout**, the **read-only rule**, and the
173
172
  `_dirsql_*` **internal-table denial** apply identically. A rejected read
@@ -23,9 +23,7 @@ the config file's location. See [`--config`](./cli.md#flags).
23
23
  | Key | Type | Default | Description |
24
24
  |---|---|---|---|
25
25
  | `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. |
26
- | `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). |
27
- | `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). |
28
- | `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). |
26
+ | `hook-timeout` | integer (seconds) | `30` | One global per-run timeout for every `on-file` command hook run. Positive whole seconds; zero and negative values are a config error. See [Command hooks](./hooks.md#timeout). |
29
27
 
30
28
  The top-level `.dirsql/` directory under the root is always excluded from
31
29
  scanning, whether or not it appears in `ignore` — it is reserved for
@@ -147,15 +145,10 @@ The configs load and merge in **argv order**:
147
145
 
148
146
  - **`[[table]]`, `ignore`, and `[[dirsql.extension]]` entries accumulate** across
149
147
  all configs, in order.
150
- - **Each config's `on-file`, `pre-query`, and `post-query` hooks run from that
151
- config file's own directory**, under that config's own
152
- [`hook-timeout`](#dirsql-keys) — so a relative command like
153
- `on-file = "sh ./extract.sh"` resolves against the config that declared it,
154
- wherever it lives.
155
- - **`pre-query` / `post-query` hooks chain FIFO**: the request body flows through
156
- each `pre-query` stage in order to the final SQL, and the result rows flow
157
- through each `post-query` stage to the response. See the
158
- [hook contract](./hooks.md).
148
+ - **Each config's `on-file` hooks run from that config file's own
149
+ directory**, under that config's own [`hook-timeout`](#dirsql-keys) — so a
150
+ relative command like `on-file = "sh ./extract.sh"` resolves against the
151
+ config that declared it, wherever it lives.
159
152
  - Each config is **validated on its own** (the [parse errors](#parse-errors)
160
153
  below apply per file). There is no cross-file merge validation, with one
161
154
  structural exception: **two configs defining a table of the same name is an
@@ -181,7 +174,6 @@ SDKs raise/reject) when:
181
174
  > `[[table]] '**/*.md' has no on-file hook, so every row would be all-NULL. Add an `on-file` hook that emits the columns, or, for stat columns with no code, query the path directly: `FROM './'``
182
175
 
183
176
  - A `[[dirsql.extension]]` entry omits `path`, or `path` is empty.
184
- - `pre-query` or `post-query` is present but empty/whitespace.
185
177
  - `hook-timeout` is zero or negative.
186
178
 
187
179
  ## Full example
@@ -189,8 +181,6 @@ SDKs raise/reject) when:
189
181
  ```toml
190
182
  [dirsql]
191
183
  ignore = ["node_modules/**", ".git/**", "dist/**"]
192
- pre-query = "uv run python to_sql.py {args}"
193
- post-query = "jq -c '{results: .}'"
194
184
  hook-timeout = 120
195
185
 
196
186
  [[dirsql.extension]]
@@ -1,8 +1,8 @@
1
1
  # Command hooks
2
2
 
3
- Three [config keys](./config.md) run an external command: `on-file` (per
4
- `[[table]]`), and the server-wide `pre-query` and `post-query` (under
5
- `[dirsql]`; CLI server only). All three share one execution contract.
3
+ The `on-file` [config key](./config.md) (per `[[table]]`, also available as
4
+ the [`--on-file` flag](./cli.md#on-file-command) on `dirsql query`) runs an
5
+ external command under the execution contract below.
6
6
 
7
7
  ## Execution contract
8
8
 
@@ -32,8 +32,8 @@ occurrence, within whole argv tokens, in a single left-to-right pass:
32
32
  `{…}` is inert.
33
33
  - An unrecognized `{…}` is left literal.
34
34
 
35
- Which placeholders exist depends on the hook (see
36
- [per-hook contracts](#per-hook-contracts) below).
35
+ The available placeholders are listed under the
36
+ [`on-file` contract](#on-file-contract) below.
37
37
 
38
38
  ### Working directory and environment
39
39
 
@@ -62,7 +62,7 @@ compactly on one line.
62
62
 
63
63
  Every hook run is bounded by a **30-second** default timeout. A run
64
64
  exceeding it is killed and treated as a failure. One global config key
65
- raises (or tightens) the bound for **all** hooks:
65
+ raises (or tightens) the bound:
66
66
 
67
67
  ```toml
68
68
  [dirsql]
@@ -80,19 +80,16 @@ A hook run fails when the command:
80
80
  stderr tail are reported),
81
81
  - exceeds the timeout (killed; stderr tail reported),
82
82
  - exits zero but prints no non-empty stdout line,
83
- - or (per hook, below) prints output that does not parse as expected.
83
+ - or prints output that does not parse as a JSON array of row objects.
84
84
 
85
- What a failure *means* differs per hook:
85
+ What a failure *means*: **per-file isolation.** The file contributes no rows
86
+ and is reported as skipped; the scan indexes every other file and commits.
87
+ The CLI names up to ten skipped files on stderr, then `... and N more`, and
88
+ exits `23` — distinct from `0` (clean) and `1` (the run failed), so a caller
89
+ can tell a partial index from a complete one. A row the table rejects under
90
+ `strict` counts as the same kind of failure.
86
91
 
87
- | Hook | On failure |
88
- |---|---|
89
- | `on-file` | **Per-file isolation.** The file contributes no rows and is reported as skipped; the scan indexes every other file and commits. The CLI names up to ten skipped files on stderr, then `... and N more`, and exits `23` — distinct from `0` (clean) and `1` (the run failed), so a caller can tell a partial index from a complete one. A row the table rejects under `strict` counts as the same kind of failure. |
90
- | `pre-query` | The request returns `500 Internal Server Error` with the command's stderr tail in the JSON `error` body. |
91
- | `post-query` | The request returns `500 Internal Server Error` with the command's stderr tail (or, for unparseable output, `post-query did not return valid JSON: <err>`) in the JSON `error` body. |
92
-
93
- ## Per-hook contracts
94
-
95
- ### `on-file`
92
+ ## `on-file` contract
96
93
 
97
94
  Runs once per file matched by the table's `glob`, at initial scan and on
98
95
  every watched change. The command reads the file itself and prints a JSON
@@ -114,38 +111,3 @@ the path or stat metadata emits it (it has `{path}`).
114
111
  |---|---|
115
112
  | `{path}` | The matched file's **absolute** path. `on-file = "extract.py {path}"` — self-sufficient from any working directory, so the command resolves it even when the config lives outside the index. |
116
113
  | `{root}` | The index root directory. Derive a root-relative path with `relpath({path}, {root})`. |
117
-
118
- ### `pre-query`
119
-
120
- Runs once per `POST /query` request, before the query. The raw request body
121
- goes in; plain-text SQL comes out (the stdout payload line). `dirsql` runs
122
- that SQL and returns rows as usual. With no `pre-query` key, the body is
123
- parsed as `{"sql": …}` instead — see [HTTP API](./http-api.md#post-query).
124
-
125
- | Placeholder | Value |
126
- |---|---|
127
- | `{args}` | The raw `POST /query` request body, verbatim, as one argv token. |
128
-
129
- **The hook owns SQL safety.** The `{args}` substitution keeps the untrusted
130
- body inert *as an argv token*, but whatever SQL string the hook prints is
131
- executed as-is. Validate, escape, or parameterize inside the hook.
132
-
133
- ### `post-query`
134
-
135
- Runs once per successful `POST /query`, after the query. The result rows
136
- are serialized to a JSON array and delivered two ways:
137
-
138
- - **On stdin** — always, unbounded. This is the recommended path.
139
- - **As `{args}`** — only when the serialized payload is ≤ **96 KiB**. Above
140
- that, `{args}` is substituted with an **empty string** and a stderr
141
- warning naming the byte size directs the operator to stdin. The full
142
- payload is still on stdin — this is a fallback, not truncation.
143
-
144
- | Placeholder | Value |
145
- |---|---|
146
- | `{args}` | The result rows as a JSON array, as one argv token; emptied (with a stderr warning) when the payload exceeds 96 KiB. |
147
-
148
- The stdout payload line is parsed as JSON and returned verbatim as the
149
- `200 application/json` response body. A payload that is not valid JSON
150
- fails the request (`500`). With no `post-query` key, the bare row array is
151
- returned — see [HTTP API](./http-api.md#post-query).
@@ -48,11 +48,11 @@ excluded from `SELECT *` results.
48
48
 
49
49
  | Status | When |
50
50
  |---|---|
51
- | `200` | Query succeeded. Body: array of row objects (or the [`post-query`](./hooks.md#post-query) hook's JSON). |
51
+ | `200` | Query succeeded. Body: array of row objects. |
52
52
  | `400` | Malformed JSON body, missing or empty `sql` field, or a SQL error (syntax error, unknown table, or a statement SQLite classifies as a write — queries are read-only). |
53
53
  | `405` | `GET /query`. Plain-text body `method not allowed`. |
54
54
  | `408` | The query exceeded the 30-second per-query timeout. |
55
- | `500` | Internal server fault, or a failed [`pre-query`](./hooks.md#pre-query) / [`post-query`](./hooks.md#post-query) hook. |
55
+ | `500` | Internal server fault. |
56
56
  | `503` | The server is in [degraded mode](./cli.md#degraded-mode) (the config file exists but failed to load). |
57
57
 
58
58
  All error responses (except `405`) are `application/json`:
@@ -61,15 +61,6 @@ All error responses (except `405`) are `application/json`:
61
61
  {"error": "syntax error near \"SLECT\""}
62
62
  ```
63
63
 
64
- ### Hook interactions
65
-
66
- - With [`[dirsql].pre-query`](./config.md#dirsql-keys) configured, the
67
- request body is **not** parsed as `{"sql": …}`; the raw body is passed to
68
- the hook, which prints the SQL to run. Hook failure → `500`.
69
- - With [`[dirsql].post-query`](./config.md#dirsql-keys) configured, the
70
- `200` body is whatever JSON the hook prints instead of the bare row
71
- array. Hook failure or non-JSON output → `500`.
72
-
73
64
  ## `GET /events`
74
65
 
75
66
  Opens a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirsql",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "Ephemeral SQL index over a local directory",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/thekevinscott/dirsql",
@@ -190,6 +190,7 @@
190
190
  }
191
191
  },
192
192
  "dependencies": {
193
+ "bin-shim": "^0.2.0",
193
194
  "smol-toml": "^1.6.1"
194
195
  },
195
196
  "devDependencies": {
@@ -212,15 +213,10 @@
212
213
  ]
213
214
  },
214
215
  "optionalDependencies": {
215
- "@dirsql/lib-linux-x64-gnu": "0.4.12",
216
- "@dirsql/lib-linux-arm64-gnu": "0.4.12",
217
- "@dirsql/lib-darwin-x64": "0.4.12",
218
- "@dirsql/lib-darwin-arm64": "0.4.12",
219
- "@dirsql/lib-win32-x64-msvc": "0.4.12",
220
- "@dirsql/cli-linux-x64-gnu": "0.4.12",
221
- "@dirsql/cli-linux-arm64-gnu": "0.4.12",
222
- "@dirsql/cli-darwin-x64": "0.4.12",
223
- "@dirsql/cli-darwin-arm64": "0.4.12",
224
- "@dirsql/cli-win32-x64-msvc": "0.4.12"
216
+ "@dirsql/lib-linux-x64-gnu": "0.4.14",
217
+ "@dirsql/lib-linux-arm64-gnu": "0.4.14",
218
+ "@dirsql/lib-darwin-x64": "0.4.14",
219
+ "@dirsql/lib-darwin-arm64": "0.4.14",
220
+ "@dirsql/lib-win32-x64-msvc": "0.4.14"
225
221
  }
226
222
  }
@@ -1 +0,0 @@
1
- export declare function platformKey(): string;
@@ -1,5 +0,0 @@
1
- // Return the `platform-arch` key used to look up the per-platform
2
- // optional-dependency package (e.g. `linux-x64`, `darwin-arm64`).
3
- export function platformKey() {
4
- return `${process.platform}-${process.arch}`;
5
- }
@@ -1,5 +0,0 @@
1
- /** A minimal `require.resolve`-shaped function. Injectable for tests. */
2
- export type Resolver = (specifier: string) => string;
3
- /** Default resolver: a CJS-style `require.resolve` rooted at this ESM module. */
4
- export declare function defaultResolver(): Resolver;
5
- export declare function resolveBinary(key?: string, resolver?: Resolver): string;
@@ -1,25 +0,0 @@
1
- // Resolve the absolute path of the prebuilt `dirsql` binary shipped by
2
- // whichever `@dirsql/cli-<triple>` optional-dependency package matched
3
- // the host at `npm install` time.
4
- import { createRequire } from "node:module";
5
- import { nodeTriples } from "../platforms.js";
6
- import { die } from "./die.js";
7
- import { platformKey } from "./platform-key.js";
8
- /** Default resolver: a CJS-style `require.resolve` rooted at this ESM module. */
9
- export function defaultResolver() {
10
- return createRequire(import.meta.url).resolve;
11
- }
12
- export function resolveBinary(key = platformKey(), resolver = defaultResolver()) {
13
- const triples = nodeTriples();
14
- const pkg = triples[key];
15
- if (!pkg) {
16
- die(`no prebuilt binary for ${key}. Build from source with \`cargo install dirsql --features cli\`.`);
17
- }
18
- const bin = process.platform === "win32" ? "dirsql.exe" : "dirsql";
19
- try {
20
- return resolver(`${pkg}/${bin}`);
21
- }
22
- catch {
23
- die(`${pkg} is not installed. If you ran \`npm install --no-optional\` or \`--ignore-optional\`, re-install without that flag.`);
24
- }
25
- }
@@ -1,153 +0,0 @@
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
- ::: tip Just want it working?
11
- [`dirsql-plugin-embeddings`](https://pypi.org/project/dirsql-plugin-embeddings/)
12
- packages exactly what this guide builds, ready to install:
13
- `uvx --with dirsql-plugin-embeddings dirsql server`. Keep reading to see how it's
14
- built — the same three pieces, from scratch.
15
- :::
16
-
17
- ## How the pieces fit
18
-
19
- 1. **[`[[dirsql.extension]]`](../reference/config.md#dirsql-extension)**
20
- loads [`sqlite-vec`](https://github.com/asg017/sqlite-vec), making
21
- `vec_distance_cosine()` callable in queries.
22
- 2. **`on-file`** runs an embedding command once per matched file; the
23
- vector lands in an `embedding` column next to the text.
24
- 3. **`pre-query`** receives each raw `POST /query` body, embeds the
25
- question, and prints the nearest-neighbor SQL to run.
26
-
27
- `dirsql` never sees a model — both hooks are commands you own, so any
28
- embedding model works. This guide uses
29
- [`model2vec`](https://github.com/MinishLab/model2vec), a small, fast,
30
- CPU-only embedding library (its `potion-base-8M` model downloads ~30 MB
31
- from Hugging Face on first use).
32
-
33
- ## 1. The embedding scripts
34
-
35
- Suppose short notes live in `notes/*.md`:
36
-
37
- ```
38
- notes/pasta.md # boiling spaghetti, olive oil, garlic
39
- notes/branches.md # git feature branches and pull requests
40
- notes/tomatoes.md # planting tomato seedlings after the last frost
41
- ```
42
-
43
- Next to them, `embed.py` turns one file into one row carrying its text and
44
- its `path`, text, and embedding (a JSON array, stored as TEXT — `sqlite-vec`
45
- accepts JSON vectors directly). dirsql injects no columns, so the script emits
46
- the path itself, deriving it from the `{path}`/`{root}` the hook passes in:
47
-
48
- ```python
49
- """Embed one file's text; print a dirsql row array on stdout."""
50
- import json
51
- import os
52
- import sys
53
-
54
- from model2vec import StaticModel
55
-
56
- path, root = sys.argv[1], sys.argv[2]
57
- text = open(path, encoding="utf-8").read()
58
- model = StaticModel.from_pretrained("minishlab/potion-base-8M")
59
- vector = model.encode([text])[0]
60
- row = {"path": os.path.relpath(path, root), "text": text,
61
- "embedding": json.dumps([round(float(x), 6) for x in vector])}
62
- print(json.dumps([row]))
63
- ```
64
-
65
- And `search.py` turns a `{"q": "..."}` request body into SQL, embedding the
66
- question with the same model:
67
-
68
- ```python
69
- """Turn a {"q": "..."} request body into a nearest-neighbor SQL query."""
70
- import json
71
- import sys
72
-
73
- from model2vec import StaticModel
74
-
75
- body = json.loads(sys.argv[1])
76
- model = StaticModel.from_pretrained("minishlab/potion-base-8M")
77
- vector = model.encode([body["q"]])[0]
78
- needle = json.dumps([round(float(x), 6) for x in vector])
79
- print(
80
- "SELECT path, ROUND(vec_distance_cosine(embedding, '%s'), 3) AS distance "
81
- "FROM notes ORDER BY distance LIMIT 3" % needle
82
- )
83
- ```
84
-
85
- ::: warning The hook owns SQL safety
86
- Whatever SQL `pre-query` prints is executed as-is. Here the interpolated
87
- value is a numeric vector the script itself produced; never splice raw
88
- request text into SQL. See [`pre-query`](../reference/hooks.md#pre-query).
89
- :::
90
-
91
- ## 2. Wire them up in `.dirsql.toml`
92
-
93
- ```toml
94
- [dirsql]
95
- pre-query = "uv run --with model2vec python search.py {args}"
96
- hook-timeout = 300 # headroom for the first-run model download
97
-
98
- [[dirsql.extension]]
99
- path = "sqlite_vec" # Python module name; see note below
100
- entrypoint = "sqlite3_vec_init"
101
-
102
- [[table]]
103
- ddl = "CREATE TABLE notes (path TEXT, text TEXT, embedding TEXT)"
104
- glob = "notes/*.md"
105
- on-file = "uv run --with model2vec python embed.py {path} {root}"
106
- ```
107
-
108
- The extension is named by package: the Python launcher resolves the
109
- installed `sqlite_vec` module to its bundled loadable. Naming rules per
110
- runtime — and the literal-path alternative that works everywhere — are in
111
- [Load a SQLite extension](./load-extension.md).
112
-
113
- ## 3. Ask questions
114
-
115
- Run with `sqlite-vec` available to the launcher's environment. The initial
116
- scan runs `embed.py` once per note, then the query argument goes straight to
117
- `pre-query`, exactly as a `POST /query` body would. Pass the config with
118
- [`-c`](../reference/cli.md#flags) — `dirsql` does not auto-load a
119
- `.dirsql.toml` from the current directory:
120
-
121
- ```bash
122
- uvx --with sqlite-vec dirsql query '{"q": "how do I cook pasta?"}' -c ./.dirsql.toml
123
- ```
124
-
125
- ```json
126
- [{"path":"notes/pasta.md","distance":0.315},{"path":"notes/tomatoes.md","distance":0.881},{"path":"notes/branches.md","distance":0.92}]
127
- ```
128
-
129
- ```bash
130
- uvx --with sqlite-vec dirsql query '{"q": "reviewing code on github"}' -c ./.dirsql.toml
131
- ```
132
-
133
- ```json
134
- [{"path":"notes/branches.md","distance":0.51},{"path":"notes/pasta.md","distance":1.033},{"path":"notes/tomatoes.md","distance":1.074}]
135
- ```
136
-
137
- Neither question shares a keyword with its top note — "cook" appears
138
- nowhere in `pasta.md`, "github" nowhere in `branches.md`. The distance
139
- ranking is doing the work.
140
-
141
- Because `pre-query` is set, the query argument is *not* the usual
142
- `{"sql": …}` — it goes to your script as-is, which decides what SQL runs
143
- ([hook interactions](../reference/http-api.md#hook-interactions)).
144
-
145
- ## Recomputing vs. caching
146
-
147
- Embeddings are recomputed on every startup, because the database is a
148
- derived, ephemeral view of the files
149
- ([how `dirsql` thinks](../explanation.md)). Once the model is cached this
150
- is fast for small trees; for large ones, enable persistence so unchanged
151
- files keep their stored embeddings across restarts —
152
- [Keep the index across restarts](./persist.md). Editing a note re-embeds
153
- just that file, automatically.