dirsql 0.3.61__tar.gz → 0.3.62__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 (136) hide show
  1. {dirsql-0.3.61 → dirsql-0.3.62}/Cargo.lock +1 -1
  2. {dirsql-0.3.61 → dirsql-0.3.62}/PKG-INFO +1 -1
  3. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/db.rs +122 -0
  6. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/lib.rs +110 -106
  7. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/persist.rs +0 -145
  8. {dirsql-0.3.61 → dirsql-0.3.62}/Cargo.toml +0 -0
  9. {dirsql-0.3.61 → dirsql-0.3.62}/README.md +0 -0
  10. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/__init__.py +0 -0
  11. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/_async.py +0 -0
  12. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/_dirsql.pyi +0 -0
  13. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/cli/__init__.py +0 -0
  14. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/cli/binary_path.py +0 -0
  15. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/cli/interpret/__init__.py +0 -0
  16. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/cli/is_windows.py +0 -0
  17. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/cli/main.py +0 -0
  18. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/cli/resolve_config_extensions.py +0 -0
  19. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/py.typed +0 -0
  20. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/resolve_config_extensions.py +0 -0
  21. {dirsql-0.3.61 → dirsql-0.3.62}/dirsql/resolve_extension.py +0 -0
  22. {dirsql-0.3.61 → dirsql-0.3.62}/docs/.claude/CLAUDE.md +0 -0
  23. {dirsql-0.3.61 → dirsql-0.3.62}/docs/.vitepress/config.ts +0 -0
  24. {dirsql-0.3.61 → dirsql-0.3.62}/docs/.vitepress/theme/index.ts +0 -0
  25. {dirsql-0.3.61 → dirsql-0.3.62}/docs/.vitepress/theme/lang.ts +0 -0
  26. {dirsql-0.3.61 → dirsql-0.3.62}/docs/AGENTS.md +0 -0
  27. {dirsql-0.3.61 → dirsql-0.3.62}/docs/explanation.md +0 -0
  28. {dirsql-0.3.61 → dirsql-0.3.62}/docs/getting-started.md +0 -0
  29. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/columns-from-paths.md +0 -0
  30. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/define-tables.md +0 -0
  31. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/embed.md +0 -0
  32. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/extract-from-contents.md +0 -0
  33. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/load-extension.md +0 -0
  34. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/persist.md +0 -0
  35. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/react-to-changes.md +0 -0
  36. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/search-by-meaning.md +0 -0
  37. {dirsql-0.3.61 → dirsql-0.3.62}/docs/howto/skip-files.md +0 -0
  38. {dirsql-0.3.61 → dirsql-0.3.62}/docs/index.md +0 -0
  39. {dirsql-0.3.61 → dirsql-0.3.62}/docs/migrations.md +0 -0
  40. {dirsql-0.3.61 → dirsql-0.3.62}/docs/package.json +0 -0
  41. {dirsql-0.3.61 → dirsql-0.3.62}/docs/playwright.config.ts +0 -0
  42. {dirsql-0.3.61 → dirsql-0.3.62}/docs/pnpm-lock.yaml +0 -0
  43. {dirsql-0.3.61 → dirsql-0.3.62}/docs/pnpm-workspace.yaml +0 -0
  44. {dirsql-0.3.61 → dirsql-0.3.62}/docs/reference/cli.md +0 -0
  45. {dirsql-0.3.61 → dirsql-0.3.62}/docs/reference/columns.md +0 -0
  46. {dirsql-0.3.61 → dirsql-0.3.62}/docs/reference/config.md +0 -0
  47. {dirsql-0.3.61 → dirsql-0.3.62}/docs/reference/hooks.md +0 -0
  48. {dirsql-0.3.61 → dirsql-0.3.62}/docs/reference/http-api.md +0 -0
  49. {dirsql-0.3.61 → dirsql-0.3.62}/docs/reference/sdk.md +0 -0
  50. {dirsql-0.3.61 → dirsql-0.3.62}/docs/tests/integration/home.spec.ts +0 -0
  51. {dirsql-0.3.61 → dirsql-0.3.62}/docs/tests/integration/language-flag.spec.ts +0 -0
  52. {dirsql-0.3.61 → dirsql-0.3.62}/docs/tests/integration/sidebar.spec.ts +0 -0
  53. {dirsql-0.3.61 → dirsql-0.3.62}/docs/tests/unit/config.test.ts +0 -0
  54. {dirsql-0.3.61 → dirsql-0.3.62}/docs/tests/unit/lang.test.ts +0 -0
  55. {dirsql-0.3.61 → dirsql-0.3.62}/docs/vitest.config.ts +0 -0
  56. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/README.md +0 -0
  57. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/conftest.py +0 -0
  58. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/.claude/CLAUDE.md +0 -0
  59. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/.vitepress/config.ts +0 -0
  60. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  61. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  62. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/AGENTS.md +0 -0
  63. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/explanation.md +0 -0
  64. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/getting-started.md +0 -0
  65. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/columns-from-paths.md +0 -0
  66. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/define-tables.md +0 -0
  67. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/embed.md +0 -0
  68. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/extract-from-contents.md +0 -0
  69. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/load-extension.md +0 -0
  70. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/persist.md +0 -0
  71. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/react-to-changes.md +0 -0
  72. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/search-by-meaning.md +0 -0
  73. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/howto/skip-files.md +0 -0
  74. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/index.md +0 -0
  75. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/migrations.md +0 -0
  76. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/package.json +0 -0
  77. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/playwright.config.ts +0 -0
  78. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/pnpm-lock.yaml +0 -0
  79. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/pnpm-workspace.yaml +0 -0
  80. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/reference/cli.md +0 -0
  81. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/reference/columns.md +0 -0
  82. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/reference/config.md +0 -0
  83. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/reference/hooks.md +0 -0
  84. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/reference/http-api.md +0 -0
  85. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/reference/sdk.md +0 -0
  86. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  87. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  88. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  89. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/tests/unit/config.test.ts +0 -0
  90. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  91. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/docs/vitest.config.ts +0 -0
  92. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/src/lib.rs +0 -0
  93. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/tests/__init__.py +0 -0
  94. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/tests/binding/__init__.py +0 -0
  95. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/tests/conftest.py +0 -0
  96. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/tests/e2e/__init__.py +0 -0
  97. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/tests/integration/__init__.py +0 -0
  98. {dirsql-0.3.61 → dirsql-0.3.62}/packages/python/tests/smoke/__init__.py +0 -0
  99. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/Cargo.toml +0 -0
  100. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/README.md +0 -0
  101. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/benches/db_bench.rs +0 -0
  102. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/benches/differ_bench.rs +0 -0
  103. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/benches/matcher_bench.rs +0 -0
  104. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/benches/scanner_bench.rs +0 -0
  105. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/explanation.md +0 -0
  106. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/getting-started.md +0 -0
  107. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/columns-from-paths.md +0 -0
  108. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/define-tables.md +0 -0
  109. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/embed.md +0 -0
  110. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/extract-from-contents.md +0 -0
  111. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/load-extension.md +0 -0
  112. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/persist.md +0 -0
  113. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/react-to-changes.md +0 -0
  114. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/search-by-meaning.md +0 -0
  115. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/howto/skip-files.md +0 -0
  116. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/index.md +0 -0
  117. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/migrations.md +0 -0
  118. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/reference/cli.md +0 -0
  119. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/reference/columns.md +0 -0
  120. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/reference/config.md +0 -0
  121. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/reference/hooks.md +0 -0
  122. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/reference/http-api.md +0 -0
  123. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/docs/reference/sdk.md +0 -0
  124. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/bin/dirsql.rs +0 -0
  125. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/cli/init.rs +0 -0
  126. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/cli/mod.rs +0 -0
  127. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/cli/router.rs +0 -0
  128. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/cli/serialize.rs +0 -0
  129. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/cli/server.rs +0 -0
  130. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/command.rs +0 -0
  131. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/config.rs +0 -0
  132. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/differ.rs +0 -0
  133. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/matcher.rs +0 -0
  134. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/scanner.rs +0 -0
  135. {dirsql-0.3.61 → dirsql-0.3.62}/packages/rust/src/watcher.rs +0 -0
  136. {dirsql-0.3.61 → dirsql-0.3.62}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.61"
