dirsql 0.3.48__tar.gz → 0.3.50__tar.gz

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.
Files changed (121) hide show
  1. {dirsql-0.3.48 → dirsql-0.3.50}/Cargo.lock +1 -1
  2. {dirsql-0.3.48 → dirsql-0.3.50}/PKG-INFO +1 -1
  3. {dirsql-0.3.48 → dirsql-0.3.50}/docs/cli/config.md +56 -2
  4. {dirsql-0.3.48 → dirsql-0.3.50}/docs/cli/http-api.md +9 -0
  5. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/Cargo.toml +1 -1
  6. {dirsql-0.3.48/packages/rust → dirsql-0.3.50/packages/python}/docs/cli/config.md +56 -2
  7. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/cli/http-api.md +9 -0
  8. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/e2e-attestation.json +2 -2
  9. {dirsql-0.3.48/packages/python → dirsql-0.3.50/packages/rust}/docs/cli/config.md +56 -2
  10. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/cli/http-api.md +9 -0
  11. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/bin/dirsql.rs +23 -1
  12. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/cli/mod.rs +57 -0
  13. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/cli/router.rs +85 -2
  14. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/cli/server.rs +1 -0
  15. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/config.rs +71 -11
  16. {dirsql-0.3.48 → dirsql-0.3.50}/Cargo.toml +0 -0
  17. {dirsql-0.3.48 → dirsql-0.3.50}/README.md +0 -0
  18. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/__init__.py +0 -0
  19. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/_async.py +0 -0
  20. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/_dirsql.pyi +0 -0
  21. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/cli/__init__.py +0 -0
  22. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/cli/binary_path.py +0 -0
  23. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/cli/interpret/__init__.py +0 -0
  24. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/cli/is_windows.py +0 -0
  25. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/cli/main.py +0 -0
  26. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/cli/resolve_config_extensions.py +0 -0
  27. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/py.typed +0 -0
  28. {dirsql-0.3.48 → dirsql-0.3.50}/dirsql/resolve_extension.py +0 -0
  29. {dirsql-0.3.48 → dirsql-0.3.50}/docs/.claude/CLAUDE.md +0 -0
  30. {dirsql-0.3.48 → dirsql-0.3.50}/docs/.vitepress/config.ts +0 -0
  31. {dirsql-0.3.48 → dirsql-0.3.50}/docs/.vitepress/theme/index.ts +0 -0
  32. {dirsql-0.3.48 → dirsql-0.3.50}/docs/.vitepress/theme/lang.ts +0 -0
  33. {dirsql-0.3.48 → dirsql-0.3.50}/docs/AGENTS.md +0 -0
  34. {dirsql-0.3.48 → dirsql-0.3.50}/docs/api/index.md +0 -0
  35. {dirsql-0.3.48 → dirsql-0.3.50}/docs/cli/index.md +0 -0
  36. {dirsql-0.3.48 → dirsql-0.3.50}/docs/cli/init.md +0 -0
  37. {dirsql-0.3.48 → dirsql-0.3.50}/docs/cli/server.md +0 -0
  38. {dirsql-0.3.48 → dirsql-0.3.50}/docs/getting-started.md +0 -0
  39. {dirsql-0.3.48 → dirsql-0.3.50}/docs/guide/async.md +0 -0
  40. {dirsql-0.3.48 → dirsql-0.3.50}/docs/guide/crdt.md +0 -0
  41. {dirsql-0.3.48 → dirsql-0.3.50}/docs/guide/persistence.md +0 -0
  42. {dirsql-0.3.48 → dirsql-0.3.50}/docs/guide/querying.md +0 -0
  43. {dirsql-0.3.48 → dirsql-0.3.50}/docs/guide/tables.md +0 -0
  44. {dirsql-0.3.48 → dirsql-0.3.50}/docs/guide/watching.md +0 -0
  45. {dirsql-0.3.48 → dirsql-0.3.50}/docs/index.md +0 -0
  46. {dirsql-0.3.48 → dirsql-0.3.50}/docs/migrations.md +0 -0
  47. {dirsql-0.3.48 → dirsql-0.3.50}/docs/package.json +0 -0
  48. {dirsql-0.3.48 → dirsql-0.3.50}/docs/playwright.config.ts +0 -0
  49. {dirsql-0.3.48 → dirsql-0.3.50}/docs/pnpm-lock.yaml +0 -0
  50. {dirsql-0.3.48 → dirsql-0.3.50}/docs/pnpm-workspace.yaml +0 -0
  51. {dirsql-0.3.48 → dirsql-0.3.50}/docs/tests/integration/home.spec.ts +0 -0
  52. {dirsql-0.3.48 → dirsql-0.3.50}/docs/tests/integration/language-flag.spec.ts +0 -0
  53. {dirsql-0.3.48 → dirsql-0.3.50}/docs/tests/integration/sidebar.spec.ts +0 -0
  54. {dirsql-0.3.48 → dirsql-0.3.50}/docs/tests/unit/config.test.ts +0 -0
  55. {dirsql-0.3.48 → dirsql-0.3.50}/docs/tests/unit/lang.test.ts +0 -0
  56. {dirsql-0.3.48 → dirsql-0.3.50}/docs/vitest.config.ts +0 -0
  57. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/README.md +0 -0
  58. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/conftest.py +0 -0
  59. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/.claude/CLAUDE.md +0 -0
  60. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/.vitepress/config.ts +0 -0
  61. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  62. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  63. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/AGENTS.md +0 -0
  64. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/api/index.md +0 -0
  65. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/cli/index.md +0 -0
  66. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/cli/init.md +0 -0
  67. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/cli/server.md +0 -0
  68. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/getting-started.md +0 -0
  69. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/guide/async.md +0 -0
  70. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/guide/crdt.md +0 -0
  71. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/guide/persistence.md +0 -0
  72. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/guide/querying.md +0 -0
  73. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/guide/tables.md +0 -0
  74. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/guide/watching.md +0 -0
  75. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/index.md +0 -0
  76. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/migrations.md +0 -0
  77. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/package.json +0 -0
  78. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/playwright.config.ts +0 -0
  79. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/pnpm-lock.yaml +0 -0
  80. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/pnpm-workspace.yaml +0 -0
  81. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  82. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  83. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  84. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/tests/unit/config.test.ts +0 -0
  85. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  86. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/docs/vitest.config.ts +0 -0
  87. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/src/lib.rs +0 -0
  88. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/tests/__init__.py +0 -0
  89. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/tests/conftest.py +0 -0
  90. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/tests/e2e/__init__.py +0 -0
  91. {dirsql-0.3.48 → dirsql-0.3.50}/packages/python/tests/integration/__init__.py +0 -0
  92. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/Cargo.toml +0 -0
  93. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/README.md +0 -0
  94. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/benches/db_bench.rs +0 -0
  95. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/benches/differ_bench.rs +0 -0
  96. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/benches/matcher_bench.rs +0 -0
  97. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/benches/scanner_bench.rs +0 -0
  98. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/api/index.md +0 -0
  99. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/cli/index.md +0 -0
  100. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/cli/init.md +0 -0
  101. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/cli/server.md +0 -0
  102. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/getting-started.md +0 -0
  103. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/guide/async.md +0 -0
  104. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/guide/crdt.md +0 -0
  105. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/guide/persistence.md +0 -0
  106. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/guide/querying.md +0 -0
  107. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/guide/tables.md +0 -0
  108. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/guide/watching.md +0 -0
  109. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/index.md +0 -0
  110. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/docs/migrations.md +0 -0
  111. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/cli/init.rs +0 -0
  112. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/cli/serialize.rs +0 -0
  113. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/command.rs +0 -0
  114. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/db.rs +0 -0
  115. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/differ.rs +0 -0
  116. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/lib.rs +0 -0
  117. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/matcher.rs +0 -0
  118. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/persist.rs +0 -0
  119. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/scanner.rs +0 -0
  120. {dirsql-0.3.48 → dirsql-0.3.50}/packages/rust/src/watcher.rs +0 -0
  121. {dirsql-0.3.48 → dirsql-0.3.50}/pyproject.toml +0 -0
