dirsql 0.3.95__tar.gz → 0.3.96__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 (139) hide show
  1. {dirsql-0.3.95 → dirsql-0.3.96}/Cargo.lock +1 -1
  2. {dirsql-0.3.95 → dirsql-0.3.96}/PKG-INFO +1 -1
  3. {dirsql-0.3.95/packages/python → dirsql-0.3.96}/docs/howto/define-tables.md +4 -3
  4. {dirsql-0.3.95/packages/rust → dirsql-0.3.96}/docs/reference/config.md +1 -1
  5. {dirsql-0.3.95/packages/rust → dirsql-0.3.96}/docs/reference/sdk.md +3 -1
  6. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/Cargo.toml +1 -1
  7. {dirsql-0.3.95/packages/rust → dirsql-0.3.96/packages/python}/docs/howto/define-tables.md +4 -3
  8. {dirsql-0.3.95 → dirsql-0.3.96/packages/python}/docs/reference/config.md +1 -1
  9. {dirsql-0.3.95 → dirsql-0.3.96/packages/python}/docs/reference/sdk.md +3 -1
  10. dirsql-0.3.96/packages/python/e2e-attestations/claude-github-issue-580-d0jd9u.json +7 -0
  11. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/benches/matcher_bench.rs +2 -2
  12. {dirsql-0.3.95 → dirsql-0.3.96/packages/rust}/docs/howto/define-tables.md +4 -3
  13. {dirsql-0.3.95/packages/python → dirsql-0.3.96/packages/rust}/docs/reference/config.md +1 -1
  14. {dirsql-0.3.95/packages/python → dirsql-0.3.96/packages/rust}/docs/reference/sdk.md +3 -1
  15. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/lib.rs +61 -43
  16. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/matcher.rs +146 -96
  17. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/persist.rs +84 -16
  18. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/scanner.rs +4 -2
  19. {dirsql-0.3.95 → dirsql-0.3.96}/Cargo.toml +0 -0
  20. {dirsql-0.3.95 → dirsql-0.3.96}/README.md +0 -0
  21. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/__init__.py +0 -0
  22. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/_async.py +0 -0
  23. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/_dirsql.pyi +0 -0
  24. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/cli/__init__.py +0 -0
  25. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/cli/binary_path.py +0 -0
  26. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/cli/is_windows.py +0 -0
  27. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/cli/main.py +0 -0
  28. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/cli/resolve_config_extensions.py +0 -0
  29. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/py.typed +0 -0
  30. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/resolve_config_extensions.py +0 -0
  31. {dirsql-0.3.95 → dirsql-0.3.96}/dirsql/resolve_extension.py +0 -0
  32. {dirsql-0.3.95 → dirsql-0.3.96}/docs/.claude/CLAUDE.md +0 -0
  33. {dirsql-0.3.95 → dirsql-0.3.96}/docs/.vitepress/config.ts +0 -0
  34. {dirsql-0.3.95 → dirsql-0.3.96}/docs/.vitepress/theme/index.ts +0 -0
  35. {dirsql-0.3.95 → dirsql-0.3.96}/docs/.vitepress/theme/lang.ts +0 -0
  36. {dirsql-0.3.95 → dirsql-0.3.96}/docs/AGENTS.md +0 -0
  37. {dirsql-0.3.95 → dirsql-0.3.96}/docs/explanation.md +0 -0
  38. {dirsql-0.3.95 → dirsql-0.3.96}/docs/getting-started.md +0 -0
  39. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/columns-from-paths.md +0 -0
  40. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/embed.md +0 -0
  41. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/extract-from-contents.md +0 -0
  42. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/load-extension.md +0 -0
  43. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/persist.md +0 -0
  44. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/react-to-changes.md +0 -0
  45. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/search-by-meaning.md +0 -0
  46. {dirsql-0.3.95 → dirsql-0.3.96}/docs/howto/skip-files.md +0 -0
  47. {dirsql-0.3.95 → dirsql-0.3.96}/docs/index.md +0 -0
  48. {dirsql-0.3.95 → dirsql-0.3.96}/docs/migrations.md +0 -0
  49. {dirsql-0.3.95 → dirsql-0.3.96}/docs/package.json +0 -0
  50. {dirsql-0.3.95 → dirsql-0.3.96}/docs/playwright.config.ts +0 -0
  51. {dirsql-0.3.95 → dirsql-0.3.96}/docs/pnpm-lock.yaml +0 -0
  52. {dirsql-0.3.95 → dirsql-0.3.96}/docs/pnpm-workspace.yaml +0 -0
  53. {dirsql-0.3.95 → dirsql-0.3.96}/docs/reference/cli.md +0 -0
  54. {dirsql-0.3.95 → dirsql-0.3.96}/docs/reference/columns.md +0 -0
  55. {dirsql-0.3.95 → dirsql-0.3.96}/docs/reference/hooks.md +0 -0
  56. {dirsql-0.3.95 → dirsql-0.3.96}/docs/reference/http-api.md +0 -0
  57. {dirsql-0.3.95 → dirsql-0.3.96}/docs/tests/integration/home.spec.ts +0 -0
  58. {dirsql-0.3.95 → dirsql-0.3.96}/docs/tests/integration/language-flag.spec.ts +0 -0
  59. {dirsql-0.3.95 → dirsql-0.3.96}/docs/tests/integration/sidebar.spec.ts +0 -0
  60. {dirsql-0.3.95 → dirsql-0.3.96}/docs/tests/unit/config.test.ts +0 -0
  61. {dirsql-0.3.95 → dirsql-0.3.96}/docs/tests/unit/lang.test.ts +0 -0
  62. {dirsql-0.3.95 → dirsql-0.3.96}/docs/vitest.config.ts +0 -0
  63. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/README.md +0 -0
  64. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/conftest.py +0 -0
  65. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/.claude/CLAUDE.md +0 -0
  66. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/.vitepress/config.ts +0 -0
  67. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  68. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  69. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/AGENTS.md +0 -0
  70. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/explanation.md +0 -0
  71. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/getting-started.md +0 -0
  72. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/columns-from-paths.md +0 -0
  73. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/embed.md +0 -0
  74. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/extract-from-contents.md +0 -0
  75. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/load-extension.md +0 -0
  76. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/persist.md +0 -0
  77. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/react-to-changes.md +0 -0
  78. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/search-by-meaning.md +0 -0
  79. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/howto/skip-files.md +0 -0
  80. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/index.md +0 -0
  81. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/migrations.md +0 -0
  82. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/package.json +0 -0
  83. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/playwright.config.ts +0 -0
  84. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/pnpm-lock.yaml +0 -0
  85. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/pnpm-workspace.yaml +0 -0
  86. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/reference/cli.md +0 -0
  87. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/reference/columns.md +0 -0
  88. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/reference/hooks.md +0 -0
  89. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/reference/http-api.md +0 -0
  90. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  91. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  92. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  93. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/tests/unit/config.test.ts +0 -0
  94. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  95. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/docs/vitest.config.ts +0 -0
  96. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/e2e-attestations/claude-open-issues-review-le8hm5-red-546.json +0 -0
  97. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/e2e-attestations/claude-open-issues-review-le8hm5-red-553.json +0 -0
  98. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/src/lib.rs +0 -0
  99. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/tests/__init__.py +0 -0
  100. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/tests/conftest.py +0 -0
  101. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/tests/e2e/__init__.py +0 -0
  102. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/tests/integration/__init__.py +0 -0
  103. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/tests/integration/binding/__init__.py +0 -0
  104. {dirsql-0.3.95 → dirsql-0.3.96}/packages/python/tests/integration/hermetic/__init__.py +0 -0
  105. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/Cargo.toml +0 -0
  106. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/README.md +0 -0
  107. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/benches/db_bench.rs +0 -0
  108. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/benches/differ_bench.rs +0 -0
  109. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/benches/scanner_bench.rs +0 -0
  110. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/explanation.md +0 -0
  111. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/getting-started.md +0 -0
  112. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/columns-from-paths.md +0 -0
  113. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/embed.md +0 -0
  114. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/extract-from-contents.md +0 -0
  115. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/load-extension.md +0 -0
  116. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/persist.md +0 -0
  117. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/react-to-changes.md +0 -0
  118. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/search-by-meaning.md +0 -0
  119. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/howto/skip-files.md +0 -0
  120. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/index.md +0 -0
  121. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/migrations.md +0 -0
  122. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/reference/cli.md +0 -0
  123. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/reference/columns.md +0 -0
  124. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/reference/hooks.md +0 -0
  125. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/docs/reference/http-api.md +0 -0
  126. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/bin/dirsql.rs +0 -0
  127. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/cli/execute.rs +0 -0
  128. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/cli/init.rs +0 -0
  129. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/cli/mod.rs +0 -0
  130. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/cli/router.rs +0 -0
  131. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/cli/serialize.rs +0 -0
  132. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/cli/server.rs +0 -0
  133. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/command.rs +0 -0
  134. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/config.rs +0 -0
  135. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/db.rs +0 -0
  136. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/default_config.toml +0 -0
  137. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/differ.rs +0 -0
  138. {dirsql-0.3.95 → dirsql-0.3.96}/packages/rust/src/watcher.rs +0 -0
  139. {dirsql-0.3.95 → dirsql-0.3.96}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.95"