503
+ version = "0.3.62"
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.61
3
+ Version: 0.3.62
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.61"
7
+ version = "0.3.62"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "command": "uv run python -m pytest tests/e2e/ -x -q",
3
- "ran_at": 1783171803,
3
+ "ran_at": 1783182399,
4
4
  "exit_code": 0,
5
- "commit": "86cf2578852a1dec1149453a8af60578a16dd69c"
5
+ "commit": "f26da60822c101e45dee58c80d7b6cbd2d4a0646"
6
6
  }
@@ -312,6 +312,56 @@ impl Db {
312
312
  Ok(())
313
313
  }
314
314
 
315
+ /// Read back the rows a given file produced for `table`, ordered by row
316
+ /// index. Ownership and ordering come from the `_dirsql_internal_rows`
317
+ /// mapping (joined on `rowid`); user columns are qualified with the table
318
+ /// alias so a user column named like a mapping column stays unambiguous.
319
+ ///
320
+ /// This is the watcher's diffing source (#401): `handle_upsert` /
321
+ /// `handle_delete` snapshot a file's previous rows here instead of holding
322
+ /// a corpus-wide in-memory copy. A row read back compares equal to the
323
+ /// normalized row that was inserted as long as the extract's value types
324
+ /// match the declared column affinities (SQLite coerces on insert
325
+ /// otherwise, e.g. `Integer(5)` into a TEXT column comes back
326
+ /// `Text("5")`).
327
+ pub fn get_rows_by_file(
328
+ &self,
329
+ table: &str,
330
+ file_path: &str,
331
+ ) -> Result<Vec<HashMap<String, Value>>> {
332
+ let user_columns = self.get_table_columns(table)?;
333
+ let mut col_list = user_columns
334
+ .iter()
335
+ .map(|c| format!("t.\"{c}\""))
336
+ .collect::<Vec<_>>()
337
+ .join(", ");
338
+ if col_list.is_empty() {
339
+ // No columns can only happen for a nonexistent table (SQLite
340
+ // requires ≥1 declared column); keep the SELECT list valid and
341
+ // let SQLite report "no such table".
342
+ col_list = "1".to_string();
343
+ }
344
+ let sql = format!(
345
+ "SELECT {col_list} FROM \"{table}\" AS t \
346
+ JOIN _dirsql_internal_rows AS m ON m.rowid_ref = t.rowid \
347
+ WHERE m.table_name = ?1 AND m.file_path = ?2 ORDER BY m.row_index"
348
+ );
349
+ let mut stmt = self.conn.prepare(&sql)?;
350
+ let rows = stmt.query_map(rusqlite::params![table, file_path], |row| {
351
+ let mut map = HashMap::new();
352
+ for (i, name) in user_columns.iter().enumerate() {
353
+ let v: rusqlite::types::Value = row.get(i)?;
354
+ map.insert(name.clone(), Value::from(v));
355
+ }
356
+ Ok(map)
357
+ })?;
358
+ let mut out = Vec::new();
359
+ for row in rows {
360
+ out.push(row?);
361
+ }
362
+ Ok(out)
363
+ }
364
+
315
365
  /// Delete all rows that were produced by a given file path.
