dirsql 0.3.29__tar.gz → 0.3.30__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 (167) hide show
  1. {dirsql-0.3.29 → dirsql-0.3.30}/Cargo.lock +1 -1
  2. {dirsql-0.3.29 → dirsql-0.3.30}/PKG-INFO +1 -1
  3. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/lib.rs +192 -23
  5. {dirsql-0.3.29 → dirsql-0.3.30}/Cargo.toml +0 -0
  6. {dirsql-0.3.29 → dirsql-0.3.30}/README.md +0 -0
  7. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/__init__.py +0 -0
  8. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/_async.py +0 -0
  9. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/_async_test.py +0 -0
  10. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/_dirsql.pyi +0 -0
  11. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/__init__.py +0 -0
  12. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/binary_path.py +0 -0
  13. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/binary_path_test.py +0 -0
  14. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/__init__.py +0 -0
  15. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/dispatch_extract.py +0 -0
  16. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/dispatch_extract_test.py +0 -0
  17. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/load_app.py +0 -0
  18. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/load_app_test.py +0 -0
  19. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/run.py +0 -0
  20. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/run_test.py +0 -0
  21. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/write_message.py +0 -0
  22. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/interpret/write_message_test.py +0 -0
  23. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/is_windows.py +0 -0
  24. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/is_windows_test.py +0 -0
  25. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/main.py +0 -0
  26. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/cli/main_test.py +0 -0
  27. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/py.typed +0 -0
  28. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/resolve_config.py +0 -0
  29. {dirsql-0.3.29 → dirsql-0.3.30}/dirsql/resolve_config_test.py +0 -0
  30. {dirsql-0.3.29 → dirsql-0.3.30}/docs/.claude/CLAUDE.md +0 -0
  31. {dirsql-0.3.29 → dirsql-0.3.30}/docs/.vitepress/config.ts +0 -0
  32. {dirsql-0.3.29 → dirsql-0.3.30}/docs/.vitepress/theme/index.ts +0 -0
  33. {dirsql-0.3.29 → dirsql-0.3.30}/docs/.vitepress/theme/lang.ts +0 -0
  34. {dirsql-0.3.29 → dirsql-0.3.30}/docs/AGENTS.md +0 -0
  35. {dirsql-0.3.29 → dirsql-0.3.30}/docs/api/index.md +0 -0
  36. {dirsql-0.3.29 → dirsql-0.3.30}/docs/cli/config.md +0 -0
  37. {dirsql-0.3.29 → dirsql-0.3.30}/docs/cli/http-api.md +0 -0
  38. {dirsql-0.3.29 → dirsql-0.3.30}/docs/cli/index.md +0 -0
  39. {dirsql-0.3.29 → dirsql-0.3.30}/docs/cli/init.md +0 -0
  40. {dirsql-0.3.29 → dirsql-0.3.30}/docs/cli/server.md +0 -0
  41. {dirsql-0.3.29 → dirsql-0.3.30}/docs/getting-started.md +0 -0
  42. {dirsql-0.3.29 → dirsql-0.3.30}/docs/guide/async.md +0 -0
  43. {dirsql-0.3.29 → dirsql-0.3.30}/docs/guide/crdt.md +0 -0
  44. {dirsql-0.3.29 → dirsql-0.3.30}/docs/guide/persistence.md +0 -0
  45. {dirsql-0.3.29 → dirsql-0.3.30}/docs/guide/querying.md +0 -0
  46. {dirsql-0.3.29 → dirsql-0.3.30}/docs/guide/tables.md +0 -0
  47. {dirsql-0.3.29 → dirsql-0.3.30}/docs/guide/watching.md +0 -0
  48. {dirsql-0.3.29 → dirsql-0.3.30}/docs/index.md +0 -0
  49. {dirsql-0.3.29 → dirsql-0.3.30}/docs/migrations.md +0 -0
  50. {dirsql-0.3.29 → dirsql-0.3.30}/docs/package.json +0 -0
  51. {dirsql-0.3.29 → dirsql-0.3.30}/docs/playwright.config.ts +0 -0
  52. {dirsql-0.3.29 → dirsql-0.3.30}/docs/pnpm-lock.yaml +0 -0
  53. {dirsql-0.3.29 → dirsql-0.3.30}/docs/pnpm-workspace.yaml +0 -0
  54. {dirsql-0.3.29 → dirsql-0.3.30}/docs/tests/integration/home.spec.ts +0 -0
  55. {dirsql-0.3.29 → dirsql-0.3.30}/docs/tests/integration/language-flag.spec.ts +0 -0
  56. {dirsql-0.3.29 → dirsql-0.3.30}/docs/tests/unit/config.test.ts +0 -0
  57. {dirsql-0.3.29 → dirsql-0.3.30}/docs/tests/unit/lang.test.ts +0 -0
  58. {dirsql-0.3.29 → dirsql-0.3.30}/docs/vitest.config.ts +0 -0
  59. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/README.md +0 -0
  60. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/conftest.py +0 -0
  61. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/.claude/CLAUDE.md +0 -0
  62. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/.vitepress/config.ts +0 -0
  63. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  64. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  65. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/AGENTS.md +0 -0
  66. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/api/index.md +0 -0
  67. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/cli/config.md +0 -0
  68. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/cli/http-api.md +0 -0
  69. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/cli/index.md +0 -0
  70. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/cli/init.md +0 -0
  71. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/cli/server.md +0 -0
  72. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/getting-started.md +0 -0
  73. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/guide/async.md +0 -0
  74. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/guide/crdt.md +0 -0
  75. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/guide/persistence.md +0 -0
  76. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/guide/querying.md +0 -0
  77. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/guide/tables.md +0 -0
  78. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/guide/watching.md +0 -0
  79. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/index.md +0 -0
  80. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/migrations.md +0 -0
  81. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/package.json +0 -0
  82. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/playwright.config.ts +0 -0
  83. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/pnpm-lock.yaml +0 -0
  84. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/pnpm-workspace.yaml +0 -0
  85. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  86. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  87. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/tests/unit/config.test.ts +0 -0
  88. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  89. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/docs/vitest.config.ts +0 -0
  90. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/src/lib.rs +0 -0
  91. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/__init__.py +0 -0
  92. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/conftest.py +0 -0
  93. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/e2e/__init__.py +0 -0
  94. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/data/a/meta.json +0 -0
  95. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/data/b/meta.json +0 -0
  96. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/dirsql.config.py +0 -0
  97. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/interpret/data/a/meta.json +0 -0
  98. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/interpret/data/b/meta.json +0 -0
  99. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config.py +0 -0
  100. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_no_app.py +0 -0
  101. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_raises.py +0 -0
  102. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/__init__.py +0 -0
  103. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/interpret_subprocess.py +0 -0
  104. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_async_dirsql.py +0 -0
  105. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_binding.py +0 -0
  106. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_dirsql.py +0 -0
  107. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_docs_examples.py +0 -0
  108. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_docs_gaps.py +0 -0
  109. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_from_config.py +0 -0
  110. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_interpret.py +0 -0
  111. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_native_config.py +0 -0
  112. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_persist.py +0 -0
  113. {dirsql-0.3.29 → dirsql-0.3.30}/packages/python/tests/integration/test_serialization.py +0 -0
  114. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/Cargo.toml +0 -0
  115. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/README.md +0 -0
  116. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/benches/db_bench.rs +0 -0
  117. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/benches/differ_bench.rs +0 -0
  118. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/benches/matcher_bench.rs +0 -0
  119. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/benches/scanner_bench.rs +0 -0
  120. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/api/index.md +0 -0
  121. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/cli/config.md +0 -0
  122. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/cli/http-api.md +0 -0
  123. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/cli/index.md +0 -0
  124. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/cli/init.md +0 -0
  125. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/cli/server.md +0 -0
  126. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/getting-started.md +0 -0
  127. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/guide/async.md +0 -0
  128. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/guide/crdt.md +0 -0
  129. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/guide/persistence.md +0 -0
  130. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/guide/querying.md +0 -0
  131. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/guide/tables.md +0 -0
  132. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/guide/watching.md +0 -0
  133. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/index.md +0 -0
  134. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/docs/migrations.md +0 -0
  135. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/bin/dirsql.rs +0 -0
  136. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/cli/init.rs +0 -0
  137. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/cli/mod.rs +0 -0
  138. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/cli/native_config.rs +0 -0
  139. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/cli/router.rs +0 -0
  140. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/cli/serialize.rs +0 -0
  141. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/cli/server.rs +0 -0
  142. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/config.rs +0 -0
  143. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/db.rs +0 -0
  144. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/differ.rs +0 -0
  145. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/matcher.rs +0 -0
  146. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/persist.rs +0 -0
  147. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/scanner.rs +0 -0
  148. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/src/watcher.rs +0 -0
  149. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/async_sdk.rs +0 -0
  150. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/cli_e2e.rs +0 -0
  151. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/cli_integration.rs +0 -0
  152. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/code_review_findings.rs +0 -0
  153. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/config.rs +0 -0
  154. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/docs_examples.rs +0 -0
  155. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/docs_gaps.rs +0 -0
  156. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/extensions.rs +0 -0
  157. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/from_config.rs +0 -0
  158. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/init_e2e.rs +0 -0
  159. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/init_integration.rs +0 -0
  160. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/persist.rs +0 -0
  161. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/readonly_query.rs +0 -0
  162. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/scanner.rs +0 -0
  163. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/sdk.rs +0 -0
  164. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/serialization.rs +0 -0
  165. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/watch_relative_root.rs +0 -0
  166. {dirsql-0.3.29 → dirsql-0.3.30}/packages/rust/tests/watcher.rs +0 -0
  167. {dirsql-0.3.29 → dirsql-0.3.30}/pyproject.toml +0 -0