@@ -501,7 +501,7 @@ dependencies = [
501
501
 
502
502
  [[package]]
503
503
  name = "dirsql-py-ext"
504
- version = "0.3.48"
504
+ version = "0.3.50"
505
505
  dependencies = [
506
506
  "dirsql",
507
507
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.48
3
+ Version: 0.3.50
4
4
  Requires-Dist: pytest>=8 ; extra == 'dev'
5
5
  Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
6
  Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
@@ -310,6 +310,60 @@ curl -s http://localhost:7117/query -d 'recent-posts' | jq
310
310
  See [Command execution](#command-execution) for the full contract (argv
311
311
  splitting, injection safety, cwd, environment, timeout, and output framing).
312
312
 
313
+ ### Reshaping responses (`post-query`)
314
+
315
+ The `post-query` hook lets you reshape every query response before it's
316
+ returned — wrap the rows in an envelope, rename or project fields, add
317
+ metadata, or emit whatever JSON shape your clients expect. Because the hook
318
+ owns the response body, `POST /query` can return any structure you like instead
319
+ of the default bare array of row objects. Like [`pre-query`](#rewriting-queries-pre-query),
320
+ `post-query` is a **server-wide** `[dirsql]` key: every successful query flows
321
+ through it.
322
+
323
+ ```toml
324
+ [dirsql]
325
+ post-query = "jq -c '{results: .}'"
326
+ ```
327
+
328
+ The `-c` (compact) flag matters: the response body is the command's **last
329
+ non-empty stdout line** (see [Command execution](#command-execution)), so the
330
+ command must print its JSON on a single line — `jq`'s default multi-line
331
+ pretty-printing would leave only a trailing `}` as the last line.
332
+
333
+ After a successful query, `dirsql` serializes the result rows to a **JSON
334
+ array** and hands it to the command two ways:
335
+
336
+ - **On stdin** — always, unbounded and injection-safe. This is the recommended
337
+ path, and it's what the canonical `jq -c '{results: .}'` example reads.
338
+ - **As the `{args}` placeholder** — for small result sets. When the serialized
339
+ payload exceeds **96 KiB** (comfortably under the OS single-argument limit),
340
+ `{args}` is substituted with an **empty string** and a warning naming the byte
341
+ size is logged to stderr, directing the operator to read stdin instead. The
342
+ full payload is still delivered on stdin — this is a fallback, **not**
343
+ truncation.
344
+
345
+ | Placeholder | Value |
346
+ |-------------|-------|
347
+ | `{args}` | The result rows as a JSON array, as one argv token — emptied (with a stderr warning) when the payload exceeds 96 KiB; read stdin for the full set. |
348
+
349
+ The command prints the **JSON response body** on stdout (the last non-empty
350
+ line is used); `dirsql` parses it with `serde_json` and returns it as `200
351
+ application/json`. If that line is **not valid JSON**, the request returns `500
352
+ Internal Server Error` with `post-query did not return valid JSON: <err>`.
353
+
354
+ When `post-query` is **absent**, nothing changes: the rows are returned as-is —
355
+ the [HTTP API](./http-api.md#post-query) default. Enabling the hook is fully
356
+ backward compatible in reverse: remove the key and the bare-array contract
357
+ returns.
358
+
359
+ **On failure** — a non-zero exit, a timeout, or a spawn error — the request
360
+ returns `500 Internal Server Error` with the command's stderr tail in the JSON
361
+ `error` body. The command runs in the config file's directory and is bounded by
362
+ a fixed **30-second** timeout.
363
+
364
+ See [Command execution](#command-execution) for the full contract (argv
365
+ splitting, injection safety, cwd, environment, timeout, and output framing).
366
+
313
367
  ### Full Example
314
368
 
315
369
  ```toml
@@ -331,8 +385,8 @@ glob = "logs/*.csv"
331
385
 
332
386
  ## Command execution
333
387
 
334
- Config keys that run an external command — today `on-file` and `pre-query`,
335
- with more events to follow — share one execution contract:
388
+ Config keys that run an external command — today `on-file`, `pre-query`, and
389
+ `post-query`, with more events to follow — share one execution contract:
336
390
 
337
391
  - **argv, not a shell.** The command string is split into an argv with
338
392
  shell-like quoting (spaces separate arguments; quotes group them), but **no
@@ -44,6 +44,15 @@ command's stderr tail. With no `pre-query` key, the `{"sql": …}` contract abov
44
44
  applies.
45
45
  :::
46
46
 
47
+ ::: tip `post-query` changes the response body
48
+ When [`[dirsql].post-query`](./config.md#reshaping-responses-post-query) is
49
+ configured, the result rows are handed to the hook command (as a JSON array on
50
+ stdin, and as `{args}` for small sets) and the JSON body it prints is returned
51
+ instead of the bare array above. A hook that fails (non-zero exit, timeout,
52
+ spawn error) or prints invalid JSON returns `500`. With no `post-query` key, the
53
+ array-of-rows response above applies.
54
+ :::
55
+
47
56
  ```bash
48
57
  curl -s http://localhost:7117/query \
49
58
  -H 'content-type: application/json' \
@@ -4,7 +4,7 @@ name = "dirsql-py-ext"
4
4
  # pypi/maturin handler can rewrite it via `write-version` before
5
5
  # `maturin build`. `pyproject.toml` declares `dynamic = ["version"]`
6
6
  # and maturin reads this field. Mirrors `packages/rust/Cargo.toml`.
7
- version = "0.3.48"
7
+ version = "0.3.50"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -310,6 +310,60 @@ curl -s http://localhost:7117/query -d 'recent-posts' | jq
310
310
  See [Command execution](#command-execution) for the full contract (argv
311
311
  splitting, injection safety, cwd, environment, timeout, and output framing).
312
312
 
313
+ ### Reshaping responses (`post-query`)
314
+
315
+ The `post-query` hook lets you reshape every query response before it's
316
+ returned — wrap the rows in an envelope, rename or project fields, add
317
+ metadata, or emit whatever JSON shape your clients expect. Because the hook
318
+ owns the response body, `POST /query` can return any structure you like instead
319
+ of the default bare array of row objects. Like [`pre-query`](#rewriting-queries-pre-query),
320
+ `post-query` is a **server-wide** `[dirsql]` key: every successful query flows
321
+ through it.
322
+
323
+ ```toml
324
+ [dirsql]
325
+ post-query = "jq -c '{results: .}'"
326
+ ```
327
+
328
+ The `-c` (compact) flag matters: the response body is the command's **last
329
+ non-empty stdout line** (see [Command execution](#command-execution)), so the
330
+ command must print its JSON on a single line — `jq`'s default multi-line
331
+ pretty-printing would leave only a trailing `}` as the last line.
332
+
333
+ After a successful query, `dirsql` serializes the result rows to a **JSON
334
+ array** and hands it to the command two ways:
335
+
336
+ - **On stdin** — always, unbounded and injection-safe. This is the recommended
337
+ path, and it's what the canonical `jq -c '{results: .}'` example reads.
338
+ - **As the `{args}` placeholder** — for small result sets. When the serialized
339
+ payload exceeds **96 KiB** (comfortably under the OS single-argument limit),
340
+ `{args}` is substituted with an **empty string** and a warning naming the byte
341
+ size is logged to stderr, directing the operator to read stdin instead. The
342
+ full payload is still delivered on stdin — this is a fallback, **not**
343
+ truncation.
344
+
345
+ | Placeholder | Value |
346
+ |-------------|-------|
347
+ | `{args}` | The result rows as a JSON array, as one argv token — emptied (with a stderr warning) when the payload exceeds 96 KiB; read stdin for the full set. |
348
+
349
+ The command prints the **JSON response body** on stdout (the last non-empty
350
+ line is used); `dirsql` parses it with `serde_json` and returns it as `200
351
+ application/json`. If that line is **not valid JSON**, the request returns `500
352
+ Internal Server Error` with `post-query did not return valid JSON: <err>`.
353
+
354
+ When `post-query` is **absent**, nothing changes: the rows are returned as-is —
355
+ the [HTTP API](./http-api.md#post-query) default. Enabling the hook is fully
356
+ backward compatible in reverse: remove the key and the bare-array contract
357
+ returns.
358
+
359
+ **On failure** — a non-zero exit, a timeout, or a spawn error — the request
360
+ returns `500 Internal Server Error` with the command's stderr tail in the JSON
361
+ `error` body. The command runs in the config file's directory and is bounded by
362
+ a fixed **30-second** timeout.
363
+
364
+ See [Command execution](#command-execution) for the full contract (argv
365
+ splitting, injection safety, cwd, environment, timeout, and output framing).
366
+
313
367
  ### Full Example
314
368
 
315
369
  ```toml
@@ -331,8 +385,8 @@ glob = "logs/*.csv"
331
385
 
332
386
  ## Command execution
333
387
 
334
- Config keys that run an external command — today `on-file` and `pre-query`,
335
- with more events to follow — share one execution contract:
388
+ Config keys that run an external command — today `on-file`, `pre-query`, and
389
+ `post-query`, with more events to follow — share one execution contract:
336
390
 
337
391
  - **argv, not a shell.** The command string is split into an argv with
338
392
  shell-like quoting (spaces separate arguments; quotes group them), but **no
@@ -44,6 +44,15 @@ command's stderr tail. With no `pre-query` key, the `{"sql": …}` contract abov
44
44
  applies.
45
45
  :::
46
46
 
47
+ ::: tip `post-query` changes the response body
48
+ When [`[dirsql].post-query`](./config.md#reshaping-responses-post-query) is
49
+ configured, the result rows are handed to the hook command (as a JSON array on
50
+ stdin, and as `{args}` for small sets) and the JSON body it prints is returned
51
+ instead of the bare array above. A hook that fails (non-zero exit, timeout,
52
+ spawn error) or prints invalid JSON returns `500`. With no `post-query` key, the
53
+ array-of-rows response above applies.
54
+ :::
55
+
47
56
  ```bash
48
57
  curl -s http://localhost:7117/query \
49
58
  -H 'content-type: application/json' \
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "command": "uv run python -m pytest tests/e2e/ -x -q",
3
- "ran_at": 1782996616,
3
+ "ran_at": 1783031403,
4
4
  "exit_code": 0,
5
- "commit": "19cf2e30ef5bff8fe5e77b2c59fd54ca0fc46575"
5
+ "commit": "3d7c9268191f94a3180442716aa1c49f48699776"
6
6
  }
@@ -310,6 +310,60 @@ curl -s http://localhost:7117/query -d 'recent-posts' | jq
310
310
  See [Command execution](#command-execution) for the full contract (argv
311
311
  splitting, injection safety, cwd, environment, timeout, and output framing).
312
312
 
313
+ ### Reshaping responses (`post-query`)
314
+
315
+ The `post-query` hook lets you reshape every query response before it's
316
+ returned — wrap the rows in an envelope, rename or project fields, add
317
+ metadata, or emit whatever JSON shape your clients expect. Because the hook
318
+ owns the response body, `POST /query` can return any structure you like instead
319
+ of the default bare array of row objects. Like [`pre-query`](#rewriting-queries-pre-query),
320
+ `post-query` is a **server-wide** `[dirsql]` key: every successful query flows
321
+ through it.
322
+
323
+ ```toml
324
+ [dirsql]
325
+ post-query = "jq -c '{results: .}'"
326
+ ```
327
+
328
+ The `-c` (compact) flag matters: the response body is the command's **last
329
+ non-empty stdout line** (see [Command execution](#command-execution)), so the
330
+ command must print its JSON on a single line — `jq`'s default multi-line
331
+ pretty-printing would leave only a trailing `}` as the last line.
332
+
333
+ After a successful query, `dirsql` serializes the result rows to a **JSON
334
+ array** and hands it to the command two ways:
335
+
336
+ - **On stdin** — always, unbounded and injection-safe. This is the recommended
337
+ path, and it's what the canonical `jq -c '{results: .}'` example reads.
338
+ - **As the `{args}` placeholder** — for small result sets. When the serialized
339
+ payload exceeds **96 KiB** (comfortably under the OS single-argument limit),
340
+ `{args}` is substituted with an **empty string** and a warning naming the byte
341
+ size is logged to stderr, directing the operator to read stdin instead. The
342
+ full payload is still delivered on stdin — this is a fallback, **not**
343
+ truncation.
344
+
345
+ | Placeholder | Value |
346
+ |-------------|-------|
347
+ | `{args}` | The result rows as a JSON array, as one argv token — emptied (with a stderr warning) when the payload exceeds 96 KiB; read stdin for the full set. |
348
+
349
+ The command prints the **JSON response body** on stdout (the last non-empty
350
+ line is used); `dirsql` parses it with `serde_json` and returns it as `200
351
+ application/json`. If that line is **not valid JSON**, the request returns `500
352
+ Internal Server Error` with `post-query did not return valid JSON: <err>`.
353
+
354
+ When `post-query` is **absent**, nothing changes: the rows are returned as-is —
355
+ the [HTTP API](./http-api.md#post-query) default. Enabling the hook is fully
356
+ backward compatible in reverse: remove the key and the bare-array contract
357
+ returns.
358
+
359
+ **On failure** — a non-zero exit, a timeout, or a spawn error — the request
360
+ returns `500 Internal Server Error` with the command's stderr tail in the JSON
361
+ `error` body. The command runs in the config file's directory and is bounded by
362
+ a fixed **30-second** timeout.
363
+
364
+ See [Command execution](#command-execution) for the full contract (argv
365
+ splitting, injection safety, cwd, environment, timeout, and output framing).
366
+
313
367
  ### Full Example
314
368
 
315
369
  ```toml
@@ -331,8 +385,8 @@ glob = "logs/*.csv"
331
385
 
332
386
  ## Command execution
333
387
 
334
- Config keys that run an external command — today `on-file` and `pre-query`,
335
- with more events to follow — share one execution contract:
388
+ Config keys that run an external command — today `on-file`, `pre-query`, and
389
+ `post-query`, with more events to follow — share one execution contract:
336
390
 
337
391
  - **argv, not a shell.** The command string is split into an argv with
338
392
  shell-like quoting (spaces separate arguments; quotes group them), but **no
@@ -44,6 +44,15 @@ command's stderr tail. With no `pre-query` key, the `{"sql": …}` contract abov
44
44
  applies.
45
45
  :::
46
46
 
47
+ ::: tip `post-query` changes the response body
48
+ When [`[dirsql].post-query`](./config.md#reshaping-responses-post-query) is
49
+ configured, the result rows are handed to the hook command (as a JSON array on
50
+ stdin, and as `{args}` for small sets) and the JSON body it prints is returned
51
+ instead of the bare array above. A hook that fails (non-zero exit, timeout,
52
+ spawn error) or prints invalid JSON returns `500`. With no `post-query` key, the
53
+ array-of-rows response above applies.
54
+ :::
55
+
47
56
  ```bash
48
57
  curl -s http://localhost:7117/query \
49
58
  -H 'content-type: application/json' \
@@ -8,7 +8,9 @@ use std::path::{Path, PathBuf};
8
8
  use std::process::ExitCode;
9
9
 
10
10
  use clap::{Args, Parser, Subcommand};
11
- use dirsql::cli::{AppState, PreQuery, ServerConfig, init::InitOptions, serve_with_state};
11
+ use dirsql::cli::{
12
+ AppState, PostQuery, PreQuery, ServerConfig, init::InitOptions, serve_with_state,
13
+ };
12
14
  use dirsql::{DirSQL, Extension, Row, Table};
13
15
 
14
16
  #[derive(Debug, Parser)]
@@ -122,6 +124,9 @@ async fn run_server(cli: Cli) -> ExitCode {
122
124
  if let Some(pre_query) = load_pre_query(&cli) {
123
125
  server_config = server_config.with_pre_query(pre_query);
124
126
  }
127
+ if let Some(post_query) = load_post_query(&cli) {
128
+ server_config = server_config.with_post_query(post_query);
129
+ }
125
130
 
126
131
  let host = cli.host.clone();
127
132
  let handle = match serve_with_state(server_config, state).await {
@@ -228,6 +233,23 @@ fn load_pre_query(cli: &Cli) -> Option<PreQuery> {
228
233
  Some(PreQuery::new(command, config_dir))
229
234
  }
230
235
 
236
+ /// Extract the server-wide `post-query` hook from the config, if any.
237
+ ///
238
+ /// Returns `None` when the config is absent, unresolvable, unparsable, or
239
+ /// declares no `post-query` — the server then returns `POST /query` result rows
240
+ /// as-is (the degraded / zero-config paths never get a hook). The command's
241
+ /// working directory is the config file's parent, mirroring [`load_pre_query`].
242
+ fn load_post_query(cli: &Cli) -> Option<PostQuery> {
243
+ let config_path = &cli.config;
244
+ if !config_path.exists() {
245
+ return None;
246
+ }
247
+ let resolved = config_path.canonicalize().ok()?;
248
+ let command = dirsql::config::load_config(&resolved).ok()?.post_query?;
249
+ let config_dir = resolved.parent()?.to_path_buf();
250
+ Some(PostQuery::new(command, config_dir))
251
+ }
252
+
231
253
  /// Zero-config fallback. When no `.dirsql.toml` is found, dirsql indexes the
232
254
  /// directory that would have held the config with a single default `files`
233
255
  /// table — one row per file, columns drawn entirely from filesystem facts —
@@ -63,6 +63,31 @@ impl PreQuery {
63
63
  }
64
64
  }
65
65
 
66
+ /// A server-wide `post-query` command hook, carrying the command template plus
67
+ /// the directory it runs in (the config file's parent). When set on a
68
+ /// [`ServerConfig`], the server hands each successful `POST /query` result set
69
+ /// (the rows serialized as a JSON array) to the command as `{args}` and on
70
+ /// stdin, and returns the JSON body the command prints instead of the rows
71
+ /// as-is. See [`crate::command`] for the execution contract.
72
+ #[derive(Debug, Clone)]
73
+ pub struct PostQuery {
74
+ /// The command template (argv-split, no shell). Receives the serialized
75
+ /// result rows as the `{args}` placeholder (and on stdin).
76
+ pub command: String,
77
+ /// The command's working directory — the config file's parent.
78
+ pub config_dir: PathBuf,
79
+ }
80
+
81
+ impl PostQuery {
82
+ /// Build a [`PostQuery`] from a command template and its working directory.
83
+ pub fn new(command: impl Into<String>, config_dir: impl Into<PathBuf>) -> Self {
84
+ Self {
85
+ command: command.into(),
86
+ config_dir: config_dir.into(),
87
+ }
88
+ }
89
+ }
90
+
66
91
  /// Configure how the server binds. Defaults to `localhost:7117` with a
67
92
  /// 30-second per-query timeout and no `pre-query` hook.
68
93
  #[derive(Debug, Clone)]
@@ -73,6 +98,9 @@ pub struct ServerConfig {
73
98
  /// Optional server-wide `pre-query` command. When `None` (the default),
74
99
  /// `POST /query` parses its body as `{"sql": …}`.
75
100
  pub pre_query: Option<PreQuery>,
101
+ /// Optional server-wide `post-query` command. When `None` (the default),
102
+ /// `POST /query` returns the result rows as-is.
103
+ pub post_query: Option<PostQuery>,
76
104
  }
77
105
 
78
106
  impl ServerConfig {
@@ -84,6 +112,7 @@ impl ServerConfig {
84
112
  port: 0,
85
113
  query_timeout: Duration::from_secs(30),
86
114
  pre_query: None,
115
+ post_query: None,
87
116
  }
88
117
  }
89
118
 
@@ -94,6 +123,7 @@ impl ServerConfig {
94
123
  port,
95
124
  query_timeout: Duration::from_secs(30),
96
125
  pre_query: None,
126
+ post_query: None,
97
127
  }
98
128
  }
99
129
 
@@ -111,6 +141,14 @@ impl ServerConfig {
111
141
  self.pre_query = Some(pre_query);
112
142
  self
113
143
  }
144
+
145
+ /// Attach a server-wide [`PostQuery`] hook. With it set, `POST /query`
146
+ /// hands each successful result set to the command and returns the JSON
147
+ /// body it prints instead of returning the rows as-is.
148
+ pub fn with_post_query(mut self, post_query: PostQuery) -> Self {
149
+ self.post_query = Some(post_query);
150
+ self
151
+ }
114
152
  }
115
153
 
116
154
  impl Default for ServerConfig {
@@ -214,6 +252,7 @@ mod tests {
214
252
  assert_eq!(cfg.port, 7117);
215
253
  assert_eq!(cfg.query_timeout, Duration::from_secs(30));
216
254
  assert!(cfg.pre_query.is_none());
255
+ assert!(cfg.post_query.is_none());
217
256
  }
218
257
 
219
258
  #[test]
@@ -232,4 +271,22 @@ mod tests {
232
271
  assert_eq!(pq.command, "cmd {args}");
233
272
  assert_eq!(pq.config_dir, PathBuf::from("/proj"));
234
273
  }
274
+
275
+ #[test]
276
+ fn post_query_constructor_carries_command_and_dir() {
277
+ // `PostQuery::new` is pure data plumbing: the command template and the
278
+ // working directory it will run in.
279
+ let pq = PostQuery::new("jq '{results: .}'", "/proj");
280
+ assert_eq!(pq.command, "jq '{results: .}'");
281
+ assert_eq!(pq.config_dir, PathBuf::from("/proj"));
282
+ }
283
+
284
+ #[test]
285
+ fn with_post_query_sets_the_hook() {
286
+ let cfg =
287
+ ServerConfig::ephemeral().with_post_query(PostQuery::new("reshape {args}", "/proj"));
288
+ let pq = cfg.post_query.expect("hook must be set");
289
+ assert_eq!(pq.command, "reshape {args}");
290
+ assert_eq!(pq.config_dir, PathBuf::from("/proj"));
291
+ }
235
292
  }
@@ -17,7 +17,7 @@ use tokio::sync::{broadcast, watch};
17
17
  use tokio_stream::wrappers::BroadcastStream;
18
18
 
19
19
  use super::serialize::rows_to_json;
20
- use super::{AppState, PreQuery};
20
+ use super::{AppState, PostQuery, PreQuery};
21
21
  use crate::command::{Placeholder, run_command};
22
22
  use crate::{DirSQL, DirSqlError};
23
23
 
@@ -26,6 +26,16 @@ use crate::{DirSQL, DirSqlError};
26
26
  /// `on-file`'s `ON_FILE_TIMEOUT`).
27
27
  const PRE_QUERY_TIMEOUT: Duration = Duration::from_secs(30);
28
28
 
29
+ /// Fixed timeout for a server-wide `post-query` command (mirrors
30
+ /// `PRE_QUERY_TIMEOUT`).
31
+ const POST_QUERY_TIMEOUT: Duration = Duration::from_secs(30);
32
+
33
+ /// Cap on the serialized result payload passed as the `{args}` argv token.
34
+ /// Beyond this, `{args}` is emptied and the operator is directed to stdin
35
+ /// (which always carries the full payload) — comfortably under Linux's 128 KiB
36
+ /// single-arg `MAX_ARG_STRLEN`.
37
+ const POST_QUERY_ARGS_MAX: usize = 96 * 1024;
38
+
29
39
  pub(super) struct AppContext {
30
40
  pub state: AppState,
31
41
  pub events: broadcast::Sender<String>,
@@ -35,6 +45,10 @@ pub(super) struct AppContext {
35
45
  /// rewrites the request body through the command; when `None`, the body
36
46
  /// is parsed as `{"sql": …}`.
37
47
  pub pre_query: Option<PreQuery>,
48
+ /// Optional server-wide `post-query` hook. When `Some`, a successful
49
+ /// `POST /query` result set is reshaped by the command before responding;
50
+ /// when `None`, the rows are returned as-is.
51
+ pub post_query: Option<PostQuery>,
38
52
  }
39
53
 
40
54
  pub(super) type SharedCtx = Arc<AppContext>;
@@ -81,7 +95,16 @@ async fn handle_query(State(ctx): State<SharedCtx>, body: String) -> Response {
81
95
  tokio::time::timeout(timeout, tokio::task::spawn_blocking(move || db.query(&sql))).await;
82
96
 
83
97
  match join {
84
- Ok(Ok(Ok(rows))) => Json(rows_to_json(&rows)).into_response(),
98
+ Ok(Ok(Ok(rows))) => {
99
+ let rows_json = rows_to_json(&rows);
100
+ match &ctx.post_query {
101
+ Some(pq) => match run_post_query(pq, rows_json).await {
102
+ Ok(value) => Json(value).into_response(),
103
+ Err(resp) => resp,
104
+ },
105
+ None => Json(rows_json).into_response(),
106
+ }
107
+ }
85
108
  Ok(Ok(Err(err))) => {
86
109
  let status = classify_query_error(&err);
87
110
  error_response(status, err.to_string())
@@ -154,6 +177,66 @@ async fn run_pre_query(pq: &PreQuery, raw_body: String) -> Result<String, Respon
154
177
  .map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
155
178
  }
156
179
 
180
+ /// Run the server-wide `post-query` hook over a successful result set and
181
+ /// return the JSON body it prints. The rows are serialized to a JSON array and
182
+ /// delivered two ways: always on the child's stdin (unbounded, injection-safe),
183
+ /// and as the `{args}` placeholder when the payload is within
184
+ /// [`POST_QUERY_ARGS_MAX`] (beyond that `{args}` is emptied and a warning names
185
+ /// the size, directing the operator to stdin — never silent truncation). The
186
+ /// command's last non-empty stdout line is parsed as JSON and returned as the
187
+ /// `200` body; anything that isn't valid JSON, or any failure (non-zero exit,
188
+ /// timeout, spawn error), maps to `500`.
189
+ ///
190
+ /// `Response` is large (see [`parse_sql_body`]); returned by value for the same
191
+ /// reason.
192
+ #[allow(clippy::result_large_err)]
193
+ async fn run_post_query(
194
+ pq: &PostQuery,
195
+ rows: Vec<serde_json::Value>,
196
+ ) -> Result<serde_json::Value, Response> {
197
+ let payload = serde_json::to_string(&rows)
198
+ .map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
199
+ let command = pq.command.clone();
200
+ let config_dir = pq.config_dir.clone();
201
+ // `run_command` is blocking — it spawns a child and joins drain threads —
202
+ // so run it off the async runtime. It enforces `POST_QUERY_TIMEOUT`
203
+ // internally, so no outer `tokio::time::timeout` is needed.
204
+ let outcome = tokio::task::spawn_blocking(move || {
205
+ let args_value = if payload.len() <= POST_QUERY_ARGS_MAX {
206
+ payload.clone()
207
+ } else {
208
+ eprintln!(
209
+ "dirsql: post-query result payload is {} bytes, exceeding the \
210
+ {POST_QUERY_ARGS_MAX}-byte argv threshold; `{{args}}` is emptied — \
211
+ read the rows from stdin instead",
212
+ payload.len()
213
+ );
214
+ String::new()
215
+ };
216
+ run_command(
217
+ &command,
218
+ &[Placeholder::new("args", &args_value)],
219
+ &config_dir,
220
+ POST_QUERY_TIMEOUT,
221
+ Some(payload.as_bytes()),
222
+ )
223
+ })
224
+ .await
225
+ .map_err(|join_err| error_response(StatusCode::INTERNAL_SERVER_ERROR, join_err.to_string()))?;
226
+
227
+ let out = outcome
228
+ .map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
229
+
230
+ // The command's payload (last non-empty stdout line) is the JSON response
231
+ // body; reject anything that doesn't parse as JSON.
232
+ serde_json::from_str(&out.payload).map_err(|err| {
233
+ error_response(
234
+ StatusCode::INTERNAL_SERVER_ERROR,
235
+ format!("post-query did not return valid JSON: {err}"),
236
+ )
237
+ })
238
+ }
239
+
157
240
  async fn handle_events(State(ctx): State<SharedCtx>) -> Response {
158
241
  if let Err(resp) = require_ready(&ctx.state) {
159
242
  return resp;
@@ -49,6 +49,7 @@ pub async fn serve_with_state(
49
49
  cancel: cancel_rx,
50
50
  query_timeout: config.query_timeout,
51
51
  pre_query: config.pre_query,
52
+ post_query: config.post_query,
52
53
  });
53
54
  let app = router(shared);
54
55
 
@@ -51,6 +51,13 @@ pub struct Config {
51
51
  /// execution contract. Only the CLI server consults this; the SDK ignores
52
52
  /// it.
53
53
  pub pre_query: Option<String>,
54
+ /// Optional server-wide `post-query` command (`[dirsql].post-query`). When
55
+ /// set, the HTTP server hands each successful `POST /query` result set (the
56
+ /// rows serialized as a JSON array) to this command as `{args}` and on
57
+ /// stdin, and returns the JSON body the command prints, instead of returning
58
+ /// the rows as-is. See `dirsql::command` for the execution contract. Only
59
+ /// the CLI server consults this; the SDK ignores it.
60
+ pub post_query: Option<String>,
54
61
  }
55
62
 
56
63
  /// A SQLite extension to load at startup.
@@ -109,6 +116,8 @@ struct RawDirsql {
109
116
  extension: Option<Vec<RawExtension>>,
110
117
  #[serde(rename = "pre-query")]
111
118
  pre_query: Option<String>,
119
+ #[serde(rename = "post-query")]
120
+ post_query: Option<String>,
112
121
  }
113
122
 
114
123
  #[derive(Deserialize)]
@@ -136,17 +145,19 @@ pub fn load_config(path: &Path) -> Result<Config> {
136
145
  pub fn load_config_str(content: &str) -> Result<Config> {
137
146
  let raw: RawConfig = toml::from_str(content)?;
138
147
 
139
- let (root, ignore, persist, persist_path, raw_extensions, raw_pre_query) = match raw.dirsql {
140
- Some(d) => (
141
- d.root,
142
- d.ignore.unwrap_or_default(),
143
- d.persist.unwrap_or(false),
144
- d.persist_path,
145
- d.extension.unwrap_or_default(),
146
- d.pre_query,
147
- ),
148
- None => (None, Vec::new(), false, None, Vec::new(), None),
149
- };
148
+ let (root, ignore, persist, persist_path, raw_extensions, raw_pre_query, raw_post_query) =
149
+ match raw.dirsql {
150
+ Some(d) => (
151
+ d.root,
152
+ d.ignore.unwrap_or_default(),
153
+ d.persist.unwrap_or(false),
154
+ d.persist_path,
155
+ d.extension.unwrap_or_default(),
156
+ d.pre_query,
157
+ d.post_query,
158
+ ),
159
+ None => (None, Vec::new(), false, None, Vec::new(), None, None),
160
+ };
150
161
 
151
162
  // A present-but-empty `pre-query = ""` is as unusable as a missing key:
152
163
  // reject it at parse time rather than spawning an empty command later
@@ -158,6 +169,16 @@ pub fn load_config_str(content: &str) -> Result<Config> {
158
169
  other => other,
159
170
  };
160
171
 
172
+ // A present-but-empty `post-query = ""` is as unusable as a missing key:
173
+ // reject it at parse time rather than spawning an empty command later
174
+ // (mirrors the `pre-query` handling above).
175
+ let post_query = match raw_post_query {
176
+ Some(cmd) if cmd.trim().is_empty() => {
177
+ return Err(ConfigError::EmptyField("post-query"));
178
+ }
179
+ other => other,
180
+ };
181
+
161
182
  let mut extensions = Vec::with_capacity(raw_extensions.len());
162
183
  for raw_ext in raw_extensions {
163
184
  // An empty `path = ""` is as unusable as a missing key: reject it at
@@ -204,6 +225,7 @@ pub fn load_config_str(content: &str) -> Result<Config> {
204
225
  persist_path,
205
226
  extensions,
206
227
  pre_query,
228
+ post_query,
207
229
  })
208
230
  }
209
231
 
@@ -568,6 +590,44 @@ pre-query = " "
568
590
  );
569
591
  }
570
592
 
593
+ #[test]
594
+ fn post_query_parses_when_present() {
595
+ let toml = r#"
596
+ [dirsql]
597
+ post-query = "jq '{results: .}'"
598
+
599
+ [[table]]
600
+ ddl = "CREATE TABLE t (_path TEXT)"
601
+ glob = "*.json"
602
+ "#;
603
+ let config = load_config_str(toml).unwrap();
604
+ assert_eq!(config.post_query.as_deref(), Some("jq '{results: .}'"));
605
+ }
606
+
607
+ #[test]
608
+ fn post_query_absent_is_none() {
609
+ let toml = r#"
610
+ [[table]]
611
+ ddl = "CREATE TABLE t (_path TEXT)"
612
+ glob = "*.json"
613
+ "#;
614
+ let config = load_config_str(toml).unwrap();
615
+ assert!(config.post_query.is_none());
616
+ }
617
+
618
+ #[test]
619
+ fn post_query_empty_errors() {
620
+ let toml = r#"
621
+ [dirsql]
622
+ post-query = " "
623
+ "#;
624
+ let err = load_config_str(toml).unwrap_err();
625
+ assert!(
626
+ matches!(err, ConfigError::EmptyField("post-query")),
627
+ "got: {err:?}"
628
+ );
629
+ }
630
+
571
631
  #[test]
572
632
  fn extension_empty_path_errors() {
573
633
  // An empty `path = ""` is as unusable as a missing key — it must be
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes