dirsql 0.3.25__tar.gz → 0.3.27__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.25 → dirsql-0.3.27}/Cargo.lock +1 -1
  2. {dirsql-0.3.25 → dirsql-0.3.27}/PKG-INFO +1 -1
  3. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/lib.rs +264 -49
  5. dirsql-0.3.27/packages/rust/tests/watch_relative_root.rs +134 -0
  6. {dirsql-0.3.25 → dirsql-0.3.27}/Cargo.toml +0 -0
  7. {dirsql-0.3.25 → dirsql-0.3.27}/README.md +0 -0
  8. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/__init__.py +0 -0
  9. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/_async.py +0 -0
  10. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/_async_test.py +0 -0
  11. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/_dirsql.pyi +0 -0
  12. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/__init__.py +0 -0
  13. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/binary_path.py +0 -0
  14. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/binary_path_test.py +0 -0
  15. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/__init__.py +0 -0
  16. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/dispatch_extract.py +0 -0
  17. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/dispatch_extract_test.py +0 -0
  18. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/load_app.py +0 -0
  19. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/load_app_test.py +0 -0
  20. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/run.py +0 -0
  21. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/run_test.py +0 -0
  22. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/write_message.py +0 -0
  23. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/interpret/write_message_test.py +0 -0
  24. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/is_windows.py +0 -0
  25. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/is_windows_test.py +0 -0
  26. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/main.py +0 -0
  27. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/cli/main_test.py +0 -0
  28. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/py.typed +0 -0
  29. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/resolve_config.py +0 -0
  30. {dirsql-0.3.25 → dirsql-0.3.27}/dirsql/resolve_config_test.py +0 -0
  31. {dirsql-0.3.25 → dirsql-0.3.27}/docs/.claude/CLAUDE.md +0 -0
  32. {dirsql-0.3.25 → dirsql-0.3.27}/docs/.vitepress/config.ts +0 -0
  33. {dirsql-0.3.25 → dirsql-0.3.27}/docs/.vitepress/theme/index.ts +0 -0
  34. {dirsql-0.3.25 → dirsql-0.3.27}/docs/.vitepress/theme/lang.ts +0 -0
  35. {dirsql-0.3.25 → dirsql-0.3.27}/docs/AGENTS.md +0 -0
  36. {dirsql-0.3.25 → dirsql-0.3.27}/docs/api/index.md +0 -0
  37. {dirsql-0.3.25 → dirsql-0.3.27}/docs/cli/config.md +0 -0
  38. {dirsql-0.3.25 → dirsql-0.3.27}/docs/cli/http-api.md +0 -0
  39. {dirsql-0.3.25 → dirsql-0.3.27}/docs/cli/index.md +0 -0
  40. {dirsql-0.3.25 → dirsql-0.3.27}/docs/cli/init.md +0 -0
  41. {dirsql-0.3.25 → dirsql-0.3.27}/docs/cli/server.md +0 -0
  42. {dirsql-0.3.25 → dirsql-0.3.27}/docs/getting-started.md +0 -0
  43. {dirsql-0.3.25 → dirsql-0.3.27}/docs/guide/async.md +0 -0
  44. {dirsql-0.3.25 → dirsql-0.3.27}/docs/guide/crdt.md +0 -0
  45. {dirsql-0.3.25 → dirsql-0.3.27}/docs/guide/persistence.md +0 -0
  46. {dirsql-0.3.25 → dirsql-0.3.27}/docs/guide/querying.md +0 -0
  47. {dirsql-0.3.25 → dirsql-0.3.27}/docs/guide/tables.md +0 -0
  48. {dirsql-0.3.25 → dirsql-0.3.27}/docs/guide/watching.md +0 -0
  49. {dirsql-0.3.25 → dirsql-0.3.27}/docs/index.md +0 -0
  50. {dirsql-0.3.25 → dirsql-0.3.27}/docs/migrations.md +0 -0
  51. {dirsql-0.3.25 → dirsql-0.3.27}/docs/package.json +0 -0
  52. {dirsql-0.3.25 → dirsql-0.3.27}/docs/playwright.config.ts +0 -0
  53. {dirsql-0.3.25 → dirsql-0.3.27}/docs/pnpm-lock.yaml +0 -0
  54. {dirsql-0.3.25 → dirsql-0.3.27}/docs/pnpm-workspace.yaml +0 -0
  55. {dirsql-0.3.25 → dirsql-0.3.27}/docs/tests/integration/home.spec.ts +0 -0
  56. {dirsql-0.3.25 → dirsql-0.3.27}/docs/tests/integration/language-flag.spec.ts +0 -0
  57. {dirsql-0.3.25 → dirsql-0.3.27}/docs/tests/unit/config.test.ts +0 -0
  58. {dirsql-0.3.25 → dirsql-0.3.27}/docs/tests/unit/lang.test.ts +0 -0
  59. {dirsql-0.3.25 → dirsql-0.3.27}/docs/vitest.config.ts +0 -0
  60. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/README.md +0 -0
  61. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/conftest.py +0 -0
  62. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/.claude/CLAUDE.md +0 -0
  63. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/.vitepress/config.ts +0 -0
  64. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  65. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  66. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/AGENTS.md +0 -0
  67. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/api/index.md +0 -0
  68. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/cli/config.md +0 -0
  69. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/cli/http-api.md +0 -0
  70. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/cli/index.md +0 -0
  71. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/cli/init.md +0 -0
  72. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/cli/server.md +0 -0
  73. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/getting-started.md +0 -0
  74. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/guide/async.md +0 -0
  75. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/guide/crdt.md +0 -0
  76. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/guide/persistence.md +0 -0
  77. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/guide/querying.md +0 -0
  78. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/guide/tables.md +0 -0
  79. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/guide/watching.md +0 -0
  80. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/index.md +0 -0
  81. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/migrations.md +0 -0
  82. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/package.json +0 -0
  83. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/playwright.config.ts +0 -0
  84. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/pnpm-lock.yaml +0 -0
  85. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/pnpm-workspace.yaml +0 -0
  86. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  87. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  88. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/tests/unit/config.test.ts +0 -0
  89. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  90. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/docs/vitest.config.ts +0 -0
  91. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/src/lib.rs +0 -0
  92. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/__init__.py +0 -0
  93. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/conftest.py +0 -0
  94. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/e2e/__init__.py +0 -0
  95. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/data/a/meta.json +0 -0
  96. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/data/b/meta.json +0 -0
  97. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/dirsql.config.py +0 -0
  98. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/interpret/data/a/meta.json +0 -0
  99. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/interpret/data/b/meta.json +0 -0
  100. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config.py +0 -0
  101. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_no_app.py +0 -0
  102. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_raises.py +0 -0
  103. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/__init__.py +0 -0
  104. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/interpret_subprocess.py +0 -0
  105. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_async_dirsql.py +0 -0
  106. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_binding.py +0 -0
  107. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_dirsql.py +0 -0
  108. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_docs_examples.py +0 -0
  109. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_docs_gaps.py +0 -0
  110. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_from_config.py +0 -0
  111. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_interpret.py +0 -0
  112. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_native_config.py +0 -0
  113. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_persist.py +0 -0
  114. {dirsql-0.3.25 → dirsql-0.3.27}/packages/python/tests/integration/test_serialization.py +0 -0
  115. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/Cargo.toml +0 -0
  116. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/README.md +0 -0
  117. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/benches/db_bench.rs +0 -0
  118. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/benches/differ_bench.rs +0 -0
  119. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/benches/matcher_bench.rs +0 -0
  120. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/benches/scanner_bench.rs +0 -0
  121. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/api/index.md +0 -0
  122. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/cli/config.md +0 -0
  123. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/cli/http-api.md +0 -0
  124. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/cli/index.md +0 -0
  125. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/cli/init.md +0 -0
  126. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/cli/server.md +0 -0
  127. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/getting-started.md +0 -0
  128. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/guide/async.md +0 -0
  129. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/guide/crdt.md +0 -0
  130. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/guide/persistence.md +0 -0
  131. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/guide/querying.md +0 -0
  132. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/guide/tables.md +0 -0
  133. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/guide/watching.md +0 -0
  134. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/index.md +0 -0
  135. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/docs/migrations.md +0 -0
  136. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/bin/dirsql.rs +0 -0
  137. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/cli/init.rs +0 -0
  138. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/cli/mod.rs +0 -0
  139. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/cli/native_config.rs +0 -0
  140. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/cli/router.rs +0 -0
  141. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/cli/serialize.rs +0 -0
  142. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/cli/server.rs +0 -0
  143. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/config.rs +0 -0
  144. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/db.rs +0 -0
  145. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/differ.rs +0 -0
  146. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/matcher.rs +0 -0
  147. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/persist.rs +0 -0
  148. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/scanner.rs +0 -0
  149. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/src/watcher.rs +0 -0
  150. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/async_sdk.rs +0 -0
  151. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/cli_e2e.rs +0 -0
  152. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/cli_integration.rs +0 -0
  153. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/code_review_findings.rs +0 -0
  154. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/config.rs +0 -0
  155. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/docs_examples.rs +0 -0
  156. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/docs_gaps.rs +0 -0
  157. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/extensions.rs +0 -0
  158. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/from_config.rs +0 -0
  159. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/init_e2e.rs +0 -0
  160. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/init_integration.rs +0 -0
  161. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/persist.rs +0 -0
  162. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/readonly_query.rs +0 -0
  163. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/scanner.rs +0 -0
  164. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/sdk.rs +0 -0
  165. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/serialization.rs +0 -0
  166. {dirsql-0.3.25 → dirsql-0.3.27}/packages/rust/tests/watcher.rs +0 -0
  167. {dirsql-0.3.25 → dirsql-0.3.27}/pyproject.toml +0 -0
@@ -499,7 +499,7 @@ dependencies = [
499
499
 
500
500
  [[package]]
501
501
  name = "dirsql-py-ext"
502
- version = "0.3.25"
502
+ version = "0.3.27"
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.25
3
+ Version: 0.3.27
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.25"
7
+ version = "0.3.27"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -227,6 +227,17 @@ impl Table {
227
227
  struct DirSqlInner {
228
228
  db: Mutex<Db>,
229
229
  root: PathBuf,
230
+ /// Canonicalized form of `root`, used **only** for the live filesystem
231
+ /// watcher. `notify` has surprising behavior when handed a relative path
232
+ /// like `.` / `./data` (it may deliver no events at all, or deliver them
233
+ /// under the cwd-joined path so the relative prefix no longer strips):
234
+ /// the CLI binary works around this by canonicalizing its root before
235
+ /// watching, and the SDK now does the same (#250). Derived once at
236
+ /// construction via [`canonical_root`] (literal fallback when
237
+ /// canonicalization fails, e.g. a not-yet-created root), so the user's
238
+ /// `root` — and therefore the initial scan, [`DirSQL::config`], and the
239
+ /// `_path` virtual column — stay byte-for-byte unchanged.
240
+ watch_root: PathBuf,
230
241
  /// Pre-compiled matcher over all table globs plus ignore patterns.
231
242
  /// Built once at construction, reused by the initial scan and every
232
243
  /// subsequent watch iteration.
@@ -393,7 +404,9 @@ impl DirSQL {
393
404
  pub fn start_watching(&self) -> Result<()> {
394
405
  let mut guard = self.inner.watcher.lock().map_err(DirSqlError::lock)?;
395
406
  if guard.is_none() {
396
- let watcher = Watcher::new(&self.inner.root).map_err(DirSqlError::watch)?;
407
+ // Watch the canonicalized root, never the (possibly relative)
408
+ // user-supplied one — `notify` misbehaves on relative paths (#250).
409
+ let watcher = Watcher::new(&self.inner.watch_root).map_err(DirSqlError::watch)?;
397
410
  *guard = Some(watcher);
398
411
  }
399
412
  Ok(())
@@ -515,8 +528,15 @@ impl DirSQL {
515
528
  let abs_path = match &event {
516
529
  FileEvent::Created(p) | FileEvent::Modified(p) | FileEvent::Deleted(p) => p.clone(),
517
530
  };
531
+ // Events now arrive under the canonical `watch_root` (the watcher was
532
+ // started on it), so strip that first; fall back to the user-supplied
533
+ // `root` (covers the already-canonical/absolute-root case and any
534
+ // event whose path predates the watch-root change), then to the raw
535
+ // absolute path. This keeps the computed relative `_path` identical to
536
+ // the pre-#250 behavior for both absolute and relative roots.
518
537
  let rel_path_buf = abs_path
519
- .strip_prefix(&self.inner.root)
538
+ .strip_prefix(&self.inner.watch_root)
539
+ .or_else(|_| abs_path.strip_prefix(&self.inner.root))
520
540
  .unwrap_or(&abs_path)
521
541
  .to_path_buf();
522
542
 
@@ -870,10 +890,17 @@ impl DirSQL {
870
890
  write_meta(db.conn(), meta).map_err(DirSqlError::sqlite)?;
871
891
  }
872
892
 
893
+ // Canonicalize the watch root once, here at the single shared
894
+ // construction point reached by both `build()` and `build_async()`,
895
+ // so the live watcher never sees a relative path (#250). `root` itself
896
+ // is left untouched.
897
+ let watch_root = PathBuf::from(canonical_root(&root));
898
+
873
899
  Ok(Self {
874
900
  inner: Arc::new(DirSqlInner {
875
901
  db: Mutex::new(db),
876
902
  root,
903
+ watch_root,
877
904
  matcher,
878
905
  extract_map,
879
906
  strict_map,
@@ -1457,6 +1484,40 @@ const STAT_CTIME: &str = "_ctime";
1457
1484
  /// (`_path`, `_basename`, `_dir`, `_ext`) and stat-derived (`_size`,
1458
1485
  /// `_mtime`, `_ctime`).
1459
1486
  fn compute_stat_virtuals(rel_path: &str, abs_path: &Path) -> Row {
1487
+ // Read the file's stats once; a missing/unreadable file yields all-`None`,
1488
+ // which `stat_virtuals` renders as absent `_size`/`_mtime`/`_ctime`
1489
+ // columns. `_mtime`/`_ctime` are `None` when the platform can't supply
1490
+ // them (or the value predates the epoch). The pure column-building logic
1491
+ // lives in `stat_virtuals`.
1492
+ let (size, mtime_secs, ctime_secs) = match std::fs::metadata(abs_path) {
1493
+ Ok(metadata) => {
1494
+ let to_secs = |t: std::io::Result<std::time::SystemTime>| {
1495
+ t.ok()
1496
+ .and_then(|st| st.duration_since(std::time::UNIX_EPOCH).ok())
1497
+ .map(|d| d.as_secs() as i64)
1498
+ };
1499
+ (
1500
+ Some(metadata.len() as i64),
1501
+ to_secs(metadata.modified()),
1502
+ to_secs(metadata.created()),
1503
+ )
1504
+ }
1505
+ Err(_) => (None, None, None),
1506
+ };
1507
+ stat_virtuals(rel_path, size, mtime_secs, ctime_secs)
1508
+ }
1509
+
1510
+ /// Pure core of [`compute_stat_virtuals`]: build the filesystem-fact columns
1511
+ /// from the relative path plus already-read stat values (each `None` when the
1512
+ /// corresponding fact is unavailable). Split out so the column-mapping logic
1513
+ /// is unit-testable without touching the filesystem; the metadata read lives
1514
+ /// in the caller.
1515
+ fn stat_virtuals(
1516
+ rel_path: &str,
1517
+ size: Option<i64>,
1518
+ mtime_secs: Option<i64>,
1519
+ ctime_secs: Option<i64>,
1520
+ ) -> Row {
1460
1521
  let mut out = Row::new();
1461
1522
 
1462
1523
  out.insert(STAT_PATH.into(), Value::Text(rel_path.to_string()));
@@ -1484,18 +1545,14 @@ fn compute_stat_virtuals(rel_path: &str, abs_path: &Path) -> Row {
1484
1545
  );
1485
1546
  }
1486
1547
 
1487
- if let Ok(metadata) = std::fs::metadata(abs_path) {
1488
- out.insert(STAT_SIZE.into(), Value::Integer(metadata.len() as i64));
1489
- if let Ok(mtime) = metadata.modified()
1490
- && let Ok(d) = mtime.duration_since(std::time::UNIX_EPOCH)
1491
- {
1492
- out.insert(STAT_MTIME.into(), Value::Integer(d.as_secs() as i64));
1493
- }
1494
- if let Ok(ctime) = metadata.created()
1495
- && let Ok(d) = ctime.duration_since(std::time::UNIX_EPOCH)
1496
- {
1497
- out.insert(STAT_CTIME.into(), Value::Integer(d.as_secs() as i64));
1498
- }
1548
+ if let Some(size) = size {
1549
+ out.insert(STAT_SIZE.into(), Value::Integer(size));
1550
+ }
1551
+ if let Some(mtime) = mtime_secs {
1552
+ out.insert(STAT_MTIME.into(), Value::Integer(mtime));
1553
+ }
1554
+ if let Some(ctime) = ctime_secs {
1555
+ out.insert(STAT_CTIME.into(), Value::Integer(ctime));
1499
1556
  }
1500
1557
 
1501
1558
  out
@@ -1753,26 +1810,29 @@ mod internal_tests {
1753
1810
  use super::*;
1754
1811
  use tempfile::TempDir;
1755
1812
 
1756
- /// A real file with a basename, parent dir, and extension exercises the
1757
- /// `Some` arms of `compute_stat_virtuals`' path inspection plus the
1758
- /// `Ok(metadata)` arm.
1813
+ /// A relative path with a basename, parent dir, and extension exercises the
1814
+ /// `Some` arms of `stat_virtuals`' path inspection, plus the `Some` arms of
1815
+ /// the size/mtime/ctime inserts. The metadata read that supplies those
1816
+ /// values lives in `compute_stat_virtuals` and is covered by the
1817
+ /// integration suite (real-file scans).
1759
1818
  #[test]
1760
- fn compute_stat_virtuals_populates_from_real_file() {
1761
- let dir = TempDir::new().unwrap();
1762
- let abs = dir.path().join("sub.txt");
1763
- std::fs::create_dir_all(dir.path().join("sub")).ok();
1764
- std::fs::write(&abs, b"hello").unwrap();
1765
- let stat = compute_stat_virtuals("nested/sub.txt", &abs);
1819
+ fn stat_virtuals_populates_all_fields() {
1820
+ let stat = stat_virtuals("nested/sub.txt", Some(5), Some(100), Some(50));
1766
1821
  assert_eq!(stat[STAT_PATH], Value::Text("nested/sub.txt".into()));
1767
1822
  assert_eq!(stat[STAT_BASENAME], Value::Text("sub.txt".into()));
1768
1823
  assert_eq!(stat[STAT_DIR], Value::Text("nested".into()));
1769
1824
  assert_eq!(stat[STAT_EXT], Value::Text("txt".into()));
1770
1825
  assert!(matches!(stat.get(STAT_SIZE), Some(Value::Integer(5))));
1826
+ assert!(matches!(stat.get(STAT_MTIME), Some(Value::Integer(100))));
1827
+ assert!(matches!(stat.get(STAT_CTIME), Some(Value::Integer(50))));
1771
1828
  }
1772
1829
 
1773
1830
  /// A bare filename has no parent component and no extension, and a
1774
- /// nonexistent abs path makes `std::fs::metadata` fail. This drives the
1775
- /// skip (false) branches: no `_dir`, no `_ext`, no `_size`/`_mtime`/`_ctime`.
1831
+ /// nonexistent abs path makes `compute_stat_virtuals`' `std::fs::metadata`
1832
+ /// read fail (its `Err` arm -> all-`None`). This drives the skip branches:
1833
+ /// no `_ext`, no `_size`/`_mtime`/`_ctime`. (Calling the real
1834
+ /// `compute_stat_virtuals` with a nonexistent path keeps the test free of a
1835
+ /// direct `std::fs` call while still covering the read-failure arm.)
1776
1836
  #[test]
1777
1837
  fn compute_stat_virtuals_skips_absent_fields() {
1778
1838
  let stat = compute_stat_virtuals("bare", Path::new("/nonexistent-xyz/bare"));
@@ -1867,6 +1927,171 @@ mod internal_tests {
1867
1927
  assert_eq!(events.len(), 1, "non-ignored path must produce one event");
1868
1928
  }
1869
1929
 
1930
+ // -----------------------------------------------------------------------
1931
+ // #250: canonical `watch_root` and the strip-prefix fallbacks.
1932
+ //
1933
+ // `std::env::set_current_dir` is process-global, so the relative-root test
1934
+ // serializes through this lock and restores the cwd on the way out.
1935
+ // -----------------------------------------------------------------------
1936
+
1937
+ fn cwd_lock() -> &'static Mutex<()> {
1938
+ static LOCK: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
1939
+ LOCK.get_or_init(|| Mutex::new(()))
1940
+ }
1941
+
1942
+ /// Building with a **relative** root canonicalizes `watch_root` to an
1943
+ /// absolute path while leaving `root` (and therefore `config()` / `_path`)
1944
+ /// exactly as the caller supplied it. This is the core of the #250 fix:
1945
+ /// `start_watching` watches `watch_root`, so `notify` never sees `.`.
1946
+ #[test]
1947
+ fn relative_root_canonicalizes_watch_root_only() {
1948
+ let dir = TempDir::new().unwrap();
1949
+ let canonical = std::fs::canonicalize(dir.path()).unwrap();
1950
+
1951
+ let _guard = cwd_lock().lock().unwrap_or_else(|p| p.into_inner());
1952
+ let original = std::env::current_dir().unwrap();
1953
+ std::env::set_current_dir(&canonical).unwrap();
1954
+
1955
+ let db = DirSQL::new(
1956
+ ".",
1957
+ vec![Table::new("CREATE TABLE t (x TEXT)", "*.txt", |_| vec![])],
1958
+ )
1959
+ .unwrap();
1960
+
1961
+ // Restore cwd before asserting so a failure can't strand the process.
1962
+ std::env::set_current_dir(&original).unwrap();
1963
+
1964
+ // `root` is preserved verbatim; `config()` echoes it.
1965
+ assert_eq!(db.inner.root, PathBuf::from("."));
1966
+ assert_eq!(db.config().root, PathBuf::from("."));
1967
+ // `watch_root` is absolute and points at the canonical temp dir.
1968
+ assert!(
1969
+ db.inner.watch_root.is_absolute(),
1970
+ "watch_root must be absolute, got {:?}",
1971
+ db.inner.watch_root
1972
+ );
1973
+ assert_eq!(db.inner.watch_root, canonical);
1974
+ }
1975
+
1976
+ /// With an absolute root the canonical `watch_root` equals the (already
1977
+ /// canonical) root on this platform, and `process_file_event` strips that
1978
+ /// prefix to yield a root-relative `_path` — the first `strip_prefix`
1979
+ /// (watch_root) arm.
1980
+ #[test]
1981
+ fn process_file_event_strips_watch_root_prefix() {
1982
+ let dir = TempDir::new().unwrap();
1983
+ let canonical = std::fs::canonicalize(dir.path()).unwrap();
1984
+ let db = DirSQL::new(
1985
+ &canonical,
1986
+ vec![Table::new(
1987
+ "CREATE TABLE items (name TEXT, _path TEXT)",
1988
+ "**/*.txt",
1989
+ |_| {
1990
+ vec![Row::from_iter([(
1991
+ "name".to_string(),
1992
+ Value::Text("x".into()),
1993
+ )])]
1994
+ },
1995
+ )],
1996
+ )
1997
+ .unwrap();
1998
+
1999
+ let abs = canonical.join("nested").join("a.txt");
2000
+ std::fs::create_dir_all(canonical.join("nested")).unwrap();
2001
+ std::fs::write(&abs, b"").unwrap();
2002
+
2003
+ let events = db.process_file_event(FileEvent::Created(abs));
2004
+ assert_eq!(events.len(), 1, "expected one insert: {events:?}");
2005
+ match &events[0] {
2006
+ RowEvent::Insert { row, .. } => {
2007
+ assert_eq!(
2008
+ row.get("_path"),
2009
+ Some(&Value::Text("nested/a.txt".to_string())),
2010
+ "watch_root prefix must be stripped to a root-relative path"
2011
+ );
2012
+ }
2013
+ other => panic!("expected Insert, got {other:?}"),
2014
+ }
2015
+ }
2016
+
2017
+ /// When an event path lies under the user-supplied `root` but not under
2018
+ /// the canonical `watch_root`, the `.or_else` fallback strips `root`
2019
+ /// instead. We force that split by pointing `watch_root` at a sibling that
2020
+ /// is not a prefix of the event path, leaving `root` as the real dir.
2021
+ #[test]
2022
+ fn process_file_event_falls_back_to_root_prefix() {
2023
+ let dir = TempDir::new().unwrap();
2024
+ let canonical = std::fs::canonicalize(dir.path()).unwrap();
2025
+ let mut db = DirSQL::new(
2026
+ &canonical,
2027
+ vec![Table::new(
2028
+ "CREATE TABLE items (name TEXT, _path TEXT)",
2029
+ "**/*.txt",
2030
+ |_| {
2031
+ vec![Row::from_iter([(
2032
+ "name".to_string(),
2033
+ Value::Text("x".into()),
2034
+ )])]
2035
+ },
2036
+ )],
2037
+ )
2038
+ .unwrap();
2039
+
2040
+ // Repoint watch_root to a non-prefix sibling so the first strip misses
2041
+ // and the `.or_else(root)` arm runs. `root` stays the real dir.
2042
+ Arc::get_mut(&mut db.inner).unwrap().watch_root = canonical.join("does-not-prefix");
2043
+
2044
+ let abs = canonical.join("b.txt");
2045
+ std::fs::write(&abs, b"").unwrap();
2046
+ let events = db.process_file_event(FileEvent::Created(abs));
2047
+ assert_eq!(events.len(), 1, "expected one insert: {events:?}");
2048
+ match &events[0] {
2049
+ RowEvent::Insert { row, .. } => {
2050
+ assert_eq!(
2051
+ row.get("_path"),
2052
+ Some(&Value::Text("b.txt".to_string())),
2053
+ "root fallback must strip the user-supplied root prefix"
2054
+ );
2055
+ }
2056
+ other => panic!("expected Insert, got {other:?}"),
2057
+ }
2058
+ }
2059
+
2060
+ /// When the event path is under neither `watch_root` nor `root`, the final
2061
+ /// `unwrap_or(&abs_path)` arm keeps the absolute path. A path that matches
2062
+ /// no table glob then yields no events, but the strip fallback is still
2063
+ /// executed — we assert the no-event outcome to pin the arm without relying
2064
+ /// on a row.
2065
+ #[test]
2066
+ fn process_file_event_keeps_absolute_path_when_no_prefix_matches() {
2067
+ let dir = TempDir::new().unwrap();
2068
+ let canonical = std::fs::canonicalize(dir.path()).unwrap();
2069
+ let db = DirSQL::new(
2070
+ &canonical,
2071
+ vec![Table::new(
2072
+ "CREATE TABLE items (name TEXT)",
2073
+ "*.txt",
2074
+ |_| {
2075
+ vec![Row::from_iter([(
2076
+ "name".to_string(),
2077
+ Value::Text("x".into()),
2078
+ )])]
2079
+ },
2080
+ )],
2081
+ )
2082
+ .unwrap();
2083
+
2084
+ // A path outside both roots: neither strip matches, so the absolute
2085
+ // path is used as the relative path. It does not match `*.txt` at the
2086
+ // root, so no events are produced.
2087
+ let outside = PathBuf::from("/some/elsewhere/c.md");
2088
+ let events = db.process_file_event(FileEvent::Created(outside));
2089
+ assert!(
2090
+ events.is_empty(),
2091
+ "unmatched absolute path must produce no events: {events:?}"
2092
+ );
2093
+ }
2094
+
1870
2095
  /// Drive `reconcile_scan` directly with a cached file whose
1871
2096
  /// `snapshot_ns <= mtime_ns`, forcing the racy-window hash-confirm branch.
1872
2097
  /// With a matching content hash the file is trusted.
@@ -1966,36 +2191,26 @@ mod internal_tests {
1966
2191
  // public-API integration suite.
1967
2192
  // -----------------------------------------------------------------------
1968
2193
 
1969
- /// Poison a mutex by panicking while holding its guard on a scoped thread.
2194
+ /// Poison a mutex by panicking while holding its guard. `catch_unwind` on
2195
+ /// the current thread does this without `std::thread` (the `unit lint`
2196
+ /// isolation rule keeps effectful std out of unit tests): the guard's
2197
+ /// `Drop` runs during unwinding and marks the mutex poisoned.
1970
2198
  fn poison<T: Send>(m: &Mutex<T>) {
1971
- let _ = std::thread::scope(|s| {
1972
- s.spawn(|| {
1973
- let _g = m.lock().unwrap();
1974
- panic!("poison");
1975
- })
1976
- .join()
1977
- });
2199
+ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2200
+ let _g = m.lock().unwrap();
2201
+ panic!("poison");
2202
+ }));
1978
2203
  assert!(m.is_poisoned(), "mutex should be poisoned");
1979
2204
  }
1980
2205
 
1981
- /// Build a one-table `DirSQL` over a temp dir that already contains a
1982
- /// matching file, so the table's extract closure runs during the initial
1983
- /// scan (keeping the closure body out of the uncovered set). Returns the
1984
- /// TempDir guard alongside the db.
2206
+ /// Build a tableless `DirSQL` over an empty temp dir. These tests only
2207
+ /// need a live instance whose inner mutexes can be poisoned, so there is no
2208
+ /// table or file to stage (which keeps `std::fs` out of this unit module).
2209
+ /// Extract-closure coverage lives in the `process_file_event_*` tests.
1985
2210
  fn simple_db() -> (TempDir, DirSQL) {
1986
2211
  let dir = TempDir::new().unwrap();
1987
- std::fs::write(dir.path().join("a.txt"), b"").unwrap();
1988
- let db = DirSQL::with_ignore(
1989
- dir.path(),
1990
- vec![Table::new("CREATE TABLE t (name TEXT)", "*.txt", |_| {
1991
- vec![Row::from_iter([(
1992
- "name".to_string(),
1993
- Value::Text("x".into()),
1994
- )])]
1995
- })],
1996
- Vec::<String>::new(),
1997
- )
1998
- .unwrap();
2212
+ let db =
2213
+ DirSQL::with_ignore(dir.path(), Vec::<Table>::new(), Vec::<String>::new()).unwrap();
1999
2214
  (dir, db)
2000
2215
  }
2001
2216
 
@@ -0,0 +1,134 @@
1
+ //! Regression test for #250: the file watcher must deliver `RowEvent`s when
2
+ //! `DirSQL` is constructed with a **relative** `root` (e.g. `DirSQL::new(".",
3
+ //! ...)`), not just an absolute one.
4
+ //!
5
+ //! Root cause: `start_watching()` handed the user-supplied (possibly relative)
6
+ //! `root` straight to `notify`, which has surprising behavior when watching
7
+ //! relative paths like `./`. The initial scan worked fine either way; only the
8
+ //! live watcher was broken. The CLI binary already canonicalized its root for
9
+ //! exactly this reason — the SDK did not. The fix watches a canonicalized
10
+ //! `watch_root` while keeping the user-supplied `root` for scanning,
11
+ //! `config()`, and `_path` output.
12
+ //!
13
+ //! `std::env::set_current_dir` mutates **process-global** state, so every test
14
+ //! in this file serializes through `CWD_LOCK` and restores the original cwd on
15
+ //! the way out (via the `CwdGuard` drop), even on panic.
16
+
17
+ use dirsql::{DirSQL, Table, Value};
18
+ use std::fs;
19
+ use std::path::PathBuf;
20
+ use std::sync::{Mutex, MutexGuard, OnceLock};
21
+ use std::time::{Duration, Instant};
22
+
23
+ /// Serializes the process-global cwd across the tests in this file. A `OnceLock`
24
+ /// avoids pulling in a `lazy_static`/`once_cell` dependency just for the test.
25
+ fn cwd_lock() -> &'static Mutex<()> {
26
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
27
+ LOCK.get_or_init(|| Mutex::new(()))
28
+ }
29
+
30
+ /// Holds the cwd lock and the original working directory, restoring it on drop.
31
+ /// Recovers a poisoned lock: a prior test panicking while holding the guard
32
+ /// must not cascade-fail every later cwd test.
33
+ struct CwdGuard {
34
+ _guard: MutexGuard<'static, ()>,
35
+ original: PathBuf,
36
+ }
37
+
38
+ impl CwdGuard {
39
+ fn enter(target: &std::path::Path) -> Self {
40
+ let guard = cwd_lock().lock().unwrap_or_else(|p| p.into_inner());
41
+ let original = std::env::current_dir().expect("read cwd");
42
+ std::env::set_current_dir(target).expect("chdir into target");
43
+ Self {
44
+ _guard: guard,
45
+ original,
46
+ }
47
+ }
48
+ }
49
+
50
+ impl Drop for CwdGuard {
51
+ fn drop(&mut self) {
52
+ // Best-effort restore; if this fails the process is already in a bad
53
+ // state and later tests will surface it.
54
+ let _ = std::env::set_current_dir(&self.original);
55
+ }
56
+ }
57
+
58
+ fn items_table() -> Table {
59
+ Table::new(
60
+ "CREATE TABLE items (name TEXT, _path TEXT)",
61
+ "**/*.txt",
62
+ |path| {
63
+ let content = std::fs::read_to_string(path).unwrap_or_default();
64
+ vec![std::collections::HashMap::from([(
65
+ "name".to_string(),
66
+ Value::Text(content.trim().to_string()),
67
+ )])]
68
+ },
69
+ )
70
+ }
71
+
72
+ /// #250: a watcher built on a relative `root` (`"."`) must still emit an insert
73
+ /// `RowEvent` when a matching file is created after `start_watching()`.
74
+ ///
75
+ /// Without the fix the watcher hands `"."` to `notify`, which never delivers
76
+ /// events, so this poll loop times out with zero events (RED). With the fix the
77
+ /// watcher canonicalizes the root, the create is observed, and an `Insert`
78
+ /// arrives (GREEN). The companion absolute-root assertion is intentionally
79
+ /// *not* a separate `#[test]` so it can't race for the cwd lock; it shares this
80
+ /// one to prove the two roots behave identically.
81
+ #[test]
82
+ fn watch_with_relative_root_emits_events() {
83
+ let dir = tempfile::TempDir::new().unwrap();
84
+ // Canonicalize the temp dir up front: on macOS `TempDir` lives under
85
+ // `/var/...` which is a symlink to `/private/var/...`, and the post-fix
86
+ // `_path` is computed by stripping the *canonical* watch root. Comparing
87
+ // against a canonical base keeps the relative-path assertion portable.
88
+ let canonical_dir = fs::canonicalize(dir.path()).unwrap();
89
+
90
+ let _cwd = CwdGuard::enter(&canonical_dir);
91
+
92
+ // Relative root: the exact shape from the bug report / docs examples.
93
+ let db = DirSQL::new(".", vec![items_table()]).unwrap();
94
+ db.start_watching().unwrap();
95
+
96
+ // Give the watcher a moment to register before mutating the tree.
97
+ std::thread::sleep(Duration::from_millis(250));
98
+ fs::write(canonical_dir.join("apple.txt"), "apple").unwrap();
99
+
100
+ let mut events = Vec::new();
101
+ let deadline = Instant::now() + Duration::from_secs(5);
102
+ while events.is_empty() && Instant::now() < deadline {
103
+ events.extend(db.poll_events(Duration::from_millis(200)).unwrap());
104
+ }
105
+
106
+ let insert = events
107
+ .iter()
108
+ .find(|e| matches!(e, dirsql::RowEvent::Insert { .. }));
109
+ assert!(
110
+ insert.is_some(),
111
+ "relative-root watcher must emit an Insert event (#250); saw: {events:?}"
112
+ );
113
+
114
+ // The relative `_path` must be the root-relative file name, identical to
115
+ // what an absolute root would have produced — proving the canonical
116
+ // watch-root did not leak the absolute prefix into the event path.
117
+ if let Some(dirsql::RowEvent::Insert { row, .. }) = insert {
118
+ assert_eq!(
119
+ row.get("_path"),
120
+ Some(&Value::Text("apple.txt".to_string())),
121
+ "_path must stay root-relative for a relative root"
122
+ );
123
+ }
124
+
125
+ // And the row landed in the in-memory index.
126
+ let rows = db.query("SELECT name FROM items").unwrap();
127
+ assert!(
128
+ rows.iter().any(|r| matches!(
129
+ r.get("name"),
130
+ Some(Value::Text(name)) if name == "apple"
131
+ )),
132
+ "indexed rows should include the created file"
133
+ );
134
+ }
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