316
366
  ///
317
367
  /// The user-row deletes and the matching `_dirsql_internal_rows` mapping
@@ -1206,6 +1256,78 @@ mod tests {
1206
1256
  assert_eq!(deleted, 0);
1207
1257
  }
1208
1258
 
1259
+ // --- get_rows_by_file: the watcher's diffing source (#401) ---
1260
+
1261
+ #[test]
1262
+ fn get_rows_by_file_returns_rows_in_row_index_order() {
1263
+ let db = Db::new().unwrap();
1264
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1265
+ // Insert with row indices out of insertion order; the read must
1266
+ // follow row_index, not rowid / insertion order.
1267
+ let second = HashMap::from([("id".into(), Value::Text("second".into()))]);
1268
+ let first = HashMap::from([("id".into(), Value::Text("first".into()))]);
1269
+ db.insert_row("t", &second, "a.json", 1).unwrap();
1270
+ db.insert_row("t", &first, "a.json", 0).unwrap();
1271
+
1272
+ let rows = db.get_rows_by_file("t", "a.json").unwrap();
1273
+ assert_eq!(rows.len(), 2);
1274
+ assert_eq!(rows[0]["id"], Value::Text("first".into()));
1275
+ assert_eq!(rows[1]["id"], Value::Text("second".into()));
1276
+ }
1277
+
1278
+ #[test]
1279
+ fn get_rows_by_file_scopes_by_file_and_table() {
1280
+ let db = Db::new().unwrap();
1281
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1282
+ db.create_table("CREATE TABLE u (id TEXT)").unwrap();
1283
+ let t_row = HashMap::from([("id".into(), Value::Text("t-row".into()))]);
1284
+ let u_row = HashMap::from([("id".into(), Value::Text("u-row".into()))]);
1285
+ let other = HashMap::from([("id".into(), Value::Text("other-file".into()))]);
1286
+ db.insert_row("t", &t_row, "a.json", 0).unwrap();
1287
+ db.insert_row("u", &u_row, "a.json", 0).unwrap();
1288
+ db.insert_row("t", &other, "b.json", 0).unwrap();
1289
+
1290
+ let rows = db.get_rows_by_file("t", "a.json").unwrap();
1291
+ assert_eq!(rows.len(), 1);
1292
+ assert_eq!(rows[0]["id"], Value::Text("t-row".into()));
1293
+
1294
+ // A file with no rows in this table reads back empty.
1295
+ let none = db.get_rows_by_file("u", "b.json").unwrap();
1296
+ assert!(none.is_empty());
1297
+ }
1298
+
1299
+ #[test]
1300
+ fn get_rows_by_file_round_trips_all_value_variants() {
1301
+ // The watcher's positional diff compares a read-back row against a
1302
+ // freshly normalized one, so every Value variant must survive
1303
+ // insert -> select unchanged when the declared column affinity
1304
+ // matches the value type.
1305
+ let db = Db::new().unwrap();
1306
+ db.create_table("CREATE TABLE t (i INTEGER, r REAL, s TEXT, b BLOB, n TEXT)")
1307
+ .unwrap();
1308
+ let row = HashMap::from([
1309
+ ("i".to_string(), Value::Integer(42)),
1310
+ ("r".to_string(), Value::Real(1.5)),
1311
+ ("s".to_string(), Value::Text("hello".into())),
1312
+ ("b".to_string(), Value::Blob(vec![0, 1, 2])),
1313
+ ("n".to_string(), Value::Null),
1314
+ ]);
1315
+ db.insert_row("t", &row, "a.json", 0).unwrap();
1316
+
1317
+ let rows = db.get_rows_by_file("t", "a.json").unwrap();
1318
+ assert_eq!(rows.len(), 1);
1319
+ assert_eq!(rows[0], row);
1320
+ }
1321
+
1322
+ #[test]
1323
+ fn get_rows_by_file_missing_table_returns_error() {
1324
+ // A nonexistent table has no columns, so the SELECT list falls back
1325
+ // to `1` and SQLite reports the missing table.
1326
+ let db = Db::new().unwrap();
1327
+ let err = db.get_rows_by_file("ghost", "a.json").unwrap_err();
1328
+ assert!(err.to_string().contains("no such table"), "got: {err}");
1329
+ }
1330
+
1209
1331
  // --- Error path: Db::open on an unopenable path ---
