dirsql 0.3.67__tar.gz → 0.3.70__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 (137) hide show
  1. {dirsql-0.3.67 → dirsql-0.3.70}/Cargo.lock +1 -1
  2. {dirsql-0.3.67 → dirsql-0.3.70}/PKG-INFO +1 -9
  3. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/src/lib.rs +134 -18
  6. dirsql-0.3.70/packages/rust/src/cli/execute.rs +314 -0
  7. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/cli/mod.rs +4 -1
  8. dirsql-0.3.70/packages/rust/src/cli/router.rs +191 -0
  9. {dirsql-0.3.67 → dirsql-0.3.70}/pyproject.toml +7 -1
  10. dirsql-0.3.67/packages/rust/src/cli/router.rs +0 -393
  11. {dirsql-0.3.67 → dirsql-0.3.70}/Cargo.toml +0 -0
  12. {dirsql-0.3.67 → dirsql-0.3.70}/README.md +0 -0
  13. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/__init__.py +0 -0
  14. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/_async.py +0 -0
  15. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/_dirsql.pyi +0 -0
  16. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/cli/__init__.py +0 -0
  17. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/cli/binary_path.py +0 -0
  18. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/cli/is_windows.py +0 -0
  19. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/cli/main.py +0 -0
  20. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/cli/resolve_config_extensions.py +0 -0
  21. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/py.typed +0 -0
  22. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/resolve_config_extensions.py +0 -0
  23. {dirsql-0.3.67 → dirsql-0.3.70}/dirsql/resolve_extension.py +0 -0
  24. {dirsql-0.3.67 → dirsql-0.3.70}/docs/.claude/CLAUDE.md +0 -0
  25. {dirsql-0.3.67 → dirsql-0.3.70}/docs/.vitepress/config.ts +0 -0
  26. {dirsql-0.3.67 → dirsql-0.3.70}/docs/.vitepress/theme/index.ts +0 -0
  27. {dirsql-0.3.67 → dirsql-0.3.70}/docs/.vitepress/theme/lang.ts +0 -0
  28. {dirsql-0.3.67 → dirsql-0.3.70}/docs/AGENTS.md +0 -0
  29. {dirsql-0.3.67 → dirsql-0.3.70}/docs/explanation.md +0 -0
  30. {dirsql-0.3.67 → dirsql-0.3.70}/docs/getting-started.md +0 -0
  31. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/columns-from-paths.md +0 -0
  32. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/define-tables.md +0 -0
  33. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/embed.md +0 -0
  34. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/extract-from-contents.md +0 -0
  35. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/load-extension.md +0 -0
  36. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/persist.md +0 -0
  37. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/react-to-changes.md +0 -0
  38. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/search-by-meaning.md +0 -0
  39. {dirsql-0.3.67 → dirsql-0.3.70}/docs/howto/skip-files.md +0 -0
  40. {dirsql-0.3.67 → dirsql-0.3.70}/docs/index.md +0 -0
  41. {dirsql-0.3.67 → dirsql-0.3.70}/docs/migrations.md +0 -0
  42. {dirsql-0.3.67 → dirsql-0.3.70}/docs/package.json +0 -0
  43. {dirsql-0.3.67 → dirsql-0.3.70}/docs/playwright.config.ts +0 -0
  44. {dirsql-0.3.67 → dirsql-0.3.70}/docs/pnpm-lock.yaml +0 -0
  45. {dirsql-0.3.67 → dirsql-0.3.70}/docs/pnpm-workspace.yaml +0 -0
  46. {dirsql-0.3.67 → dirsql-0.3.70}/docs/reference/cli.md +0 -0
  47. {dirsql-0.3.67 → dirsql-0.3.70}/docs/reference/columns.md +0 -0
  48. {dirsql-0.3.67 → dirsql-0.3.70}/docs/reference/config.md +0 -0
  49. {dirsql-0.3.67 → dirsql-0.3.70}/docs/reference/hooks.md +0 -0
  50. {dirsql-0.3.67 → dirsql-0.3.70}/docs/reference/http-api.md +0 -0
  51. {dirsql-0.3.67 → dirsql-0.3.70}/docs/reference/sdk.md +0 -0
  52. {dirsql-0.3.67 → dirsql-0.3.70}/docs/tests/integration/home.spec.ts +0 -0
  53. {dirsql-0.3.67 → dirsql-0.3.70}/docs/tests/integration/language-flag.spec.ts +0 -0
  54. {dirsql-0.3.67 → dirsql-0.3.70}/docs/tests/integration/sidebar.spec.ts +0 -0
  55. {dirsql-0.3.67 → dirsql-0.3.70}/docs/tests/unit/config.test.ts +0 -0
  56. {dirsql-0.3.67 → dirsql-0.3.70}/docs/tests/unit/lang.test.ts +0 -0
  57. {dirsql-0.3.67 → dirsql-0.3.70}/docs/vitest.config.ts +0 -0
  58. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/README.md +0 -0
  59. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/conftest.py +0 -0
  60. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/.claude/CLAUDE.md +0 -0
  61. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/.vitepress/config.ts +0 -0
  62. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  63. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  64. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/AGENTS.md +0 -0
  65. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/explanation.md +0 -0
  66. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/getting-started.md +0 -0
  67. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/columns-from-paths.md +0 -0
  68. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/define-tables.md +0 -0
  69. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/embed.md +0 -0
  70. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/extract-from-contents.md +0 -0
  71. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/load-extension.md +0 -0
  72. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/persist.md +0 -0
  73. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/react-to-changes.md +0 -0
  74. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/search-by-meaning.md +0 -0
  75. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/howto/skip-files.md +0 -0
  76. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/index.md +0 -0
  77. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/migrations.md +0 -0
  78. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/package.json +0 -0
  79. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/playwright.config.ts +0 -0
  80. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/pnpm-lock.yaml +0 -0
  81. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/pnpm-workspace.yaml +0 -0
  82. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/reference/cli.md +0 -0
  83. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/reference/columns.md +0 -0
  84. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/reference/config.md +0 -0
  85. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/reference/hooks.md +0 -0
  86. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/reference/http-api.md +0 -0
  87. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/reference/sdk.md +0 -0
  88. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  89. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  90. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  91. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/tests/unit/config.test.ts +0 -0
  92. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  93. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/docs/vitest.config.ts +0 -0
  94. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/tests/__init__.py +0 -0
  95. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/tests/binding/__init__.py +0 -0
  96. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/tests/conftest.py +0 -0
  97. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/tests/e2e/__init__.py +0 -0
  98. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/tests/integration/__init__.py +0 -0
  99. {dirsql-0.3.67 → dirsql-0.3.70}/packages/python/tests/smoke/__init__.py +0 -0
  100. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/Cargo.toml +0 -0
  101. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/README.md +0 -0
  102. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/benches/db_bench.rs +0 -0
  103. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/benches/differ_bench.rs +0 -0
  104. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/benches/matcher_bench.rs +0 -0
  105. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/benches/scanner_bench.rs +0 -0
  106. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/explanation.md +0 -0
  107. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/getting-started.md +0 -0
  108. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/columns-from-paths.md +0 -0
  109. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/define-tables.md +0 -0
  110. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/embed.md +0 -0
  111. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/extract-from-contents.md +0 -0
  112. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/load-extension.md +0 -0
  113. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/persist.md +0 -0
  114. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/react-to-changes.md +0 -0
  115. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/search-by-meaning.md +0 -0
  116. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/howto/skip-files.md +0 -0
  117. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/index.md +0 -0
  118. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/migrations.md +0 -0
  119. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/reference/cli.md +0 -0
  120. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/reference/columns.md +0 -0
  121. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/reference/config.md +0 -0
  122. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/reference/hooks.md +0 -0
  123. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/reference/http-api.md +0 -0
  124. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/docs/reference/sdk.md +0 -0
  125. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/bin/dirsql.rs +0 -0
  126. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/cli/init.rs +0 -0
  127. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/cli/serialize.rs +0 -0
  128. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/cli/server.rs +0 -0
  129. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/command.rs +0 -0
  130. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/config.rs +0 -0
  131. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/db.rs +0 -0
  132. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/differ.rs +0 -0
  133. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/lib.rs +0 -0
  134. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/matcher.rs +0 -0
  135. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/persist.rs +0 -0
  136. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/scanner.rs +0 -0
  137. {dirsql-0.3.67 → dirsql-0.3.70}/packages/rust/src/watcher.rs +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.67"
