dirsql 0.3.56__tar.gz → 0.3.58__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 (124) hide show
  1. {dirsql-0.3.56 → dirsql-0.3.58}/Cargo.lock +1 -1
  2. {dirsql-0.3.56 → dirsql-0.3.58}/PKG-INFO +1 -1
  3. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/cli/init.rs +17 -0
  6. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/cli/server.rs +29 -0
  7. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/command.rs +99 -0
  8. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/db.rs +77 -2
  9. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/lib.rs +559 -0
  10. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/persist.rs +146 -6
  11. {dirsql-0.3.56 → dirsql-0.3.58}/Cargo.toml +0 -0
  12. {dirsql-0.3.56 → dirsql-0.3.58}/README.md +0 -0
  13. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/__init__.py +0 -0
  14. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/_async.py +0 -0
  15. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/_dirsql.pyi +0 -0
  16. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/cli/__init__.py +0 -0
  17. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/cli/binary_path.py +0 -0
  18. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/cli/interpret/__init__.py +0 -0
  19. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/cli/is_windows.py +0 -0
  20. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/cli/main.py +0 -0
  21. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/cli/resolve_config_extensions.py +0 -0
  22. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/py.typed +0 -0
  23. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/resolve_config_extensions.py +0 -0
  24. {dirsql-0.3.56 → dirsql-0.3.58}/dirsql/resolve_extension.py +0 -0
  25. {dirsql-0.3.56 → dirsql-0.3.58}/docs/.claude/CLAUDE.md +0 -0
  26. {dirsql-0.3.56 → dirsql-0.3.58}/docs/.vitepress/config.ts +0 -0
  27. {dirsql-0.3.56 → dirsql-0.3.58}/docs/.vitepress/theme/index.ts +0 -0
  28. {dirsql-0.3.56 → dirsql-0.3.58}/docs/.vitepress/theme/lang.ts +0 -0
  29. {dirsql-0.3.56 → dirsql-0.3.58}/docs/AGENTS.md +0 -0
  30. {dirsql-0.3.56 → dirsql-0.3.58}/docs/api/index.md +0 -0
  31. {dirsql-0.3.56 → dirsql-0.3.58}/docs/cli/config.md +0 -0
  32. {dirsql-0.3.56 → dirsql-0.3.58}/docs/cli/http-api.md +0 -0
  33. {dirsql-0.3.56 → dirsql-0.3.58}/docs/cli/index.md +0 -0
  34. {dirsql-0.3.56 → dirsql-0.3.58}/docs/cli/init.md +0 -0
  35. {dirsql-0.3.56 → dirsql-0.3.58}/docs/cli/server.md +0 -0
  36. {dirsql-0.3.56 → dirsql-0.3.58}/docs/getting-started.md +0 -0
  37. {dirsql-0.3.56 → dirsql-0.3.58}/docs/guide/async.md +0 -0
  38. {dirsql-0.3.56 → dirsql-0.3.58}/docs/guide/crdt.md +0 -0
  39. {dirsql-0.3.56 → dirsql-0.3.58}/docs/guide/persistence.md +0 -0
  40. {dirsql-0.3.56 → dirsql-0.3.58}/docs/guide/querying.md +0 -0
  41. {dirsql-0.3.56 → dirsql-0.3.58}/docs/guide/tables.md +0 -0
  42. {dirsql-0.3.56 → dirsql-0.3.58}/docs/guide/watching.md +0 -0
  43. {dirsql-0.3.56 → dirsql-0.3.58}/docs/index.md +0 -0
  44. {dirsql-0.3.56 → dirsql-0.3.58}/docs/migrations.md +0 -0
  45. {dirsql-0.3.56 → dirsql-0.3.58}/docs/package.json +0 -0
  46. {dirsql-0.3.56 → dirsql-0.3.58}/docs/playwright.config.ts +0 -0
  47. {dirsql-0.3.56 → dirsql-0.3.58}/docs/pnpm-lock.yaml +0 -0
  48. {dirsql-0.3.56 → dirsql-0.3.58}/docs/pnpm-workspace.yaml +0 -0
  49. {dirsql-0.3.56 → dirsql-0.3.58}/docs/tests/integration/home.spec.ts +0 -0
  50. {dirsql-0.3.56 → dirsql-0.3.58}/docs/tests/integration/language-flag.spec.ts +0 -0
  51. {dirsql-0.3.56 → dirsql-0.3.58}/docs/tests/integration/sidebar.spec.ts +0 -0
  52. {dirsql-0.3.56 → dirsql-0.3.58}/docs/tests/unit/config.test.ts +0 -0
  53. {dirsql-0.3.56 → dirsql-0.3.58}/docs/tests/unit/lang.test.ts +0 -0
  54. {dirsql-0.3.56 → dirsql-0.3.58}/docs/vitest.config.ts +0 -0
  55. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/README.md +0 -0
  56. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/conftest.py +0 -0
  57. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/.claude/CLAUDE.md +0 -0
  58. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/.vitepress/config.ts +0 -0
  59. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  60. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  61. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/AGENTS.md +0 -0
  62. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/api/index.md +0 -0
  63. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/cli/config.md +0 -0
  64. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/cli/http-api.md +0 -0
  65. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/cli/index.md +0 -0
  66. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/cli/init.md +0 -0
  67. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/cli/server.md +0 -0
  68. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/getting-started.md +0 -0
  69. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/guide/async.md +0 -0
  70. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/guide/crdt.md +0 -0
  71. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/guide/persistence.md +0 -0
  72. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/guide/querying.md +0 -0
  73. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/guide/tables.md +0 -0
  74. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/guide/watching.md +0 -0
  75. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/index.md +0 -0
  76. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/migrations.md +0 -0
  77. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/package.json +0 -0
  78. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/playwright.config.ts +0 -0
  79. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/pnpm-lock.yaml +0 -0
  80. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/pnpm-workspace.yaml +0 -0
  81. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  82. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  83. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  84. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/tests/unit/config.test.ts +0 -0
  85. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  86. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/docs/vitest.config.ts +0 -0
  87. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/src/lib.rs +0 -0
  88. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/tests/__init__.py +0 -0
  89. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/tests/binding/__init__.py +0 -0
  90. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/tests/conftest.py +0 -0
  91. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/tests/e2e/__init__.py +0 -0
  92. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/tests/integration/__init__.py +0 -0
  93. {dirsql-0.3.56 → dirsql-0.3.58}/packages/python/tests/smoke/__init__.py +0 -0
  94. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/Cargo.toml +0 -0
  95. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/README.md +0 -0
  96. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/benches/db_bench.rs +0 -0
  97. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/benches/differ_bench.rs +0 -0
  98. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/benches/matcher_bench.rs +0 -0
  99. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/benches/scanner_bench.rs +0 -0
  100. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/api/index.md +0 -0
  101. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/cli/config.md +0 -0
  102. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/cli/http-api.md +0 -0
  103. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/cli/index.md +0 -0
  104. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/cli/init.md +0 -0
  105. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/cli/server.md +0 -0
  106. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/getting-started.md +0 -0
  107. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/guide/async.md +0 -0
  108. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/guide/crdt.md +0 -0
  109. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/guide/persistence.md +0 -0
  110. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/guide/querying.md +0 -0
  111. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/guide/tables.md +0 -0
  112. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/guide/watching.md +0 -0
  113. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/index.md +0 -0
  114. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/docs/migrations.md +0 -0
  115. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/bin/dirsql.rs +0 -0
  116. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/cli/mod.rs +0 -0
  117. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/cli/router.rs +0 -0
  118. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/cli/serialize.rs +0 -0
  119. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/config.rs +0 -0
  120. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/differ.rs +0 -0
  121. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/matcher.rs +0 -0
  122. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/scanner.rs +0 -0
  123. {dirsql-0.3.56 → dirsql-0.3.58}/packages/rust/src/watcher.rs +0 -0
  124. {dirsql-0.3.56 → dirsql-0.3.58}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.56"
503
+ version = "0.3.58"
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.56
3
+ Version: 0.3.58
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.56"
7
+ version = "0.3.58"
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": 1783101421,
3
+ "ran_at": 1783118080,
4
4
  "exit_code": 0,
5
- "commit": "557de9e56c14139d4d35b6ba0aed13c601497d4d"
5
+ "commit": "b45791b9ad04fbac360618f3ff885e5f84a8289e"
6
6
  }
@@ -149,4 +149,21 @@ mod tests {
149
149
  let msg = format!("{err}").to_lowercase();
150
150
  assert!(msg.contains("claude"));
151
151
  }
152
+
153
+ /// `run` bails before spawning the agent when the output already exists and
154
+ /// `--force` was not passed (the temp dir itself is an existing path).
155
+ #[test]
156
+ fn run_bails_when_output_exists_without_force() {
157
+ let dir = tempfile::tempdir().unwrap();
158
+ let opts = InitOptions {
159
+ root: dir.path().to_path_buf(),
160
+ output: dir.path().to_path_buf(),
161
+ force: false,
162
+ };
163
+ let err = run(opts).unwrap_err();
164
+ assert!(
165
+ matches!(err, InitError::AlreadyExists { .. }),
166
+ "got: {err:?}"
167
+ );
168
+ }
152
169
  }
@@ -114,4 +114,33 @@ mod tests {
114
114
 
115
115
  handle.shutdown().await.expect("graceful shutdown");
116
116
  }
117
+
118
+ // A `Ready` state drives the `serve` convenience wrapper plus
119
+ // `start_watch_task` (which attaches `DirSQL::watch` and pumps its stream
120
+ // into the broadcast channel). Built over an empty temp dir so the scan
121
+ // touches nothing; the port-0 bind and graceful shutdown are real.
122
+ #[tokio::test]
123
+ async fn serve_with_a_ready_db_attaches_the_watcher_then_shuts_down() {
124
+ let dir = tempfile::tempdir().unwrap();
125
+ let db = DirSQL::new(dir.path(), Vec::new()).unwrap();
126
+ let config = ServerConfig::bind("127.0.0.1".to_string(), 0);
127
+ let handle = serve(config, db).await.expect("bind on an ephemeral port");
128
+ assert_ne!(handle.local_addr().port(), 0);
129
+ handle.shutdown().await.expect("graceful shutdown");
130
+ }
131
+
132
+ // Binding to a non-local TEST-NET address (RFC 5737) fails with
133
+ // "cannot assign requested address", surfacing `ServerError::Bind` rather
134
+ // than panicking — deterministic and DNS-free.
135
+ #[tokio::test]
136
+ async fn serve_with_state_surfaces_a_bind_error_for_a_nonlocal_address() {
137
+ let config = ServerConfig::bind("192.0.2.1".to_string(), 9);
138
+ // `serve_with_state` returns `Result<ServerHandle, _>`; ServerHandle is
139
+ // not Debug, so match rather than `unwrap_err`.
140
+ let err = match serve_with_state(config, AppState::Unavailable("x".to_string())).await {
141
+ Ok(_) => panic!("expected a bind error"),
142
+ Err(e) => e,
143
+ };
144
+ assert!(matches!(err, ServerError::Bind { .. }), "got: {err:?}");
145
+ }
117
146
  }
@@ -484,4 +484,103 @@ mod tests {
484
484
  assert!(!Placeholder::new("a", "b").append_if_absent);
485
485
  assert!(Placeholder::append("a", "b").append_if_absent);
486
486
  }
487
+
488
+ // ----- run_command end-to-end (spawns trivial POSIX commands) ----------
489
+ //
490
+ // `run_command` can only be exercised by actually spawning a child, so
491
+ // these drive `sh`/`echo`/`cat` — universally present on the Linux CI
492
+ // runners. The test code itself statically references only `super::` items,
493
+ // `Duration`, and `std::path::Path`, so the `unit lint` isolation rule is
494
+ // satisfied; the effectful process/thread work lives in production code.
495
+
496
+ fn cwd() -> std::path::PathBuf {
497
+ std::path::PathBuf::from(".")
498
+ }
499
+
500
+ #[test]
501
+ fn run_command_returns_last_nonempty_stdout_line() {
502
+ let out = run_command(
503
+ "sh -c 'echo chatter; echo PAYLOAD'",
504
+ &[],
505
+ &cwd(),
506
+ Duration::from_secs(30),
507
+ None,
508
+ )
509
+ .unwrap();
510
+ assert_eq!(out.payload, "PAYLOAD");
511
+ }
512
+
513
+ #[test]
514
+ fn run_command_writes_stdin_payload_to_the_child() {
515
+ // `cat` echoes its stdin, so the payload round-trips through the
516
+ // stdin-writer thread and the stdout drain.
517
+ let out = run_command(
518
+ "cat",
519
+ &[],
520
+ &cwd(),
521
+ Duration::from_secs(30),
522
+ Some(b"hello-stdin"),
523
+ )
524
+ .unwrap();
525
+ assert_eq!(out.payload, "hello-stdin");
526
+ }
527
+
528
+ #[test]
529
+ fn run_command_reports_nonzero_exit_with_stderr_tail() {
530
+ let err = run_command(
531
+ "sh -c 'echo oops >&2; exit 3'",
532
+ &[],
533
+ &cwd(),
534
+ Duration::from_secs(30),
535
+ None,
536
+ )
537
+ .unwrap_err();
538
+ match err {
539
+ CommandError::NonZeroExit {
540
+ code, stderr_tail, ..
541
+ } => {
542
+ assert_eq!(code, "3");
543
+ assert!(stderr_tail.contains("oops"), "got: {stderr_tail}");
544
+ }
545
+ other => panic!("expected NonZeroExit, got {other:?}"),
546
+ }
547
+ }
548
+
549
+ #[test]
550
+ fn run_command_reports_empty_output_when_stdout_is_blank() {
551
+ let err = run_command("true", &[], &cwd(), Duration::from_secs(30), None).unwrap_err();
552
+ assert!(
553
+ matches!(err, CommandError::EmptyOutput { .. }),
554
+ "got: {err:?}"
555
+ );
556
+ }
557
+
558
+ #[test]
559
+ fn run_command_reports_spawn_failure_for_a_missing_program() {
560
+ let err = run_command(
561
+ "definitely-not-a-real-binary-xyzzy",
562
+ &[],
563
+ &cwd(),
564
+ Duration::from_secs(30),
565
+ None,
566
+ )
567
+ .unwrap_err();
568
+ assert!(matches!(err, CommandError::Spawn { .. }), "got: {err:?}");
569
+ }
570
+
571
+ #[test]
572
+ fn run_command_kills_and_reports_timeout_even_with_stdin() {
573
+ // A short timeout against a long sleep drives the timeout branch,
574
+ // including joining the stdin-writer thread. `sh` ignoring stdin closes
575
+ // the pipe early, which the writer treats as a clean EOF.
576
+ let err = run_command(
577
+ "sh -c 'sleep 30'",
578
+ &[],
579
+ &cwd(),
580
+ Duration::from_millis(50),
581
+ Some(b"x"),
582
+ )
583
+ .unwrap_err();
584
+ assert!(matches!(err, CommandError::Timeout { .. }), "got: {err:?}");
585
+ }
487
586
  }
@@ -301,11 +301,21 @@ impl Db {
301
301
  /// The user-row deletes and the matching `_dirsql_internal_rows` mapping
302
302
  /// deletes commit in ONE transaction (epic #358), so the mapping never
303
303
  /// outlives the rows it describes.
304
+ ///
305
+ /// Stage 2 (epic #358): row ownership is read from the mapping, not the
306
+ /// injected `_dirsql_file_path` column (now write-only). The user rows to
307
+ /// drop are those whose `rowid` the mapping attributes to `file_path` under
308
+ /// `table`; the mapping rows are then removed in the same transaction.
304
309
  pub fn delete_rows_by_file(&self, table: &str, file_path: &str) -> Result<usize> {
305
310
  validate_identifier(table)?;
306
311
  let tx = self.conn.unchecked_transaction()?;
307
- let sql = format!("DELETE FROM {} WHERE _dirsql_file_path = ?1", table);
308
- let count = tx.execute(&sql, [file_path])?;
312
+ let sql = format!(
313
+ "DELETE FROM {} WHERE rowid IN \
314
+ (SELECT rowid_ref FROM _dirsql_internal_rows \
315
+ WHERE table_name = ?1 AND file_path = ?2)",
316
+ table
317
+ );
318
+ let count = tx.execute(&sql, rusqlite::params![table, file_path])?;
309
319
  tx.execute(
310
320
  "DELETE FROM _dirsql_internal_rows WHERE table_name = ?1 AND file_path = ?2",
311
321
  rusqlite::params![table, file_path],
@@ -1593,6 +1603,71 @@ mod tests {
1593
1603
  db.check_row_mapping_equivalence("t").unwrap();
1594
1604
  }
1595
1605
 
1606
+ #[test]
1607
+ fn delete_rows_by_file_reads_mapping_not_columns() {
1608
+ // Stage 2 (epic #358): delete-by-file resolves ownership through the
1609
+ // mapping, not the now-write-only `_dirsql_file_path` column. Corrupt
1610
+ // the column so it disagrees with the mapping and confirm the delete
1611
+ // still follows the mapping.
1612
+ let db = Db::new().unwrap();
1613
+ db.create_table("CREATE TABLE t (id TEXT)").unwrap();
1614
+ db.insert_row(
1615
+ "t",
1616
+ &HashMap::from([("id".into(), Value::Text("a".into()))]),
1617
+ "real.json",
1618
+ 0,
1619
+ )
1620
+ .unwrap();
1621
+ // Desync the write-only column from the mapping.
1622
+ db.conn
1623
+ .execute("UPDATE t SET _dirsql_file_path = 'wrong.json'", [])
1624
+ .unwrap();
1625
+
1626
+ // Deleting by the mapping's file removes the row; deleting by the
1627
+ // stale column value is a no-op.
1628
+ assert_eq!(db.delete_rows_by_file("t", "wrong.json").unwrap(), 0);
1629
+ assert_eq!(db.delete_rows_by_file("t", "real.json").unwrap(), 1);
1630
+ let remaining: i64 = db
1631
+ .conn
1632
+ .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
1633
+ .unwrap();
1634
+ assert_eq!(remaining, 0);
1635
+ db.check_row_mapping_equivalence("t").unwrap();
1636
+ }
1637
+
1638
+ #[test]
1639
+ fn delete_rows_by_file_is_scoped_to_its_table() {
1640
+ // The mapping subquery is keyed on (table_name, file_path): a delete on
1641
+ // one table must not touch another table's rows for the same file path,
1642
+ // even when they share a rowid.
1643
+ let db = Db::new().unwrap();
1644
+ db.create_table("CREATE TABLE t1 (id TEXT)").unwrap();
1645
+ db.create_table("CREATE TABLE t2 (id TEXT)").unwrap();
1646
+ db.insert_row(
1647
+ "t1",
1648
+ &HashMap::from([("id".into(), Value::Text("x".into()))]),
1649
+ "shared.json",
1650
+ 0,
1651
+ )
1652
+ .unwrap();
1653
+ db.insert_row(
1654
+ "t2",
1655
+ &HashMap::from([("id".into(), Value::Text("y".into()))]),
1656
+ "shared.json",
1657
+ 0,
1658
+ )
1659
+ .unwrap();
1660
+
1661
+ assert_eq!(db.delete_rows_by_file("t1", "shared.json").unwrap(), 1);
1662
+ let t2_rows: i64 = db
1663
+ .conn
1664
+ .query_row("SELECT COUNT(*) FROM t2", [], |r| r.get(0))
1665
+ .unwrap();
1666
+ assert_eq!(t2_rows, 1, "t2's row for the same path must be untouched");
1667
+ db.check_row_mapping_equivalence("t1").unwrap();
1668
+ db.check_row_mapping_equivalence("t2").unwrap();
1669
+ }
1670
+
1596
1671
  #[test]
1597
1672
  fn failed_row_insert_leaves_no_mapping_row() {
1598
1673
  // Transactional coupling, forward direction: when the user-row insert