1210
1332
 
1211
1333
  #[test]
@@ -33,8 +33,8 @@ use crate::matcher::TableMatcher;
33
33
  use crate::persist::{
34
34
  CachedFile, FileStat, build_meta, canonical_root, compute_glob_config_hash,
35
35
  create_sidecar_tables, delete_file as cache_delete_file, drop_user_tables, ensure_parent_dir,
36
- hash_file, meta_is_compatible, now_ns, read_cached_files, read_meta, read_rows_for_file,
37
- resolve_persist_path, upsert_file, write_meta,
36
+ hash_file, meta_is_compatible, now_ns, read_cached_files, read_meta, resolve_persist_path,
37
+ upsert_file, write_meta,
38
38
  };
39
39
  use crate::scanner::scan_directory;
40
40
  use crate::watcher::{FileEvent, Watcher};
@@ -249,8 +249,6 @@ struct DirSqlInner {
249
249
  extract_map: HashMap<String, Arc<ExtractFn>>,
250
250
  /// Table name -> strict flag, resolved once.
251
251
  strict_map: HashMap<String, bool>,
252
- /// Cached rows per file path for positional diffing on modify/delete.
253
- file_rows: Mutex<HashMap<String, (String, Vec<Row>)>>,
254
252
  /// Lazily-created filesystem watcher, shared by both the polling API
255
253
  /// ([`DirSQL::poll_events`]) and the channel-based API ([`DirSQL::watch`]).
256
254
  watcher: Mutex<Option<Watcher>>,
@@ -502,23 +500,26 @@ impl DirSQL {
502
500
  }
503
501
 
504
502
  fn handle_delete(&self, table: &str, rel_path: &str) -> Vec<RowEvent> {
505
- let old_rows = match self.inner.file_rows.lock() {
506
- Ok(mut file_rows) => file_rows.remove(rel_path).map(|(_, r)| r),
507
- Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
508
- };
509
-
510
- let row_events = differ::diff(table, old_rows.as_deref(), None, rel_path);
511
-
512
- let delete_result = match self.inner.db.lock() {
513
- Ok(db) => db.delete_rows_by_file(table, rel_path),
514
- Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
503
+ // Snapshot the file's rows from the row store before deleting them —
504
+ // the Delete events carry the old-row payloads. Read and delete under
505
+ // one lock acquisition so a concurrent event for the same file can't
506
+ // interleave between them.
507
+ let old_rows = {
508
+ let db = match self.inner.db.lock() {
509
+ Ok(db) => db,
510
+ Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
511
+ };
512
+ let old_rows = match db.get_rows_by_file(table, rel_path) {
513
+ Ok(rows) => rows,
514
+ Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
515
+ };
516
+ if let Err(e) = db.delete_rows_by_file(table, rel_path) {
517
+ return vec![error_event(Some(table), rel_path, e.to_string())];
518
+ }
519
+ old_rows
515
520
  };
516
521
 
517
- if let Err(e) = delete_result {
518
- return vec![error_event(Some(table), rel_path, e.to_string())];
519
- }
520
-
521
- row_events
522
+ differ::diff(table, Some(&old_rows), None, rel_path)
522
523
  }
523
524
 
524
525
  fn handle_upsert(&self, table: &str, abs_path: &Path, rel_path: &str) -> Vec<RowEvent> {
@@ -549,7 +550,11 @@ impl DirSQL {
549
550
 
550
551
  let strict = *self.inner.strict_map.get(table).unwrap_or(&false);
551
552
 
552
- let new_rows = {
553
+ // Normalize the new rows, snapshot the file's previous rows from the
554
+ // row store, and apply the delete+insert — all under one lock
555
+ // acquisition, so a concurrent event for the same file can't
556
+ // interleave between the read and the write.
557
+ let (old_rows, new_rows) = {
553
558
  let db = match self.inner.db.lock() {
554
559
  Ok(g) => g,
555
560
  Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
@@ -559,42 +564,33 @@ impl DirSQL {
559
564
  Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
560
565
  };
561
566
  let raw_rows = merge_filesystem_facts(raw_rows, &captures, &stat, &declared_columns);
562
- let mut normalized = Vec::with_capacity(raw_rows.len());
567
+ let mut new_rows = Vec::with_capacity(raw_rows.len());
563
568
  for raw in &raw_rows {
564
569
  match db.normalize_row(table, raw, strict) {
565
- Ok(row) => normalized.push(row),
570
+ Ok(row) => new_rows.push(row),
566
571
  Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
567
572
  }
568
573
  }
569
- normalized
570
- };
571
574
 
572
- let old_rows = match self.inner.file_rows.lock() {
573
- Ok(guard) => guard.get(rel_path).map(|(_, r)| r.clone()),
574
- Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
575
- };
576
-
577
- let row_events = differ::diff(table, old_rows.as_deref(), Some(&new_rows), rel_path);
575
+ let old_rows = match db.get_rows_by_file(table, rel_path) {
576
+ Ok(rows) => rows,
577
+ Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
578
+ };
578
579
 
579
- let db_result = match self.inner.db.lock() {
580
- Ok(db) => db.delete_rows_by_file(table, rel_path).and_then(|_| {
580
+ let db_result = db.delete_rows_by_file(table, rel_path).and_then(|_| {
581
581
  for (i, row) in new_rows.iter().enumerate() {
582
582
  db.insert_row(table, row, rel_path, i)?;
583
583
  }
584
584
  Ok(())
585
- }),
586
- Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
587
- };
588
-
589
- if let Err(e) = db_result {
590
- return vec![error_event(Some(table), rel_path, e.to_string())];
591
- }
585
+ });
586
+ if let Err(e) = db_result {
587
+ return vec![error_event(Some(table), rel_path, e.to_string())];
588
+ }
592
589
 
593
- if let Ok(mut guard) = self.inner.file_rows.lock() {
594
- guard.insert(rel_path.to_string(), (table.to_string(), new_rows));
595
- }
590
+ (old_rows, new_rows)
591
+ };
596
592
 
597
- row_events
593
+ differ::diff(table, Some(&old_rows), Some(&new_rows), rel_path)
598
594
  }
599
595
 
600
596
  pub(crate) fn build_from_resolved(resolved: ResolvedBuild) -> Result<Self> {
@@ -674,7 +670,7 @@ impl DirSQL {
674
670
  // Build the list of files needing re-parse. When persist is
675
671
  // enabled, files whose stat tuple matches the cache (and that pass
676
672
  // the racy-window check) are trusted instead of re-parsed.
677
- let (scanned_files, trusted, deleted) = match &persist_ctx {
673
+ let (scanned_files, _trusted, deleted) = match &persist_ctx {
678
674
  None => {
679
675
  let mut files = Vec::with_capacity(scanned.len());
680
676
  for (path, table_name) in scanned {
@@ -699,10 +695,8 @@ impl DirSQL {
699
695
  scanned_files,
700
696
  persist: persist_ctx.map(|ctx| PreparedPersist {
701
697
  db: ctx.db,
702
- trusted,
703
698
  deleted,
704
699
  meta: ctx.expected_meta,
705
- cold_rebuild: ctx.cold_rebuild,
706
700
  }),
707
701
  poll_interval,
708
702
  })
@@ -740,7 +734,7 @@ impl DirSQL {
740
734
  } = prepared;
741
735
 
742
736
  let (db, persist_ready) = match persist {
743
- Some(p) => (p.db, Some((p.trusted, p.deleted, p.meta, p.cold_rebuild))),
737
+ Some(p) => (p.db, Some((p.deleted, p.meta))),
744
738
  None => (Db::new()?, None),
745
739
  };
746
740
 
@@ -776,19 +770,11 @@ impl DirSQL {
776
770
  ddl_map.insert(table_name, table.ddl);
777
771
  }
778
772
 
779
- let mut file_rows: HashMap<String, (String, Vec<Row>)> = HashMap::new();
780
-
781
- // First, apply trusted-file rebuilds of the in-memory file_rows
782
- // cache from the on-disk SQLite. These files are NOT re-parsed.
783
- if let Some((trusted, deleted, _, _)) = persist_ready.as_ref() {
784
- for tf in trusted {
785
- let user_columns = db.get_table_columns(&tf.table_name).map_err(map_db_error)?;
786
- let rows =
787
- read_rows_for_file(db.conn(), &tf.table_name, &tf.rel_path, &user_columns)
788
- .map_err(DirSqlError::sqlite)?;
789
- file_rows.insert(tf.rel_path.clone(), (tf.table_name.clone(), rows));
790
- }
791
-
773
+ // Drop cached rows for files that disappeared since the last cache
774
+ // write. Trusted files need no work at all: their rows already live
775
+ // in the on-disk SQLite, and the watcher's diffing path reads a
776
+ // file's previous rows back from the row store on demand (#401).
777
+ if let Some((deleted, _)) = persist_ready.as_ref() {
792
778
  for (rel_path, table_name) in deleted {
793
779
  db.delete_rows_by_file(table_name, rel_path)
794
780
  .map_err(map_db_error)?;
@@ -824,7 +810,6 @@ impl DirSQL {
824
810
  let raw_rows =
825
811
  merge_filesystem_facts(raw_rows, &captures, &stat_virtuals, &declared_columns);
826
812
 
827
- let mut rows = Vec::with_capacity(raw_rows.len());
828
813
  // When updating an existing file in the persistent cache, drop
829
814
  // its old rows before inserting the new ones.
830
815
  if persist_ready.is_some() {
@@ -834,7 +819,6 @@ impl DirSQL {
834
819
  for (row_index, raw_row) in raw_rows.iter().enumerate() {
835
820
  let row = db.normalize_row(&table_name, raw_row, strict)?;
836
821
  db.insert_row(&table_name, &row, &rel_path, row_index)?;
837
- rows.push(row);
838
822
  }
839
823
 
840
824
  // Update the persistent file index after a successful parse.
@@ -852,13 +836,11 @@ impl DirSQL {
852
836
  )
853
837
  .map_err(DirSqlError::sqlite)?;
854
838
  }
855
-
856
- file_rows.insert(rel_path, (table_name, rows));
857
839
  }
858
840
 
859
841
  // Write the meta block last so a crash mid-build leaves an
860
842
  // incompatible cache that is detected on the next startup.
861
- if let Some((_, _, meta, _)) = persist_ready.as_ref() {
843
+ if let Some((_, meta)) = persist_ready.as_ref() {
862
844
  write_meta(db.conn(), meta).map_err(DirSqlError::sqlite)?;
863
845
  }
864
846
 
@@ -876,7 +858,6 @@ impl DirSQL {
876
858
  matcher,
877
859
  extract_map,
878
860
  strict_map,
879
- file_rows: Mutex::new(file_rows),
880
861
  watcher: Mutex::new(None),
881
862
  poll_used: AtomicBool::new(false),
882
863
  watch_thread_started: AtomicBool::new(false),
@@ -1211,12 +1192,14 @@ pub struct PreparedBuild {
1211
1192
  #[doc(hidden)]
1212
1193
  pub struct PreparedPersist {
1213
1194
  db: Db,
1214
- trusted: Vec<TrustedFile>,
1215
1195
  deleted: Vec<(String, String)>,
1216
1196
  meta: HashMap<String, String>,
1217
- cold_rebuild: bool,
1218
1197
  }
1219
1198
 
1199
+ /// A file the reconcile decided to trust: its cached rows are kept as-is and
1200
+ /// the file is not re-parsed. Production consumes the decision implicitly
1201
+ /// (trusted files are excluded from the re-parse list); the list itself is
1202
+ /// returned so tests can assert on the trust decision directly.
1220
1203
  #[doc(hidden)]
1221
1204
  pub struct TrustedFile {
1222
1205
  pub rel_path: String,
@@ -1228,7 +1211,6 @@ struct PersistContext {
1228
1211
  db: Db,
1229
1212
  cached: HashMap<String, CachedFile>,
1230
1213
  expected_meta: HashMap<String, String>,
1231
- cold_rebuild: bool,
1232
1214
  }
1233
1215
 
1234
1216
  fn compile_matcher(
@@ -1263,8 +1245,8 @@ fn compile_matcher(
1263
1245
 
1264
1246
  /// Open (or create) the persistent SQLite cache and read its meta. If the
1265
1247
  /// meta is missing or incompatible with the current build, the cache is
1266
- /// wiped and the resulting [`PersistContext`] reports `cold_rebuild = true`
1267
- /// so the rest of the pipeline knows to treat every file as new.
1248
+ /// wiped and the resulting [`PersistContext`] carries an empty file index,
1249
+ /// so the rest of the pipeline treats every file as new.
1268
1250
  fn prepare_persist(
1269
1251
  root: &Path,
1270
1252
  tables: &[Table],
@@ -1284,19 +1266,17 @@ fn prepare_persist(
1284
1266
  let cached_meta = read_meta(db.conn()).map_err(DirSqlError::sqlite)?;
1285
1267
  let compatible = !cached_meta.is_empty() && meta_is_compatible(&cached_meta, &expected_meta);
1286
1268
 
1287
- let (cached, cold_rebuild) = if compatible {
1288
- let files = read_cached_files(db.conn()).map_err(DirSqlError::sqlite)?;
1289
- (files, false)
1269
+ let cached = if compatible {
1270
+ read_cached_files(db.conn()).map_err(DirSqlError::sqlite)?
1290
1271
  } else {
1291
1272
  drop_user_tables(db.conn()).map_err(DirSqlError::sqlite)?;
1292
- (HashMap::new(), true)
1273
+ HashMap::new()
1293
1274
  };
1294
1275
 
1295
1276
  Ok(PersistContext {
1296
1277
  db,
1297
1278
  cached,
1298
1279
  expected_meta,
1299
- cold_rebuild,
1300
1280
  })
1301
1281
  }
1302
1282
 
@@ -2324,7 +2304,6 @@ mod internal_tests {
2324
2304
  db: Db::new().unwrap(),
2325
2305
  cached,
2326
2306
  expected_meta: HashMap::new(),
2327
- cold_rebuild: false,
2328
2307
  };
2329
2308
  let scanned = vec![(abs.clone(), "t".to_string())];
2330
2309
  let (to_parse, trusted, deleted) =
@@ -2363,7 +2342,6 @@ mod internal_tests {
2363
2342
  db: Db::new().unwrap(),
2364
2343
  cached,
2365
2344
  expected_meta: HashMap::new(),
2366
- cold_rebuild: false,
2367
2345
  };
2368
2346
  let scanned = vec![(abs.clone(), "t".to_string())];
2369
2347
  let (to_parse, trusted, _deleted) =
@@ -2381,7 +2359,6 @@ mod internal_tests {
2381
2359
  db: Db::new().unwrap(),
2382
2360
  cached: HashMap::new(),
2383
2361
  expected_meta: HashMap::new(),
2384
- cold_rebuild: false,
2385
2362
  };
2386
2363
  let missing = dir.path().join("ghost.txt");
2387
2364
  let scanned = vec![(missing, "t".to_string())];
@@ -2527,19 +2504,9 @@ mod internal_tests {
2527
2504
  assert!(dbg.contains("poisoned lock"), "expected poison text: {dbg}");
2528
2505
  }
2529
2506
 
2530
- #[test]
2531
- fn handle_delete_surfaces_file_rows_poison() {
2532
- let (_dir, db, _abs, rel) = upsert_fixture();
2533
- poison(&db.inner.file_rows);
2534
- let events = db.handle_delete("items", &rel);
2535
- assert_single_lock_error(&events);
2536
- }
2537
-
2538
2507
  #[test]
2539
2508
  fn handle_delete_surfaces_db_poison() {
2540
2509
  let (_dir, db, _abs, rel) = upsert_fixture();
2541
- // Only the db mutex is poisoned; the file_rows lock succeeds first,
2542
- // so the error comes from the db-lock arm.
2543
2510
  poison(&db.inner.db);
2544
2511
  let events = db.handle_delete("items", &rel);
2545
2512
  assert_single_lock_error(&events);
@@ -2548,9 +2515,9 @@ mod internal_tests {
2548
2515
  #[test]
2549
2516
  fn handle_delete_surfaces_db_failure() {
2550
2517
  let (_dir, db, _abs, _rel) = upsert_fixture();
2551
- // No SQL table named `ghost` exists, so `delete_rows_by_file` issues a
2552
- // `DELETE FROM ghost ...` that fails with "no such table". That drives
2553
- // the `delete_result` Err arm of `handle_delete`.
2518
+ // No SQL table named `ghost` exists, so the old-row snapshot issues a
2519
+ // `SELECT ... FROM "ghost" ...` that fails with "no such table". That
2520
+ // drives the get_rows_by_file Err arm of `handle_delete`.
2554
2521
  let events = db.handle_delete("ghost", "whatever.txt");
2555
2522
  assert_eq!(events.len(), 1, "expected one error event: {events:?}");
2556
2523
  let dbg = format!("{:?}", events[0]);
@@ -2558,6 +2525,33 @@ mod internal_tests {
2558
2525
  assert!(dbg.contains("no such table"), "expected a SQL error: {dbg}");
2559
2526
  }
2560
2527
 
2528
+ #[test]
2529
+ fn handle_delete_surfaces_delete_failure() {
2530
+ // The old-row snapshot succeeds but the delete itself fails: a trigger
2531
+ // aborts every DELETE on items, driving `handle_delete`'s
2532
+ // delete_rows_by_file Err arm (the snapshot read runs first, so the
2533
+ // ghost-table test above no longer reaches it).
2534
+ let (_dir, db, abs, rel) = upsert_fixture();
2535
+ let events = db.handle_upsert("items", &abs, &rel);
2536
+ assert_eq!(events.len(), 1, "fixture insert failed: {events:?}");
2537
+ {
2538
+ let guard = db.inner.db.lock().unwrap();
2539
+ guard
2540
+ .conn()
2541
+ .execute(
2542
+ "CREATE TRIGGER items_no_delete BEFORE DELETE ON items \
2543
+ BEGIN SELECT RAISE(ABORT, 'delete forbidden by test trigger'); END",
2544
+ [],
2545
+ )
2546
+ .unwrap();
2547
+ }
2548
+ let events = db.handle_delete("items", &rel);
2549
+ assert_eq!(events.len(), 1, "expected one error event: {events:?}");
2550
+ let dbg = format!("{:?}", events[0]);
2551
+ assert!(dbg.contains("Error"), "expected an Error event: {dbg}");
2552
+ assert!(dbg.contains("delete forbidden"), "got: {dbg}");
2553
+ }
2554
+
2561
2555
  #[test]
2562
2556
  fn handle_upsert_surfaces_db_poison() {
2563
2557
  let (_dir, db, abs, rel) = upsert_fixture();
@@ -2567,13 +2561,27 @@ mod internal_tests {
2567
2561
  }
2568
2562
 
2569
2563
  #[test]
2570
- fn handle_upsert_surfaces_file_rows_poison() {
2564
+ fn handle_upsert_surfaces_insert_failure() {
2565
+ // Normalize and the old-row snapshot succeed, but the write-back
2566
+ // fails: a trigger aborts every INSERT on items, driving
2567
+ // `handle_upsert`'s db_result Err arm.
2571
2568
  let (_dir, db, abs, rel) = upsert_fixture();
2572
- // db is healthy, so metadata/extract/get_table_columns/normalize all
2573
- // succeed and the error originates at the file_rows-lock arm.
2574
- poison(&db.inner.file_rows);
2569
+ {
2570
+ let guard = db.inner.db.lock().unwrap();
2571
+ guard
2572
+ .conn()
2573
+ .execute(
2574
+ "CREATE TRIGGER items_no_insert BEFORE INSERT ON items \
2575
+ BEGIN SELECT RAISE(ABORT, 'insert forbidden by test trigger'); END",
2576
+ [],
2577
+ )
2578
+ .unwrap();
2579
+ }
2575
2580
  let events = db.handle_upsert("items", &abs, &rel);
2576
- assert_single_lock_error(&events);
2581
+ assert_eq!(events.len(), 1, "expected one error event: {events:?}");
2582
+ let dbg = format!("{:?}", events[0]);
2583
+ assert!(dbg.contains("Error"), "expected an Error event: {dbg}");
2584
+ assert!(dbg.contains("insert forbidden"), "got: {dbg}");
2577
2585
  }
2578
2586
 
2579
2587
  // ----- handle_upsert clean early-returns --------------------------------
@@ -2807,10 +2815,10 @@ mod internal_tests {
2807
2815
  // ----- handle_upsert success + extract error ---------------------------
2808
2816
 
2809
2817
  /// The full success path of `handle_upsert`: stat OK, extract runs, columns
2810
- /// resolved, rows normalized and inserted, cache updated. Returns one
2811
- /// Insert; the cached row then diffs to a Delete on removal.
2818
+ /// resolved, rows normalized and inserted. Returns one Insert; the stored
2819
+ /// row is then read back from SQLite and diffs to a Delete on removal.
2812
2820
  #[test]
2813
- fn handle_upsert_inserts_and_caches_rows() {
2821
+ fn handle_upsert_inserts_and_diffs_rows() {
2814
2822
  let (_dir, db, abs, rel) = upsert_fixture();
2815
2823
  let events = db.handle_upsert("items", &abs, &rel);
2816
2824
  assert_eq!(events.len(), 1, "got: {events:?}");
@@ -2909,14 +2917,13 @@ mod internal_tests {
2909
2917
  assert!(second.query("SELECT * FROM t").is_ok());
2910
2918
  }
2911
2919
 
2912
- /// `prepare_persist` on a fresh cache reports a cold rebuild with an empty
2913
- /// file index and a populated expected-meta map.
2920
+ /// `prepare_persist` on a fresh cache reports a cold rebuild: an empty
2921
+ /// file index (so every file re-parses) and a populated expected-meta map.
2914
2922
  #[test]
2915
2923
  fn prepare_persist_cold_start_reports_rebuild() {
2916
2924
  let dir = TempDir::new().unwrap();
2917
2925
  let tables = vec![Table::new("CREATE TABLE t (x TEXT)", "*.txt", |_| vec![])];
2918
2926
  let ctx = prepare_persist(dir.path(), &tables, &[], None).unwrap();
2919
- assert!(ctx.cold_rebuild);
2920
2927
  assert!(ctx.cached.is_empty());
2921
2928
  assert!(!ctx.expected_meta.is_empty());
2922
2929
  }
@@ -2946,7 +2953,6 @@ mod internal_tests {
2946
2953
  db: Db::new().unwrap(),
2947
2954
  cached,
2948
2955
  expected_meta: HashMap::new(),
2949
- cold_rebuild: false,
2950
2956
  };
2951
2957
  let scanned = vec![(abs, "t".to_string())];
2952
2958
  let (to_parse, trusted, deleted) =
@@ -2979,7 +2985,6 @@ mod internal_tests {
2979
2985
  db: Db::new().unwrap(),
2980
2986
  cached,
2981
2987
  expected_meta: HashMap::new(),
2982
- cold_rebuild: false,
2983
2988
  };
2984
2989
  let scanned = vec![(abs, "t".to_string())];
2985
2990
  let (to_parse, trusted, _deleted) =
@@ -3007,7 +3012,6 @@ mod internal_tests {
3007
3012
  db: Db::new().unwrap(),
3008
3013
  cached,
3009
3014
  expected_meta: HashMap::new(),
3010
- cold_rebuild: false,
3011
3015
  };
3012
3016
  let fake = FakeFs::default();
3013
3017
  let (to_parse, trusted, deleted) =