503
+ version = "0.3.70"
504
504
  dependencies = [
505
505
  "dirsql",
506
506
  "pyo3",
@@ -1,14 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.67
4
- Requires-Dist: pytest>=8 ; extra == 'dev'
5
- Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
- Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
7
- Requires-Dist: pytest-cov>=5 ; extra == 'dev'
8
- Requires-Dist: ruff>=0.4 ; extra == 'dev'
9
- Requires-Dist: maturin>=1.0 ; extra == 'dev'
10
- Requires-Dist: ty==0.0.42 ; extra == 'dev'
11
- Provides-Extra: dev
3
+ Version: 0.3.70
12
4
  Summary: Ephemeral SQL index over a local directory
13
5
  Keywords: sql,filesystem,directory,sqlite,index
14
6
  Author: Kevin Scott
@@ -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.67"
7
+ version = "0.3.70"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "command": "uv run python -m pytest tests/e2e/ -x -q",
3
- "ran_at": 1783215074,
3
+ "ran_at": 1783387386,
4
4
  "exit_code": 0,
5
- "commit": "c5d752734907f27497b28b3448bad19247c3482e"
5
+ "commit": "a9cf031533c564bf43c77d893745f9e85fed059f"
6
6
  }
@@ -257,57 +257,93 @@ mod python {
257
257
  PyRuntimeError::new_err(e.to_string())
258
258
  }