503
+ version = "0.3.96"
504
504
  dependencies = [
505
505
  "dirsql",
506
506
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.95
3
+ Version: 0.3.96
4
4
  Summary: Ephemeral SQL index over a local directory
5
5
  Keywords: sql,filesystem,directory,sqlite,index
6
6
  Author: Kevin Scott
@@ -42,9 +42,10 @@ zero-config default — only the tables you define are served.
42
42
 
43
43
  ## Multiple tables
44
44
 
45
- Add one `[[table]]` entry per table. When a file matches several globs, the
46
- first matching table wins see [`[[table]]`](../reference/config.md#table)
47
- for that and the remaining keys (`strict`, `on-file`).
45
+ Add one `[[table]]` entry per table. When a file matches several globs, it
46
+ populates every matching table — each table is an independent view. See
47
+ [`[[table]]`](../reference/config.md#table) for that and the remaining keys
48
+ (`strict`, `on-file`).
48
49
 
49
50
  ## Going further
50
51
 
@@ -97,7 +97,7 @@ per-file command.
97
97
  | Key | Required | Description |
98
98
  |---|---|---|
99
99
  | `ddl` | yes | A SQLite `CREATE TABLE` statement. The table name is parsed from it. Only columns declared here are populated; auto-injected facts not in the DDL are dropped. |
100
- | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). First matching table wins when a file matches several globs. |
100
+ | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). Every table whose glob matches a file receives that file's rows — a file can populate multiple tables. |
101
101
  | `strict` | no (default `false`) | When `true`, rows whose keys do not exactly match the declared columns are rejected with an error: extra keys error, and every declared column must be supplied (by the command/extract output, a glob capture, or a stat column). When `false`, extra keys are dropped and missing columns become `NULL`. |
102
102
  | `on-file` | no | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. See [Command hooks](./hooks.md#on-file). |
103
103
 
@@ -318,7 +318,9 @@ Maps files to table rows.
318
318
  - `ddl` — A SQLite `CREATE TABLE` statement; the table name is parsed from
319
319
  it. Table names must be unique across all tables.
320
320
  - `glob` — Glob pattern matched against root-relative paths. May contain
321
- `{name}` [captures](./columns.md#glob-captures).
321
+ `{name}` [captures](./columns.md#glob-captures). Every table whose glob
322
+ matches a file receives that file's rows — a file can populate multiple
323
+ tables.
322
324
  - `extract` — Callback receiving the matched file's full path (the root
323
325
  joined with the file's relative path — absolute when `root` is absolute)
324
326
  and returning the rows that file contributes. `dirsql` never reads file
@@ -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.95"
7
+ version = "0.3.96"
8
8
  # abi3 note: the `abi3-py311` pyo3 feature (below) builds ONE stable-ABI
9
9
  # `cp311-abi3` wheel per platform that loads on every CPython >= 3.11
10
10
  # (matching `pyproject.toml`'s `requires-python = ">=3.11"`), instead of
@@ -42,9 +42,10 @@ zero-config default — only the tables you define are served.
42
42
 
43
43
  ## Multiple tables
44
44
 
45
- Add one `[[table]]` entry per table. When a file matches several globs, the
46
- first matching table wins see [`[[table]]`](../reference/config.md#table)
47
- for that and the remaining keys (`strict`, `on-file`).
45
+ Add one `[[table]]` entry per table. When a file matches several globs, it
46
+ populates every matching table — each table is an independent view. See
47
+ [`[[table]]`](../reference/config.md#table) for that and the remaining keys
48
+ (`strict`, `on-file`).
48
49
 
49
50
  ## Going further
50
51
 
@@ -97,7 +97,7 @@ per-file command.
97
97
  | Key | Required | Description |
98
98
  |---|---|---|
99
99
  | `ddl` | yes | A SQLite `CREATE TABLE` statement. The table name is parsed from it. Only columns declared here are populated; auto-injected facts not in the DDL are dropped. |
100
- | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). First matching table wins when a file matches several globs. |
100
+ | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). Every table whose glob matches a file receives that file's rows — a file can populate multiple tables. |
101
101
  | `strict` | no (default `false`) | When `true`, rows whose keys do not exactly match the declared columns are rejected with an error: extra keys error, and every declared column must be supplied (by the command/extract output, a glob capture, or a stat column). When `false`, extra keys are dropped and missing columns become `NULL`. |
102
102
  | `on-file` | no | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. See [Command hooks](./hooks.md#on-file). |
103
103
 
@@ -318,7 +318,9 @@ Maps files to table rows.
318
318
  - `ddl` — A SQLite `CREATE TABLE` statement; the table name is parsed from
319
319
  it. Table names must be unique across all tables.
320
320
  - `glob` — Glob pattern matched against root-relative paths. May contain
321
- `{name}` [captures](./columns.md#glob-captures).
321
+ `{name}` [captures](./columns.md#glob-captures). Every table whose glob
322
+ matches a file receives that file's rows — a file can populate multiple
323
+ tables.
322
324
  - `extract` — Callback receiving the matched file's full path (the root
323
325
  joined with the file's relative path — absolute when `root` is absolute)
324
326
  and returning the rows that file contributes. `dirsql` never reads file
@@ -0,0 +1,7 @@
1
+ {
2
+ "command": "uv run python -m pytest tests/e2e/ -x -q",
3
+ "ran_at": 1783944230,
4
+ "exit_code": 0,
5
+ "commit": "f6b1948a6d2de0b769ca428a802f52899f85c4b9",
6
+ "branch": "claude/github-issue-580-d0jd9u"
7
+ }
@@ -20,7 +20,7 @@ fn make_test_paths(count: usize) -> Vec<String> {
20
20
  }
21
21
 
22
22
  fn bench_match_file(c: &mut Criterion) {
23
- let mut group = c.benchmark_group("matcher/match_file");
23
+ let mut group = c.benchmark_group("matcher/match_all");
24
24
  for pattern_count in [1, 10, 50] {
25
25
  let matcher = make_matcher(pattern_count);
26
26
  let paths = make_test_paths(100);
@@ -30,7 +30,7 @@ fn bench_match_file(c: &mut Criterion) {
30
30
  |b, _| {
31
31
  b.iter(|| {
32
32
  for p in &paths {
33
- matcher.match_file(Path::new(p));
33
+ matcher.match_all(Path::new(p));
34
34
  }
35
35
  });
36
36
  },
@@ -42,9 +42,10 @@ zero-config default — only the tables you define are served.
42
42
 
43
43
  ## Multiple tables
44
44
 
45
- Add one `[[table]]` entry per table. When a file matches several globs, the
46
- first matching table wins see [`[[table]]`](../reference/config.md#table)
47
- for that and the remaining keys (`strict`, `on-file`).
45
+ Add one `[[table]]` entry per table. When a file matches several globs, it
46
+ populates every matching table — each table is an independent view. See
47
+ [`[[table]]`](../reference/config.md#table) for that and the remaining keys
48
+ (`strict`, `on-file`).
48
49
 
49
50
  ## Going further
50
51
 
@@ -97,7 +97,7 @@ per-file command.
97
97
  | Key | Required | Description |
98
98
  |---|---|---|
99
99
  | `ddl` | yes | A SQLite `CREATE TABLE` statement. The table name is parsed from it. Only columns declared here are populated; auto-injected facts not in the DDL are dropped. |
100
- | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). First matching table wins when a file matches several globs. |
100
+ | `glob` | yes | Glob pattern matched against root-relative paths. May contain `{name}` [capture segments](./columns.md#glob-captures). Every table whose glob matches a file receives that file's rows — a file can populate multiple tables. |
101
101
  | `strict` | no (default `false`) | When `true`, rows whose keys do not exactly match the declared columns are rejected with an error: extra keys error, and every declared column must be supplied (by the command/extract output, a glob capture, or a stat column). When `false`, extra keys are dropped and missing columns become `NULL`. |
102
102
  | `on-file` | no | A command run once per matched file; its stdout (a JSON array of row objects) becomes the file's rows. Must be non-empty. See [Command hooks](./hooks.md#on-file). |
103
103
 
@@ -318,7 +318,9 @@ Maps files to table rows.
318
318
  - `ddl` — A SQLite `CREATE TABLE` statement; the table name is parsed from
319
319
  it. Table names must be unique across all tables.
320
320
  - `glob` — Glob pattern matched against root-relative paths. May contain
321
- `{name}` [captures](./columns.md#glob-captures).
321
+ `{name}` [captures](./columns.md#glob-captures). Every table whose glob
322
+ matches a file receives that file's rows — a file can populate multiple
323
+ tables.
322
324
  - `extract` — Callback receiving the matched file's full path (the root
323
325
  joined with the file's relative path — absolute when `root` is absolute)
324
326
  and returning the rows that file contributes. `dirsql` never reads file
@@ -454,18 +454,33 @@ impl DirSQL {
454
454
  return Vec::new();
455
455
  }
456
456
 
457
- let table_name = match self.inner.matcher.match_file(&rel_path_buf) {
458
- Some(name) => name.to_string(),
459
- None => return Vec::new(),
460
- };
457
+ // Fan-out: dispatch the event to every table whose glob matches, and
458
+ // concatenate the resulting row events. Cross-table event order is
459
+ // unspecified. An `extract` failure produces an error event for that
460
+ // table only; the other matching tables still process the event.
461
+ let matches = self.inner.matcher.match_all(&rel_path_buf);
462
+ if matches.is_empty() {
463
+ return Vec::new();
464
+ }
461
465
  let rel_path = rel_path_buf.to_string_lossy().to_string();
462
466
 
463
- match event {
464
- FileEvent::Deleted(_) => self.handle_delete(&table_name, &rel_path),
465
- FileEvent::Created(_) | FileEvent::Modified(_) => {
466
- self.handle_upsert(&table_name, &abs_path, &rel_path)
467
+ let mut events = Vec::new();
468
+ for m in matches {
469
+ match &event {
470
+ FileEvent::Deleted(_) => {
471
+ events.extend(self.handle_delete(&m.table_name, &rel_path));
472
+ }
473
+ FileEvent::Created(_) | FileEvent::Modified(_) => {
474
+ events.extend(self.handle_upsert(
475
+ &m.table_name,
476
+ &abs_path,
477
+ &rel_path,
478
+ &m.captures,
479
+ ));
480
+ }
467
481
  }
468
482
  }
483
+ events
469
484
  }
470
485
 
471
486
  fn handle_delete(&self, table: &str, rel_path: &str) -> Vec<RowEvent> {
@@ -491,7 +506,13 @@ impl DirSQL {
491
506
  differ::diff(table, Some(&old_rows), None, rel_path)
492
507
  }
493
508
 
494
- fn handle_upsert(&self, table: &str, abs_path: &Path, rel_path: &str) -> Vec<RowEvent> {
509
+ fn handle_upsert(
510
+ &self,
511
+ table: &str,
512
+ abs_path: &Path,
513
+ rel_path: &str,
514
+ captures: &HashMap<String, String>,
515
+ ) -> Vec<RowEvent> {
495
516
  // The path may have vanished between the watcher event and now, or be a
496
517
  // directory (a `mkdir` under the root matches a `**/*` glob). Only
497
518
  // regular files become rows — mirror the initial scan, which skips
@@ -513,12 +534,6 @@ impl DirSQL {
513
534
  Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
514
535
  };
515
536
 
516
- let captures = self
517
- .inner
518
- .matcher
519
- .match_file_with_captures(Path::new(rel_path))
520
- .map(|m| m.captures)
521
- .unwrap_or_default();
522
537
  let stat = compute_stat_virtuals(rel_path, abs_path);
523
538
 
524
539
  let strict = *self.inner.strict_map.get(table).unwrap_or(&false);
@@ -535,7 +550,7 @@ impl DirSQL {
535
550
  Ok(cols) => cols,
536
551
  Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
537
552
  };
538
- let raw_rows = merge_filesystem_facts(raw_rows, &captures, &stat, &declared_columns);
553
+ let raw_rows = merge_filesystem_facts(raw_rows, captures, &stat, &declared_columns);
539
554
  let mut new_rows = Vec::with_capacity(raw_rows.len());
540
555
  for raw in &raw_rows {
541
556
  match db.normalize_row(table, raw, strict) {
@@ -742,7 +757,7 @@ impl DirSQL {
742
757
  for (rel_path, table_name) in deleted {
743
758
  db.delete_rows_by_file(table_name, rel_path)
744
759
  .map_err(map_db_error)?;
745
- cache_delete_file(db.conn(), rel_path).map_err(DirSqlError::sqlite)?;
760
+ cache_delete_file(db.conn(), rel_path, table_name).map_err(DirSqlError::sqlite)?;
746
761
  }
747
762
  }
748
763
 
@@ -764,10 +779,9 @@ impl DirSQL {
764
779
  message: e.to_string(),
765
780
  })?;
766
781
 
767
- let captures = matcher
768
- .match_file_with_captures(Path::new(&rel_path))
769
- .map(|m| m.captures)
770
- .unwrap_or_default();
782
+ // Captures are per-glob: use the captures belonging to THIS
783
+ // entry's table, not a first-match lookup.
784
+ let captures = matcher.captures_for(Path::new(&rel_path), &table_name);
771
785
  let stat_virtuals = compute_stat_virtuals(&rel_path, &abs_path);
772
786
  let declared_columns = db.get_table_columns(&table_name).map_err(map_db_error)?;
773
787
  let raw_rows =
@@ -1144,7 +1158,9 @@ pub struct TrustedFile {
1144
1158
 
1145
1159
  struct PersistContext {
1146
1160
  db: Db,
1147
- cached: HashMap<String, CachedFile>,
1161
+ /// Cached file bookkeeping keyed by `(rel_path, table_name)` — a file may
1162
+ /// be cached under several tables under fan-out.
1163
+ cached: HashMap<(String, String), CachedFile>,
1148
1164
  expected_meta: HashMap<String, String>,
1149
1165
  }
1150
1166
 
@@ -1259,18 +1275,20 @@ fn reconcile_scan(
1259
1275
  ) -> Result<(Vec<ScannedFile>, Vec<TrustedFile>, Vec<(String, String)>)> {
1260
1276
  let mut to_parse = Vec::new();
1261
1277
  let mut trusted = Vec::new();
1262
- let mut seen_paths: std::collections::HashSet<String> =
1278
+ // Keyed by (rel_path, table_name): under fan-out one file may be scanned
1279
+ // for several tables, and each pair is trusted/deleted independently.
1280
+ let mut seen: std::collections::HashSet<(String, String)> =
1263
1281
  std::collections::HashSet::with_capacity(scanned.len());
1264
1282
 
1265
1283
  for (path, table_name) in scanned {
1266
1284
  let rel_path = relative_path(root, &path);
1267
- seen_paths.insert(rel_path.clone());
1285
+ seen.insert((rel_path.clone(), table_name.clone()));
1268
1286
 
1269
1287
  let stat = fs.stat(&path)?;
1270
1288
 
1271
- let cached = ctx.cached.get(&rel_path);
1289
+ let cached = ctx.cached.get(&(rel_path.clone(), table_name.clone()));
1272
1290
  let trust = match cached {
1273
- Some(c) if c.table_name == table_name && c.stat == stat => {
1291
+ Some(c) if c.stat == stat => {
1274
1292
  // Stat matches. Outside the racy window? Trust the cache.
1275
1293
  if c.snapshot_ns > stat.mtime_ns {
1276
1294
  true
@@ -1300,9 +1318,9 @@ fn reconcile_scan(
1300
1318
  }
1301
1319
 
1302
1320
  let mut deleted = Vec::new();
1303
- for (rel_path, cf) in &ctx.cached {
1304
- if !seen_paths.contains(rel_path) {
1305
- deleted.push((rel_path.clone(), cf.table_name.clone()));
1321
+ for (rel_path, table_name) in ctx.cached.keys() {
1322
+ if !seen.contains(&(rel_path.clone(), table_name.clone())) {
1323
+ deleted.push((rel_path.clone(), table_name.clone()));
1306
1324
  }
1307
1325
  }
1308
1326
 
@@ -2160,7 +2178,7 @@ mod internal_tests {
2160
2178
 
2161
2179
  let mut cached = HashMap::new();
2162
2180
  cached.insert(
2163
- "a.txt".to_string(),
2181
+ ("a.txt".to_string(), "t".to_string()),
2164
2182
  CachedFile {
2165
2183
  rel_path: "a.txt".into(),
2166
2184
  table_name: "t".into(),
@@ -2196,7 +2214,7 @@ mod internal_tests {
2196
2214
 
2197
2215
  let mut cached = HashMap::new();
2198
2216
  cached.insert(
2199
- "b.txt".to_string(),
2217
+ ("b.txt".to_string(), "t".to_string()),
2200
2218
  CachedFile {
2201
2219
  rel_path: "b.txt".into(),
2202
2220
  table_name: "t".into(),
@@ -2365,7 +2383,7 @@ mod internal_tests {
2365
2383
  // The old-row snapshot succeeds but the delete itself fails: a
2366
2384
  // trigger aborts every DELETE on items.
2367
2385
  let (_dir, db, abs, rel) = upsert_fixture();
2368
- let events = db.handle_upsert("items", &abs, &rel);
2386
+ let events = db.handle_upsert("items", &abs, &rel, &HashMap::new());
2369
2387
  assert_eq!(events.len(), 1, "fixture insert failed: {events:?}");
2370
2388
  {
2371
2389
  let guard = db.inner.db.lock().unwrap();
@@ -2389,7 +2407,7 @@ mod internal_tests {
2389
2407
  fn handle_upsert_surfaces_db_poison() {
2390
2408
  let (_dir, db, abs, rel) = upsert_fixture();
2391
2409
  poison(&db.inner.db);
2392
- let events = db.handle_upsert("items", &abs, &rel);
2410
+ let events = db.handle_upsert("items", &abs, &rel, &HashMap::new());
2393
2411
  assert_single_lock_error(&events);
2394
2412
  }
2395
2413
 
@@ -2409,7 +2427,7 @@ mod internal_tests {
2409
2427
  )
2410
2428
  .unwrap();
2411
2429
  }
2412
- let events = db.handle_upsert("items", &abs, &rel);
2430
+ let events = db.handle_upsert("items", &abs, &rel, &HashMap::new());
2413
2431
  assert_eq!(events.len(), 1, "expected one error event: {events:?}");
2414
2432
  let dbg = format!("{:?}", events[0]);
2415
2433
  assert!(dbg.contains("Error"), "expected an Error event: {dbg}");
@@ -2436,7 +2454,7 @@ mod internal_tests {
2436
2454
  )
2437
2455
  .unwrap();
2438
2456
 
2439
- let events = db.handle_upsert("files", &subdir, "subdir");
2457
+ let events = db.handle_upsert("files", &subdir, "subdir", &HashMap::new());
2440
2458
  assert!(events.is_empty(), "a directory must not produce row events");
2441
2459
  assert!(
2442
2460
  db.query("SELECT * FROM files").unwrap().is_empty(),
@@ -2448,14 +2466,14 @@ mod internal_tests {
2448
2466
  fn handle_upsert_returns_empty_when_file_vanished() {
2449
2467
  let (dir, db, _abs, _rel) = upsert_fixture();
2450
2468
  let missing = dir.path().join("gone.txt");
2451
- let events = db.handle_upsert("items", &missing, "gone.txt");
2469
+ let events = db.handle_upsert("items", &missing, "gone.txt", &HashMap::new());
2452
2470
  assert!(events.is_empty(), "vanished file must produce no events");
2453
2471
  }
2454
2472
 
2455
2473
  #[test]
2456
2474
  fn handle_upsert_returns_empty_for_unknown_table() {
2457
2475
  let (_dir, db, abs, rel) = upsert_fixture();
2458
- let events = db.handle_upsert("not_a_table", &abs, &rel);
2476
+ let events = db.handle_upsert("not_a_table", &abs, &rel, &HashMap::new());
2459
2477
  assert!(events.is_empty(), "unknown table must produce no events");
2460
2478
  }
2461
2479
 
@@ -2483,7 +2501,7 @@ mod internal_tests {
2483
2501
  )
2484
2502
  .unwrap();
2485
2503
 
2486
- let events = db.handle_upsert("items", &abs, "a.txt");
2504
+ let events = db.handle_upsert("items", &abs, "a.txt", &HashMap::new());
2487
2505
  assert_eq!(events.len(), 1, "expected one error event: {events:?}");
2488
2506
  let dbg = format!("{:?}", events[0]);
2489
2507
  assert!(dbg.contains("Error"), "expected an Error event: {dbg}");
@@ -2635,7 +2653,7 @@ mod internal_tests {
2635
2653
  #[test]
2636
2654
  fn handle_upsert_inserts_and_diffs_rows() {
2637
2655
  let (_dir, db, abs, rel) = upsert_fixture();
2638
- let events = db.handle_upsert("items", &abs, &rel);
2656
+ let events = db.handle_upsert("items", &abs, &rel, &HashMap::new());
2639
2657
  assert_eq!(events.len(), 1, "got: {events:?}");
2640
2658
  assert!(matches!(&events[0], RowEvent::Insert { .. }));
2641
2659
 
@@ -2660,7 +2678,7 @@ mod internal_tests {
2660
2678
  Arc::new(fake),
2661
2679
  )
2662
2680
  .unwrap();
2663
- let events = db.handle_upsert("items", &abs, "a.txt");
2681
+ let events = db.handle_upsert("items", &abs, "a.txt", &HashMap::new());
2664
2682
  assert_eq!(events.len(), 1, "got: {events:?}");
2665
2683
  let dbg = format!("{:?}", events[0]);
2666
2684
  assert!(dbg.contains("Error"), "got: {dbg}");
@@ -2772,7 +2790,7 @@ mod internal_tests {
2772
2790
  let fake = FakeFs::with_stat(abs.clone(), stat.clone());
2773
2791
  let mut cached = HashMap::new();
2774
2792
  cached.insert(
2775
- "a.txt".to_string(),
2793
+ ("a.txt".to_string(), "t".to_string()),
2776
2794
  CachedFile {
2777
2795
  rel_path: "a.txt".into(),
2778
2796
  table_name: "t".into(),
@@ -2804,7 +2822,7 @@ mod internal_tests {
2804
2822
  let fake = FakeFs::with_stat(abs.clone(), stat.clone());
2805
2823
  let mut cached = HashMap::new();
2806
2824
  cached.insert(
2807
- "a.txt".to_string(),
2825
+ ("a.txt".to_string(), "other".to_string()),
2808
2826
  CachedFile {
2809
2827
  rel_path: "a.txt".into(),
2810
2828
  table_name: "other".into(),
@@ -2830,7 +2848,7 @@ mod internal_tests {
2830
2848
  let dir = TempDir::new().unwrap();
2831
2849
  let mut cached = HashMap::new();
2832
2850
  cached.insert(
2833
- "gone.txt".to_string(),
2851
+ ("gone.txt".to_string(), "t".to_string()),
2834
2852
  CachedFile {
2835
2853
  rel_path: "gone.txt".into(),
2836
2854
  table_name: "t".into(),