@@ -499,7 +499,7 @@ dependencies = [
499
499
 
500
500
  [[package]]
501
501
  name = "dirsql-py-ext"
502
- version = "0.3.29"
502
+ version = "0.3.30"
503
503
  dependencies = [
504
504
  "dirsql",
505
505
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.29
3
+ Version: 0.3.30
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'
@@ -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.29"
7
+ version = "0.3.30"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -273,6 +273,10 @@ struct DirSqlInner {
273
273
  /// loop. Bounds event-to-stream latency from above (and idle CPU from
274
274
  /// below). Defaults to 200ms — see [`DirSQLBuilder::poll_interval`].
275
275
  poll_interval: Duration,
276
+ /// Filesystem seam used by the watch-upsert path. Always [`RealFs`] in
277
+ /// production; unit tests inject a deterministic double via the
278
+ /// `with_ignore_and_fs` test-seam constructor.
279
+ fs: Arc<dyn FileSystem>,
276
280
  }
277
281
 
278
282
  /// Serializable snapshot of a `DirSQL` instance's resolved runtime state.
@@ -580,7 +584,7 @@ impl DirSQL {
580
584
 
581
585
  fn handle_upsert(&self, table: &str, abs_path: &Path, rel_path: &str) -> Vec<RowEvent> {
582
586
  // The file may have vanished between the watcher event and now.
583
- match std::fs::metadata(abs_path) {
587
+ match self.inner.fs.stat(abs_path) {
584
588
  Ok(_) => {}
585
589
  Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
586
590
  Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
@@ -659,6 +663,36 @@ impl DirSQL {
659
663
  Self::finish_build(prepared)
660
664
  }
661
665
 
666
+ /// Test-seam build path: identical to [`build_from_resolved`] but stores
667
+ /// the supplied [`FileSystem`] double on the resulting instance so the
668
+ /// watch-upsert path's filesystem read can be faked deterministically.
669
+ /// The prepare phase still uses [`RealFs`] (it has no instance yet), but
670
+ /// the unit tests that exercise this seam build over an empty temp dir, so
671
+ /// the scan touches nothing.
672
+ #[cfg(test)]
673
+ pub(crate) fn with_ignore_and_fs<I, S>(
674
+ root: impl Into<PathBuf>,
675
+ tables: Vec<Table>,
676
+ ignore: I,
677
+ fs: Arc<dyn FileSystem>,
678
+ ) -> Result<Self>
679
+ where
680
+ I: IntoIterator<Item = S>,
681
+ S: Into<String>,
682
+ {
683
+ let resolved = ResolvedBuild {
684
+ root: root.into(),
685
+ tables,
686
+ ignore: ignore.into_iter().map(Into::into).collect(),
687
+ extensions: Vec::new(),
688
+ persist: false,
689
+ persist_path: None,
690
+ poll_interval: DEFAULT_POLL_INTERVAL,
691
+ };
692
+ let prepared = Self::prepare_resolved(resolved)?;
693
+ Self::finish_build_with_fs(prepared, fs)
694
+ }
695
+
662
696
  /// Split-phase construction — part 1. Performs all I/O that is safe to run
663
697
  /// off the host's main thread: validates DDL, compiles the matcher, walks
664
698
  /// the directory, opens the persistent cache (when enabled) and decides
@@ -713,7 +747,7 @@ impl DirSQL {
713
747
  }
714
748
  (files, Vec::new(), Vec::new())
715
749
  }
716
- Some(ctx) => reconcile_scan(&root, scanned, ctx)?,
750
+ Some(ctx) => reconcile_scan(&root, scanned, ctx, &RealFs)?,
717
751
  };
718
752
 
719
753
  let _ = table_names;
@@ -748,6 +782,17 @@ impl DirSQL {
748
782
  /// run. For the napi-rs binding that is the JS main thread.
749
783
  #[doc(hidden)]
750
784
  pub fn finish_build(prepared: PreparedBuild) -> Result<Self> {
785
+ Self::finish_build_with_fs(prepared, Arc::new(RealFs))
786
+ }
787
+
788
+ /// Test-seam variant of [`finish_build`] that takes the [`FileSystem`]
789
+ /// double to store on the instance. Production always passes
790
+ /// `Arc::new(RealFs)` (via [`finish_build`]); unit tests inject a fake so
791
+ /// the watch-upsert path's `stat` read is deterministic.
792
+ pub(crate) fn finish_build_with_fs(
793
+ prepared: PreparedBuild,
794
+ fs: Arc<dyn FileSystem>,
795
+ ) -> Result<Self> {
751
796
  let PreparedBuild {
752
797
  root,
753
798
  tables,
@@ -914,6 +959,7 @@ impl DirSQL {
914
959
  poll_used: AtomicBool::new(false),
915
960
  watch_thread_started: AtomicBool::new(false),
916
961
  poll_interval,
962
+ fs,
917
963
  }),
918
964
  })
919
965
  }
@@ -1316,6 +1362,33 @@ fn prepare_persist(
1316
1362
  })
1317
1363
  }
1318
1364
 
1365
+ /// Internal filesystem seam. Every effectful filesystem read performed by the
1366
+ /// persist/reconcile and watch-upsert paths goes through this trait so unit
1367
+ /// tests can inject a deterministic double (avoiding real `std::fs` calls and
1368
+ /// the racy timing windows they imply). Production always uses [`RealFs`],
1369
+ /// which replicates the previous inline `std::fs`/`hash_file` calls exactly --
1370
+ /// this is purely a test seam, not a behavioral change.
1371
+ trait FileSystem: Send + Sync {
1372
+ /// Stat a path. Mirrors `std::fs::metadata(path).map(|m| FileStat::from_metadata(&m))`.
1373
+ fn stat(&self, path: &Path) -> std::io::Result<FileStat>;
1374
+ /// BLAKE3-hash a file's contents. Mirrors [`hash_file`].
1375
+ fn hash(&self, path: &Path) -> std::io::Result<[u8; 32]>;
1376
+ }
1377
+
1378
+ /// Production [`FileSystem`]: delegates to the real `std::fs` / [`hash_file`]
1379
+ /// calls that the persist and watch paths used inline before the seam existed.
1380
+ struct RealFs;
1381
+
1382
+ impl FileSystem for RealFs {
1383
+ fn stat(&self, path: &Path) -> std::io::Result<FileStat> {
1384
+ std::fs::metadata(path).map(|m| FileStat::from_metadata(&m))
1385
+ }
1386
+
1387
+ fn hash(&self, path: &Path) -> std::io::Result<[u8; 32]> {
1388
+ hash_file(path)
1389
+ }
1390
+ }
1391
+
1319
1392
  /// Decide which files are trusted, which need re-parsing, and which were
1320
1393
  /// removed since the last cache write.
1321
1394
  #[allow(clippy::type_complexity)]
@@ -1323,6 +1396,7 @@ fn reconcile_scan(
1323
1396
  root: &Path,
1324
1397
  scanned: Vec<(PathBuf, String)>,
1325
1398
  ctx: &PersistContext,
1399
+ fs: &dyn FileSystem,
1326
1400
  ) -> Result<(Vec<ScannedFile>, Vec<TrustedFile>, Vec<(String, String)>)> {
1327
1401
  let mut to_parse = Vec::new();
1328
1402
  let mut trusted = Vec::new();
@@ -1333,8 +1407,7 @@ fn reconcile_scan(
1333
1407
  let rel_path = relative_path(root, &path);
1334
1408
  seen_paths.insert(rel_path.clone());
1335
1409
 
1336
- let metadata = std::fs::metadata(&path)?;
1337
- let stat = FileStat::from_metadata(&metadata);
1410
+ let stat = fs.stat(&path)?;
1338
1411
 
1339
1412
  let cached = ctx.cached.get(&rel_path);
1340
1413
  let trust = match cached {
@@ -1344,7 +1417,7 @@ fn reconcile_scan(
1344
1417
  true
1345
1418
  } else {
1346
1419
  // Hash-confirm.
1347
- match (hash_file(&path).ok(), c.content_hash) {
1420
+ match (fs.hash(&path).ok(), c.content_hash) {
1348
1421
  (Some(live), Some(cached_hash)) => live == cached_hash,
1349
1422
  _ => false,
1350
1423
  }
@@ -1808,8 +1881,60 @@ mod readonly_tests {
1808
1881
  #[cfg(test)]
1809
1882
  mod internal_tests {
1810
1883
  use super::*;
1884
+ use std::collections::HashMap as StdHashMap;
1811
1885
  use tempfile::TempDir;
1812
1886
 
1887
+ /// Deterministic [`FileSystem`] double for unit tests. Backed by a map of
1888
+ /// canned [`FileStat`]s (and an optional canned hash); any path not present
1889
+ /// stats/hashes as an `io::Error` of kind `NotFound`. Lets the tests of the
1890
+ /// persist/reconcile and watch-upsert paths exercise the metadata-read and
1891
+ /// racy-window branches without touching the real filesystem (and without
1892
+ /// depending on real mtime timing).
1893
+ #[derive(Default)]
1894
+ struct FakeFs {
1895
+ stats: StdHashMap<PathBuf, FileStat>,
1896
+ hashes: StdHashMap<PathBuf, [u8; 32]>,
1897
+ }
1898
+
1899
+ impl FakeFs {
1900
+ fn with_stat(path: impl Into<PathBuf>, stat: FileStat) -> Self {
1901
+ let mut fs = FakeFs::default();
1902
+ fs.stats.insert(path.into(), stat);
1903
+ fs
1904
+ }
1905
+
1906
+ fn set_hash(&mut self, path: impl Into<PathBuf>, hash: [u8; 32]) {
1907
+ self.hashes.insert(path.into(), hash);
1908
+ }
1909
+ }
1910
+
1911
+ impl FileSystem for FakeFs {
1912
+ fn stat(&self, path: &Path) -> std::io::Result<FileStat> {
1913
+ self.stats.get(path).cloned().ok_or_else(|| {
1914
+ std::io::Error::new(std::io::ErrorKind::NotFound, "fake: no such file")
1915
+ })
1916
+ }
1917
+
1918
+ fn hash(&self, path: &Path) -> std::io::Result<[u8; 32]> {
1919
+ self.hashes.get(path).copied().ok_or_else(|| {
1920
+ std::io::Error::new(std::io::ErrorKind::NotFound, "fake: no such file")
1921
+ })
1922
+ }
1923
+ }
1924
+
1925
+ /// A canned [`FileStat`] for unit tests that don't care about specific
1926
+ /// values, only that a stat succeeds. `snapshot_ns`-comparable via
1927
+ /// `mtime_ns`.
1928
+ fn fake_stat() -> FileStat {
1929
+ FileStat {
1930
+ size: 5,
1931
+ mtime_ns: 1_000,
1932
+ ctime_ns: 1_000,
1933
+ inode: 1,
1934
+ dev: 1,
1935
+ }
1936
+ }
1937
+
1813
1938
  /// A relative path with a basename, parent dir, and extension exercises the
1814
1939
  /// `Some` arms of `stat_virtuals`' path inspection, plus the `Some` arms of
1815
1940
  /// the size/mtime/ctime inserts. The metadata read that supplies those
@@ -1899,7 +2024,11 @@ mod internal_tests {
1899
2024
  #[test]
1900
2025
  fn process_file_event_skips_ignored_paths() {
1901
2026
  let dir = TempDir::new().unwrap();
1902
- let db = DirSQL::with_ignore(
2027
+ let kept = dir.path().join("keep.txt");
2028
+ // Inject a fake fs so the non-ignored path's stat read succeeds without
2029
+ // staging a real file. The ignored path is dropped before any stat.
2030
+ let fake = FakeFs::with_stat(kept.clone(), fake_stat());
2031
+ let db = DirSQL::with_ignore_and_fs(
1903
2032
  dir.path(),
1904
2033
  vec![Table::new(
1905
2034
  "CREATE TABLE items (name TEXT)",
@@ -1912,6 +2041,7 @@ mod internal_tests {
1912
2041
  },
1913
2042
  )],
1914
2043
  vec!["skip/**"],
2044
+ Arc::new(fake),
1915
2045
  )
1916
2046
  .unwrap();
1917
2047
 
@@ -1921,8 +2051,6 @@ mod internal_tests {
1921
2051
  assert!(events.is_empty(), "ignored path must produce no events");
1922
2052
 
1923
2053
  // Non-ignored path: the extract closure runs and yields one insert.
1924
- let kept = dir.path().join("keep.txt");
1925
- std::fs::write(&kept, b"").unwrap();
1926
2054
  let events = db.process_file_event(FileEvent::Created(kept));
1927
2055
  assert_eq!(events.len(), 1, "non-ignored path must produce one event");
1928
2056
  }
@@ -2099,10 +2227,12 @@ mod internal_tests {
2099
2227
  fn reconcile_scan_hash_confirms_in_racy_window() {
2100
2228
  let dir = TempDir::new().unwrap();
2101
2229
  let abs = dir.path().join("a.txt");
2102
- std::fs::write(&abs, b"payload").unwrap();
2103
- let meta = std::fs::metadata(&abs).unwrap();
2104
- let stat = FileStat::from_metadata(&meta);
2105
- let live_hash = hash_file(&abs).unwrap();
2230
+ let stat = fake_stat();
2231
+ let live_hash = [7u8; 32];
2232
+ // Fake fs: canned stat + matching hash, so the racy-window hash-confirm
2233
+ // branch sees a live hash equal to the cached one and trusts the file.
2234
+ let mut fake = FakeFs::with_stat(abs.clone(), stat.clone());
2235
+ fake.set_hash(abs.clone(), live_hash);
2106
2236
 
2107
2237
  let mut cached = HashMap::new();
2108
2238
  cached.insert(
@@ -2123,7 +2253,8 @@ mod internal_tests {
2123
2253
  cold_rebuild: false,
2124
2254
  };
2125
2255
  let scanned = vec![(abs.clone(), "t".to_string())];
2126
- let (to_parse, trusted, deleted) = reconcile_scan(dir.path(), scanned, &ctx).unwrap();
2256
+ let (to_parse, trusted, deleted) =
2257
+ reconcile_scan(dir.path(), scanned, &ctx, &fake).unwrap();
2127
2258
  assert!(to_parse.is_empty());
2128
2259
  assert_eq!(trusted.len(), 1);
2129
2260
  assert_eq!(trusted[0].rel_path, "a.txt");
@@ -2136,9 +2267,12 @@ mod internal_tests {
2136
2267
  fn reconcile_scan_racy_window_without_hash_reparses() {
2137
2268
  let dir = TempDir::new().unwrap();
2138
2269
  let abs = dir.path().join("b.txt");
2139
- std::fs::write(&abs, b"payload").unwrap();
2140
- let meta = std::fs::metadata(&abs).unwrap();
2141
- let stat = FileStat::from_metadata(&meta);
2270
+ let stat = fake_stat();
2271
+ // The live hash is available (file present) but the cache stored no
2272
+ // content hash, so the `(Some(live), None)` pair falls through the
2273
+ // `_ => false` arm: the file is NOT trusted and is re-parsed.
2274
+ let mut fake = FakeFs::with_stat(abs.clone(), stat.clone());
2275
+ fake.set_hash(abs.clone(), [9u8; 32]);
2142
2276
 
2143
2277
  let mut cached = HashMap::new();
2144
2278
  cached.insert(
@@ -2158,7 +2292,8 @@ mod internal_tests {
2158
2292
  cold_rebuild: false,
2159
2293
  };
2160
2294
  let scanned = vec![(abs.clone(), "t".to_string())];
2161
- let (to_parse, trusted, _deleted) = reconcile_scan(dir.path(), scanned, &ctx).unwrap();
2295
+ let (to_parse, trusted, _deleted) =
2296
+ reconcile_scan(dir.path(), scanned, &ctx, &fake).unwrap();
2162
2297
  assert_eq!(to_parse.len(), 1);
2163
2298
  assert!(trusted.is_empty());
2164
2299
  }
@@ -2176,7 +2311,32 @@ mod internal_tests {
2176
2311
  };
2177
2312
  let missing = dir.path().join("ghost.txt");
2178
2313
  let scanned = vec![(missing, "t".to_string())];
2179
- assert!(reconcile_scan(dir.path(), scanned, &ctx).is_err());
2314
+ // An empty fake fs stats every path as NotFound; the `?` in
2315
+ // `reconcile_scan` propagates that error.
2316
+ let fake = FakeFs::default();
2317
+ assert!(reconcile_scan(dir.path(), scanned, &ctx, &fake).is_err());
2318
+ }
2319
+
2320
+ /// Exercise the production [`RealFs`] [`FileSystem`] impl directly so its
2321
+ /// `stat`/`hash` method bodies are covered without the integration suite
2322
+ /// having to deterministically land in `reconcile_scan`'s racy window.
2323
+ /// Both methods run against a path that does not exist (inside a temp dir,
2324
+ /// so no direct `std::fs` call lives in the test): each delegates to its
2325
+ /// real backing (`std::fs::metadata` / `hash_file`) and surfaces the
2326
+ /// resulting `NotFound` error, executing the body either way.
2327
+ #[test]
2328
+ fn real_fs_delegates_stat_and_hash() {
2329
+ let dir = TempDir::new().unwrap();
2330
+ let missing = dir.path().join("nope.txt");
2331
+ let fs = RealFs;
2332
+ assert!(
2333
+ fs.stat(&missing).is_err(),
2334
+ "stat of a missing path must error"
2335
+ );
2336
+ assert!(
2337
+ fs.hash(&missing).is_err(),
2338
+ "hash of a missing path must error"
2339
+ );
2180
2340
  }
2181
2341
 
2182
2342
  // -----------------------------------------------------------------------
@@ -2220,8 +2380,12 @@ mod internal_tests {
2220
2380
  fn upsert_fixture() -> (TempDir, DirSQL, PathBuf, String) {
2221
2381
  let dir = TempDir::new().unwrap();
2222
2382
  let abs = dir.path().join("a.txt");
2223
- std::fs::write(&abs, b"hello").unwrap();
2224
- let db = DirSQL::with_ignore(
2383
+ // Inject a fake fs that stats the fixture path successfully (so
2384
+ // `handle_upsert`'s vanished-file guard passes) without staging a real
2385
+ // file. Any other path stats as NotFound, which the vanished-file test
2386
+ // relies on.
2387
+ let fake = FakeFs::with_stat(abs.clone(), fake_stat());
2388
+ let db = DirSQL::with_ignore_and_fs(
2225
2389
  dir.path(),
2226
2390
  vec![Table::new(
2227
2391
  "CREATE TABLE items (name TEXT)",
@@ -2234,6 +2398,7 @@ mod internal_tests {
2234
2398
  },
2235
2399
  )],
2236
2400
  Vec::<String>::new(),
2401
+ Arc::new(fake),
2237
2402
  )
2238
2403
  .unwrap();
2239
2404
  (dir, db, abs, "a.txt".to_string())
@@ -2360,7 +2525,12 @@ mod internal_tests {
2360
2525
  // the file is created afterwards and reaches the DB only through
2361
2526
  // `handle_upsert`, isolating the arm under test.
2362
2527
  let dir = TempDir::new().unwrap();
2363
- let db = DirSQL::with_ignore(
2528
+ let abs = dir.path().join("a.txt");
2529
+ // Inject a fake fs so the strict table's `handle_upsert` stat read
2530
+ // succeeds without staging a real file; the normalize-error arm is the
2531
+ // arm under test.
2532
+ let fake = FakeFs::with_stat(abs.clone(), fake_stat());
2533
+ let db = DirSQL::with_ignore_and_fs(
2364
2534
  dir.path(),
2365
2535
  vec![Table::strict(
2366
2536
  "CREATE TABLE items (name TEXT)",
@@ -2373,11 +2543,10 @@ mod internal_tests {
2373
2543
  },
2374
2544
  )],
2375
2545
  Vec::<String>::new(),
2546
+ Arc::new(fake),
2376
2547
  )
2377
2548
  .unwrap();
2378
2549
 
2379
- let abs = dir.path().join("a.txt");
2380
- std::fs::write(&abs, b"hello").unwrap();
2381
2550
  let events = db.handle_upsert("items", &abs, "a.txt");
2382
2551
  assert_eq!(events.len(), 1, "expected one error event: {events:?}");
2383
2552
  let dbg = format!("{:?}", events[0]);
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
File without changes
File without changes
File without changes
File without changes