259
259
 
260
- fn row_event_to_py(py: Python<'_>, event: &RowEvent) -> PyResult<PyRowEvent> {
261
- Ok(match event {
260
+ /// Pure, GIL-free intermediate for a row event. [`row_event_to_plain`]
261
+ /// builds it from a core [`RowEvent`] (unit-testable without a Python
262
+ /// interpreter); [`row_event_to_py`] then marshals it into the
263
+ /// Python-facing [`PyRowEvent`] (the GIL step). Splitting the two keeps the
264
+ /// variant -> action / field-selection mapping testable at the unit tier,
265
+ /// mirroring the napi binding's pure `row_event_to_js`. The value-level
266
+ /// `Row -> PyDict` conversion stays GIL-bound (binding-tier covered).
267
+ struct PlainRowEvent {
268
+ table: Option<String>,
269
+ action: &'static str,
270
+ row: Option<Row>,
271
+ old_row: Option<Row>,
272
+ error: Option<String>,
273
+ file_path: String,
274
+ }
275
+
276
+ fn row_event_to_plain(event: &RowEvent) -> PlainRowEvent {
277
+ match event {
262
278
  RowEvent::Insert {
263
279
  table,
264
280
  row,
265
281
  file_path,
266
- } => PyRowEvent {
282
+ } => PlainRowEvent {
267
283
  table: Some(table.clone()),
268
- action: "insert".to_string(),
269
- row: Some(value_row_to_py_dict(py, row)?),
284
+ action: "insert",
285
+ row: Some(row.clone()),
270
286
  old_row: None,
271
287
  error: None,
272
- file_path: Some(file_path.clone()),
288
+ file_path: file_path.clone(),
273
289
  },
274
290
  RowEvent::Update {
275
291
  table,
276
292
  old_row,
277
293
  new_row,
278
294
  file_path,
279
- } => PyRowEvent {
295
+ } => PlainRowEvent {
280
296
  table: Some(table.clone()),
281
- action: "update".to_string(),
282
- row: Some(value_row_to_py_dict(py, new_row)?),
283
- old_row: Some(value_row_to_py_dict(py, old_row)?),
297
+ action: "update",
298
+ row: Some(new_row.clone()),
299
+ old_row: Some(old_row.clone()),
284
300
  error: None,
285
- file_path: Some(file_path.clone()),
301
+ file_path: file_path.clone(),
286
302
  },
287
303
  RowEvent::Delete {
288
304
  table,
289
305
  row,
290
306
  file_path,
291
- } => PyRowEvent {
307
+ } => PlainRowEvent {
292
308
  table: Some(table.clone()),
293
- action: "delete".to_string(),
294
- row: Some(value_row_to_py_dict(py, row)?),
309
+ action: "delete",
310
+ row: Some(row.clone()),
295
311
  old_row: None,
296
312
  error: None,
297
- file_path: Some(file_path.clone()),
313
+ file_path: file_path.clone(),
298
314
  },
299
315
  RowEvent::Error {
300
316
  table,
301
317
  file_path,
302
318
  error,
303
- } => PyRowEvent {
319
+ } => PlainRowEvent {
304
320
  table: table.clone(),
305
- action: "error".to_string(),
321
+ action: "error",
306
322
  row: None,
307
323
  old_row: None,
308
324
  error: Some(error.clone()),
309
- file_path: Some(file_path.to_string_lossy().to_string()),
325
+ file_path: file_path.to_string_lossy().to_string(),
310
326
  },
327
+ }
328
+ }
329
+
330
+ fn row_event_to_py(py: Python<'_>, event: &RowEvent) -> PyResult<PyRowEvent> {
331
+ let plain = row_event_to_plain(event);
332
+ Ok(PyRowEvent {
333
+ table: plain.table,
334
+ action: plain.action.to_string(),
335
+ row: plain
336
+ .row
337
+ .as_ref()
338
+ .map(|r| value_row_to_py_dict(py, r))
339
+ .transpose()?,
340
+ old_row: plain
341
+ .old_row
342
+ .as_ref()
343
+ .map(|r| value_row_to_py_dict(py, r))
344
+ .transpose()?,
345
+ error: plain.error,
346
+ file_path: Some(plain.file_path),
311
347
  })
312
348
  }
313
349
 
@@ -381,4 +417,84 @@ mod python {
381
417
  m.add_class::<PyRowEvent>()?;
382
418
  Ok(())
383
419
  }
420
+
421
+ #[cfg(test)]
422
+ mod tests {
423
+ use super::*;
424
+
425
+ fn one_row() -> HashMap<String, Value> {
426
+ HashMap::from([("k".to_string(), Value::Integer(7))])
427
+ }
428
+
429
+ #[test]
430
+ fn plain_insert_maps_row_and_action() {
431
+ let p = row_event_to_plain(&RowEvent::Insert {
432
+ table: "t".into(),
433
+ row: one_row(),
434
+ file_path: "/f".into(),
435
+ });
436
+ assert_eq!(p.action, "insert");
437
+ assert_eq!(p.table.as_deref(), Some("t"));
438
+ assert_eq!(
439
+ p.row.as_ref().and_then(|r| r.get("k")),
440
+ Some(&Value::Integer(7))
441
+ );
442
+ assert!(p.old_row.is_none());
443
+ assert!(p.error.is_none());
444
+ assert_eq!(p.file_path, "/f");
445
+ }
446
+
447
+ #[test]
448
+ fn plain_update_carries_old_and_new() {
449
+ let mut new = one_row();
450
+ new.insert("k".to_string(), Value::Integer(9));
451
+ let p = row_event_to_plain(&RowEvent::Update {
452
+ table: "t".into(),
453
+ old_row: one_row(),
454
+ new_row: new,
455
+ file_path: "/f".into(),
456
+ });
457
+ assert_eq!(p.action, "update");
458
+ assert_eq!(
459
+ p.row.as_ref().and_then(|r| r.get("k")),
460
+ Some(&Value::Integer(9))
461
+ );
462
+ assert_eq!(
463
+ p.old_row.as_ref().and_then(|r| r.get("k")),
464
+ Some(&Value::Integer(7))
465
+ );
466
+ }
467
+
468
+ #[test]
469
+ fn plain_delete_has_row_no_old() {
470
+ let p = row_event_to_plain(&RowEvent::Delete {
471
+ table: "t".into(),
472
+ row: one_row(),
473
+ file_path: "/f".into(),
474
+ });
475
+ assert_eq!(p.action, "delete");
476
+ assert!(p.row.is_some());
477
+ assert!(p.old_row.is_none());
478
+ }
479
+
480
+ #[test]
481
+ fn plain_error_has_no_row_and_optional_table() {
482
+ let p = row_event_to_plain(&RowEvent::Error {
483
+ table: None,
484
+ file_path: std::path::PathBuf::from("/f"),
485
+ error: "boom".into(),
486
+ });
487
+ assert_eq!(p.action, "error");
488
+ assert!(p.table.is_none());
489
+ assert!(p.row.is_none());
490
+ assert!(p.old_row.is_none());
491
+ assert_eq!(p.error.as_deref(), Some("boom"));
492
+ assert_eq!(p.file_path, "/f");
493
+ }
494
+
495
+ #[test]
496
+ fn extract_error_displays_inner() {
497
+ assert_eq!(ExtractError("bad".to_string()).to_string(), "bad");
498
+ }
499
+ }
384
500
  }
@@ -0,0 +1,314 @@
1
+ //! Transport-agnostic `/query` pipeline.
2
+ //!
3
+ //! The full orchestration for running one query — intake validation,
4
+ //! the `pre-query` hook, the query timeout, [`DirSQL::query`], row
5
+ //! serialization, the `post-query` hook, and error classification —
6
+ //! lives here exactly once. The HTTP handler and the one-shot
7
+ //! `dirsql query` subcommand (#399) are thin transport adapters over
8
+ //! [`execute_query`], so the two surfaces cannot drift behaviorally:
9
+ //! per-surface code only maps [`QueryFailure`] to a status code or an
10
+ //! exit code.
11
+
12
+ use std::time::Duration;
13
+
14
+ use serde::Deserialize;
15
+ use serde_json::Value;
16
+
17
+ use super::serialize::rows_to_json;
18
+ use super::{AppState, PostQuery, PreQuery};
19
+ use crate::command::{Placeholder, run_command};
20
+ use crate::{DirSQL, DirSqlError};
21
+
22
+ /// Cap on the serialized result payload passed as the `{args}` argv token.
23
+ /// Beyond this, `{args}` is emptied and the operator is directed to stdin
24
+ /// (which always carries the full payload) — comfortably under Linux's 128 KiB
25
+ /// single-arg `MAX_ARG_STRLEN`.
26
+ const POST_QUERY_ARGS_MAX: usize = 96 * 1024;
27
+
28
+ /// Why a query failed, classified independently of transport. The HTTP
29
+ /// adapter maps each arm to a status code (400 / 408 / 500 / 503); the CLI
30
+ /// adapter maps every arm to stderr + a non-zero exit.
31
+ #[derive(Debug)]
32
+ pub enum QueryFailure {
33
+ /// Malformed input the caller can fix: unparsable body, missing/empty
34
+ /// `sql`, or a SQL error from the core (HTTP 400).
35
+ BadRequest(String),
36
+ /// The query exceeded the configured timeout (HTTP 408).
37
+ Timeout(Duration),
38
+ /// A server-side fault: hook failure, join error, lock poisoning
39
+ /// (HTTP 500).
40
+ Internal(String),
41
+ /// The index never became ready — the degraded config state (HTTP 503).
42
+ Unavailable(String),
43
+ }
44
+
45
+ impl QueryFailure {
46
+ /// The diagnostic message, identical across transports (the HTTP
47
+ /// `{"error": …}` body and the CLI stderr line).
48
+ pub fn message(&self) -> String {
49
+ match self {
50
+ Self::Timeout(timeout) => format!("query exceeded {timeout:?} timeout"),
51
+ Self::BadRequest(msg) | Self::Internal(msg) | Self::Unavailable(msg) => msg.clone(),
52
+ }
53
+ }
54
+ }
55
+
56
+ #[derive(Debug, Deserialize)]
57
+ struct QueryBody {
58
+ sql: Option<String>,
59
+ }
60
+
61
+ /// Run one query end to end: resolve the SQL from `raw_body` (through the
62
+ /// `pre-query` hook when present, else parsed as `{"sql": …}`), execute it
63
+ /// against the index under `timeout`, serialize the rows, and reshape them
64
+ /// through the `post-query` hook when present. `raw_body` is the exact
65
+ /// payload a `POST /query` request carries; the CLI adapter synthesizes the
66
+ /// same shape so both surfaces share intake validation and hook semantics.
67
+ pub async fn execute_query(
68
+ state: &AppState,
69
+ raw_body: String,
70
+ timeout: Duration,
71
+ pre_query: Option<&PreQuery>,
72
+ post_query: Option<&PostQuery>,
73
+ ) -> Result<Value, QueryFailure> {
74
+ // Resolve the SQL to run. With a `pre-query` hook the raw body is
75
+ // rewritten by the command; without one it is parsed as `{"sql": …}`.
76
+ let sql = match pre_query {
77
+ Some(pq) => run_pre_query(pq, raw_body).await?,
78
+ None => parse_sql_body(&raw_body)?,
79
+ };
80
+
81
+ let db = require_ready(state)?;
82
+
83
+ let join =
84
+ tokio::time::timeout(timeout, tokio::task::spawn_blocking(move || db.query(&sql))).await;
85
+
86
+ match join {
87
+ Ok(Ok(Ok(rows))) => {
88
+ let rows_json = rows_to_json(&rows);
89
+ match post_query {
90
+ Some(pq) => run_post_query(pq, rows_json).await,
91
+ None => Ok(Value::Array(rows_json)),
92
+ }
93
+ }
94
+ Ok(Ok(Err(err))) => Err(classify_query_error(err)),
95
+ Ok(Err(join_err)) => Err(QueryFailure::Internal(join_err.to_string())),
96
+ Err(_elapsed) => Err(QueryFailure::Timeout(timeout)),
97
+ }
98
+ }
99
+
100
+ /// Return a cloned [`DirSQL`] handle, or [`QueryFailure::Unavailable`] if
101
+ /// the index started in the degraded [`AppState::Unavailable`] state.
102
+ pub fn require_ready(state: &AppState) -> Result<DirSQL, QueryFailure> {
103
+ match state {
104
+ AppState::Ready(db) => Ok(db.clone()),
105
+ AppState::Unavailable(reason) => Err(QueryFailure::Unavailable(reason.clone())),
106
+ }
107
+ }
108
+
109
+ /// Parse a raw query body as `{"sql": …}` and return the trimmed SQL.
110
+ /// `BadRequest` on malformed JSON, and on a missing/empty `sql` field.
111
+ fn parse_sql_body(body: &str) -> Result<String, QueryFailure> {
112
+ let parsed: QueryBody =
113
+ serde_json::from_str(body).map_err(|err| QueryFailure::BadRequest(err.to_string()))?;
114
+ match parsed.sql.as_deref().map(str::trim) {
115
+ Some(s) if !s.is_empty() => Ok(s.to_string()),
116
+ Some(_) => Err(QueryFailure::BadRequest("`sql` must not be empty".into())),
117
+ None => Err(QueryFailure::BadRequest("missing `sql` field".into())),
118
+ }
119
+ }
120
+
121
+ /// SQL errors from the core are the caller's to fix (`BadRequest`); every
122
+ /// other failure (lock poisoning, watch/config faults) is server-side
123
+ /// (`Internal`).
124
+ fn classify_query_error(err: DirSqlError) -> QueryFailure {
125
+ match err {
126
+ DirSqlError::Core(_) => QueryFailure::BadRequest(err.to_string()),
127
+ _ => QueryFailure::Internal(err.to_string()),
128
+ }
129
+ }
130
+
131
+ /// Run the `pre-query` hook over the raw request body and return the SQL it
132
+ /// prints. The body is passed as the injection-safe `{args}` placeholder (a
133
+ /// single argv token); the command's last non-empty stdout line is the SQL to
134
+ /// run. Any failure (non-zero exit, timeout, spawn error) maps to `Internal`
135
+ /// carrying the command's stderr tail.
136
+ async fn run_pre_query(pq: &PreQuery, raw_body: String) -> Result<String, QueryFailure> {
137
+ let command = pq.command.clone();
138
+ let config_dir = pq.config_dir.clone();
139
+ let timeout = pq.timeout;
140
+ // `run_command` is blocking — it spawns a child and joins drain threads —
141
+ // so run it off the async runtime. It enforces the hook's timeout
142
+ // (the global `[dirsql].hook-timeout`, default 30s) internally, so no outer
143
+ // `tokio::time::timeout` is needed.
144
+ let outcome = tokio::task::spawn_blocking(move || {
145
+ run_command(
146
+ &command,
147
+ &[Placeholder::new("args", &raw_body)],
148
+ &config_dir,
149
+ timeout,
150
+ None,
151
+ )
152
+ })
153
+ .await
154
+ .map_err(|join_err| QueryFailure::Internal(join_err.to_string()))?;
155
+
156
+ // `run_command` only returns `Ok` with a non-empty last stdout line
157
+ // (`EmptyOutput` otherwise), so the payload is the SQL as-is.
158
+ outcome
159
+ .map(|out| out.payload)
160
+ .map_err(|err| QueryFailure::Internal(err.to_string()))
161
+ }
162
+
163
+ /// Run the `post-query` hook over a successful result set and return the JSON
164
+ /// body it prints. The rows are serialized to a JSON array and delivered two
165
+ /// ways: always on the child's stdin (unbounded, injection-safe), and as the
166
+ /// `{args}` placeholder when the payload is within [`POST_QUERY_ARGS_MAX`]
167
+ /// (beyond that `{args}` is emptied and a warning names the size, directing
168
+ /// the operator to stdin — never silent truncation). The command's last
169
+ /// non-empty stdout line is parsed as JSON and returned as the result;
170
+ /// anything that isn't valid JSON, or any failure (non-zero exit, timeout,
171
+ /// spawn error), maps to `Internal`.
172
+ async fn run_post_query(pq: &PostQuery, rows: Vec<Value>) -> Result<Value, QueryFailure> {
173
+ let payload =
174
+ serde_json::to_string(&rows).map_err(|err| QueryFailure::Internal(err.to_string()))?;
175
+ let command = pq.command.clone();
176
+ let config_dir = pq.config_dir.clone();
177
+ let timeout = pq.timeout;
178
+ // `run_command` is blocking — it spawns a child and joins drain threads —
179
+ // so run it off the async runtime. It enforces the hook's timeout
180
+ // (the global `[dirsql].hook-timeout`, default 30s) internally, so no outer
181
+ // `tokio::time::timeout` is needed.
182
+ let outcome = tokio::task::spawn_blocking(move || {
183
+ let args_value = if payload.len() <= POST_QUERY_ARGS_MAX {
184
+ payload.clone()
185
+ } else {
186
+ eprintln!(
187
+ "dirsql: post-query result payload is {} bytes, exceeding the \
188
+ {POST_QUERY_ARGS_MAX}-byte argv threshold; `{{args}}` is emptied — \
189
+ read the rows from stdin instead",
190
+ payload.len()
191
+ );
192
+ String::new()
193
+ };
194
+ run_command(
195
+ &command,
196
+ &[Placeholder::new("args", &args_value)],
197
+ &config_dir,
198
+ timeout,
199
+ Some(payload.as_bytes()),
200
+ )
201
+ })
202
+ .await
203
+ .map_err(|join_err| QueryFailure::Internal(join_err.to_string()))?;
204
+
205
+ let out = outcome.map_err(|err| QueryFailure::Internal(err.to_string()))?;
206
+
207
+ // The command's payload (last non-empty stdout line) is the JSON result
208
+ // body; reject anything that doesn't parse as JSON.
209
+ serde_json::from_str(&out.payload).map_err(|err| {
210
+ QueryFailure::Internal(format!("post-query did not return valid JSON: {err}"))
211
+ })
212
+ }
213
+
214
+ #[cfg(test)]
215
+ mod tests {
216
+ use super::*;
217
+
218
+ // The `DirSqlError::Core => BadRequest` arm of `classify_query_error` is
219
+ // exercised end-to-end at the integration tier by
220
+ // `post_query_malformed_sql_returns_400_not_500` in
221
+ // `tests/cli_integration.rs`, which posts malformed SQL to `/query` and
222
+ // asserts the 400. Constructing a `Core` value inline would require
223
+ // importing the first-party `crate::db::DbError`, which the
224
+ // `testing-conventions` `unit lint` isolation rule forbids (a unit test
225
+ // may reach only `super::` and pure `std`). The non-Core arm below is
226
+ // pure -- it builds a `super::DirSqlError::Lock` -- so it stays inline.
227
+
228
+ #[test]
229
+ fn classify_non_core_error_is_internal() {
230
+ // Lock/watch/config failures are server-side faults -> `Internal`.
231
+ // This drives the `_ =>` arm of `classify_query_error`.
232
+ let err = DirSqlError::Lock("poisoned".into());
233
+ let failure = classify_query_error(err);
234
+ assert!(
235
+ matches!(failure, QueryFailure::Internal(_)),
236
+ "got: {failure:?}"
237
+ );
238
+ }
239
+
240
+ #[test]
241
+ fn require_ready_fails_unavailable_when_degraded() {
242
+ // The degraded state yields `Unavailable` carrying the diagnostic
243
+ // verbatim instead of a `DirSQL` handle.
244
+ let state = AppState::Unavailable("config failed to load".into());
245
+ // `DirSQL` isn't `Debug`, so go through `.err()` (which drops the Ok
246
+ // value) rather than `expect_err`.
247
+ let failure = require_ready(&state)
248
+ .err()
249
+ .expect("Unavailable must not yield a db");
250
+ match failure {
251
+ QueryFailure::Unavailable(reason) => assert_eq!(reason, "config failed to load"),
252
+ other => panic!("expected Unavailable, got: {other:?}"),
253
+ }
254
+ }
255
+
256
+ // `parse_sql_body` is the no-`pre-query` intake path: it is pure (serde
257
+ // only), so it is unit-tested here directly rather than through the async
258
+ // `execute_query` pipeline (which needs a live index and is covered at
259
+ // the integration tier).
260
+
261
+ #[test]
262
+ fn parse_sql_body_returns_trimmed_sql() {
263
+ // Surrounding whitespace is stripped; the inner SQL is returned as-is.
264
+ let sql = parse_sql_body(r#"{"sql": " SELECT 1 "}"#).expect("valid body");
265
+ assert_eq!(sql, "SELECT 1");
266
+ }
267
+
268
+ #[test]
269
+ fn parse_sql_body_rejects_malformed_json() {
270
+ // A body that isn't JSON fails at the serde step -> `BadRequest`.
271
+ let failure = parse_sql_body("not json").expect_err("malformed JSON must be rejected");
272
+ assert!(
273
+ matches!(failure, QueryFailure::BadRequest(_)),
274
+ "got: {failure:?}"
275
+ );
276
+ }
277
+
278
+ #[test]
279
+ fn parse_sql_body_rejects_whitespace_only_sql() {
280
+ // A present-but-blank `sql` trims to empty -> `BadRequest` (the
281
+ // `Some(_)` arm, and the `false` side of the `!s.is_empty()` guard).
282
+ let failure = parse_sql_body(r#"{"sql": " "}"#).expect_err("empty sql must be rejected");
283
+ match failure {
284
+ QueryFailure::BadRequest(msg) => assert_eq!(msg, "`sql` must not be empty"),
285
+ other => panic!("expected BadRequest, got: {other:?}"),
286
+ }
287
+ }
288
+
289
+ #[test]
290
+ fn parse_sql_body_rejects_missing_sql_field() {
291
+ // Valid JSON object with no `sql` key -> the `None` arm -> `BadRequest`.
292
+ let failure = parse_sql_body("{}").expect_err("missing sql must be rejected");
293
+ match failure {
294
+ QueryFailure::BadRequest(msg) => assert_eq!(msg, "missing `sql` field"),
295
+ other => panic!("expected BadRequest, got: {other:?}"),
296
+ }
297
+ }
298
+
299
+ #[test]
300
+ fn timeout_failure_message_names_the_duration() {
301
+ // The timeout arm formats its message from the stored duration —
302
+ // the exact text the HTTP 408 body carried before the extraction.
303
+ let failure = QueryFailure::Timeout(Duration::from_secs(30));
304
+ assert_eq!(failure.message(), "query exceeded 30s timeout");
305
+ }
306
+
307
+ #[test]
308
+ fn non_timeout_failure_messages_pass_through_verbatim() {
309
+ // The other three arms carry their diagnostic string as-is.
310
+ assert_eq!(QueryFailure::BadRequest("a".into()).message(), "a");
311
+ assert_eq!(QueryFailure::Internal("b".into()).message(), "b");
312
+ assert_eq!(QueryFailure::Unavailable("c".into()).message(), "c");
313
+ }
314
+ }
@@ -15,7 +15,9 @@
15
15
  //! submodule:
16
16
  //!
17
17
  //! - [`server`] — bind/serve/shutdown plumbing.
18
- //! - [`router`] — axum routes + request handlers.
18
+ //! - [`router`] — axum routes + request handlers (thin HTTP adapters).
19
+ //! - [`execute`] — the transport-agnostic query pipeline shared by the
20
+ //! HTTP handler and the one-shot `dirsql query` subcommand.
19
21
  //! - [`serialize`] — row + event → JSON.
20
22
 
21
23
  use std::net::SocketAddr;
@@ -29,6 +31,7 @@ use tokio::task::JoinHandle;
29
31
  use crate::DirSQL;
30
32
  use crate::command::DEFAULT_COMMAND_TIMEOUT;
31
33
 
34
+ pub mod execute;
32
35
  pub mod init;
33
36
  pub mod router;
34
37
  pub mod serialize;