sqlrite 0.4.0__tar.gz → 0.5.0__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. {sqlrite-0.4.0 → sqlrite-0.5.0}/Cargo.lock +7 -7
  2. {sqlrite-0.4.0 → sqlrite-0.5.0}/Cargo.toml +2 -2
  3. {sqlrite-0.4.0 → sqlrite-0.5.0}/PKG-INFO +1 -1
  4. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/package.json +1 -1
  5. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/pager.md +14 -0
  6. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/supported-sql.md +19 -1
  7. {sqlrite-0.4.0 → sqlrite-0.5.0}/pyproject.toml +1 -1
  8. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/python/Cargo.toml +1 -1
  9. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/Cargo.toml +1 -1
  10. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/connection.rs +55 -0
  11. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/db/database.rs +40 -0
  12. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/mod.rs +41 -1
  13. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/freelist.rs +33 -0
  14. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/mod.rs +301 -0
  15. {sqlrite-0.4.0 → sqlrite-0.5.0}/.github/workflows/ci.yml +0 -0
  16. {sqlrite-0.4.0 → sqlrite-0.5.0}/.github/workflows/release-pr.yml +0 -0
  17. {sqlrite-0.4.0 → sqlrite-0.5.0}/.github/workflows/release.yml +0 -0
  18. {sqlrite-0.4.0 → sqlrite-0.5.0}/.github/workflows/rust.yml +0 -0
  19. {sqlrite-0.4.0 → sqlrite-0.5.0}/.gitignore +0 -0
  20. {sqlrite-0.4.0 → sqlrite-0.5.0}/CLAUDE.md +0 -0
  21. {sqlrite-0.4.0 → sqlrite-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  22. {sqlrite-0.4.0 → sqlrite-0.5.0}/LICENSE +0 -0
  23. {sqlrite-0.4.0 → sqlrite-0.5.0}/MAINTAINERS +0 -0
  24. {sqlrite-0.4.0 → sqlrite-0.5.0}/Makefile +0 -0
  25. {sqlrite-0.4.0 → sqlrite-0.5.0}/README.md +0 -0
  26. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/index.html +0 -0
  27. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/package-lock.json +0 -0
  28. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/src/App.svelte +0 -0
  29. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/src/app.css +0 -0
  30. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/src/main.ts +0 -0
  31. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/src/vite-env.d.ts +0 -0
  32. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/svelte.config.js +0 -0
  33. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/tsconfig.json +0 -0
  34. {sqlrite-0.4.0 → sqlrite-0.5.0}/desktop/vite.config.ts +0 -0
  35. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/_index.md +0 -0
  36. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/architecture.md +0 -0
  37. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/ask-backend-examples.md +0 -0
  38. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/ask.md +0 -0
  39. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/design-decisions.md +0 -0
  40. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/desktop.md +0 -0
  41. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/embedding.md +0 -0
  42. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/file-format.md +0 -0
  43. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/fts.md +0 -0
  44. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/getting-started.md +0 -0
  45. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/mcp.md +0 -0
  46. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/phase-7-plan.md +0 -0
  47. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/phase-8-plan.md +0 -0
  48. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/release-plan.md +0 -0
  49. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/release-secrets.md +0 -0
  50. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/roadmap.md +0 -0
  51. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/smoke-test.md +0 -0
  52. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/sql-engine.md +0 -0
  53. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/storage-model.md +0 -0
  54. {sqlrite-0.4.0 → sqlrite-0.5.0}/docs/usage.md +0 -0
  55. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/README.md +0 -0
  56. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/c/Makefile +0 -0
  57. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/c/hello.c +0 -0
  58. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/go/go.mod +0 -0
  59. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/go/hello.go +0 -0
  60. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/hybrid-retrieval/README.md +0 -0
  61. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/hybrid-retrieval/hybrid_retrieval.rs +0 -0
  62. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/nodejs/hello.mjs +0 -0
  63. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/python/hello.py +0 -0
  64. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/rust/quickstart.rs +0 -0
  65. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/wasm/Makefile +0 -0
  66. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/wasm/index.html +0 -0
  67. {sqlrite-0.4.0 → sqlrite-0.5.0}/examples/wasm/server.mjs +0 -0
  68. {sqlrite-0.4.0 → sqlrite-0.5.0}/images/SQLRite - Desktop.png +0 -0
  69. {sqlrite-0.4.0 → sqlrite-0.5.0}/images/SQLRite Data Structures.png +0 -0
  70. {sqlrite-0.4.0 → sqlrite-0.5.0}/images/SQLRite Simple SQL Execution High Level Diagram.png +0 -0
  71. {sqlrite-0.4.0 → sqlrite-0.5.0}/images/SQLRite Simple SQL INSERT Execution High Level Diagram (Insert Row).png +0 -0
  72. {sqlrite-0.4.0 → sqlrite-0.5.0}/images/SQLRite Simple SQL INSERT Execution High Level Diagram.png +0 -0
  73. {sqlrite-0.4.0 → sqlrite-0.5.0}/images/SQLRite_logo.png +0 -0
  74. {sqlrite-0.4.0 → sqlrite-0.5.0}/images/architecture.png +0 -0
  75. {sqlrite-0.4.0 → sqlrite-0.5.0}/rust-toolchain.toml +0 -0
  76. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/AST.delete.example +0 -0
  77. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/AST.insert.exemple +0 -0
  78. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/AST.select.example +0 -0
  79. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/AST.update.example +0 -0
  80. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/CREATE TABLE sqlrite_schema.sql +0 -0
  81. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/CREATE_TABLE with duplicate.sql +0 -0
  82. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/CREATE_TABLE.sql +0 -0
  83. {sqlrite-0.4.0 → sqlrite-0.5.0}/samples/INSERT.sql +0 -0
  84. {sqlrite-0.4.0 → sqlrite-0.5.0}/scripts/bump-version.sh +0 -0
  85. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/README.md +0 -0
  86. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/ask.go +0 -0
  87. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/ask_test.go +0 -0
  88. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/conn.go +0 -0
  89. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/go.mod +0 -0
  90. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/rows.go +0 -0
  91. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/sqlrite.go +0 -0
  92. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/sqlrite_test.go +0 -0
  93. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/go/stmt.go +0 -0
  94. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/python/README.md +0 -0
  95. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/python/src/lib.rs +0 -0
  96. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/python/tests/test_ask.py +0 -0
  97. {sqlrite-0.4.0 → sqlrite-0.5.0}/sdk/python/tests/test_sqlrite.py +0 -0
  98. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/README.md +0 -0
  99. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/src/lib.rs +0 -0
  100. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/src/prompt.rs +0 -0
  101. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/src/provider/anthropic.rs +0 -0
  102. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/src/provider/mock.rs +0 -0
  103. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/src/provider/mod.rs +0 -0
  104. {sqlrite-0.4.0 → sqlrite-0.5.0}/sqlrite-ask/tests/anthropic_http.rs +0 -0
  105. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/ask/mod.rs +0 -0
  106. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/ask/schema.rs +0 -0
  107. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/error.rs +0 -0
  108. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/lib.rs +0 -0
  109. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/main.rs +0 -0
  110. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/meta_command/mod.rs +0 -0
  111. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/repl/mod.rs +0 -0
  112. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/db/mod.rs +0 -0
  113. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/db/secondary_index.rs +0 -0
  114. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/db/table.rs +0 -0
  115. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/executor.rs +0 -0
  116. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/fts/bm25.rs +0 -0
  117. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/fts/mod.rs +0 -0
  118. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/fts/posting_list.rs +0 -0
  119. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/fts/tokenizer.rs +0 -0
  120. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/hnsw.rs +0 -0
  121. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/allocator.rs +0 -0
  122. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/cell.rs +0 -0
  123. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/file.rs +0 -0
  124. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/fts_cell.rs +0 -0
  125. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/header.rs +0 -0
  126. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/hnsw_cell.rs +0 -0
  127. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/index_cell.rs +0 -0
  128. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/interior_page.rs +0 -0
  129. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/overflow.rs +0 -0
  130. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/page.rs +0 -0
  131. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/pager.rs +0 -0
  132. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/table_page.rs +0 -0
  133. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/varint.rs +0 -0
  134. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/pager/wal.rs +0 -0
  135. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/parser/create.rs +0 -0
  136. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/parser/insert.rs +0 -0
  137. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/parser/mod.rs +0 -0
  138. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/parser/select.rs +0 -0
  139. {sqlrite-0.4.0 → sqlrite-0.5.0}/src/sql/tokenizer.rs +0 -0
@@ -3817,7 +3817,7 @@ dependencies = [
3817
3817
 
3818
3818
  [[package]]
3819
3819
  name = "sqlrite-ask"
3820
- version = "0.4.0"
3820
+ version = "0.5.0"
3821
3821
  dependencies = [
3822
3822
  "serde",
3823
3823
  "serde_json",
@@ -3828,7 +3828,7 @@ dependencies = [
3828
3828
 
3829
3829
  [[package]]
3830
3830
  name = "sqlrite-desktop"
3831
- version = "0.4.0"
3831
+ version = "0.5.0"
3832
3832
  dependencies = [
3833
3833
  "serde",
3834
3834
  "serde_json",
@@ -3840,7 +3840,7 @@ dependencies = [
3840
3840
 
3841
3841
  [[package]]
3842
3842
  name = "sqlrite-engine"
3843
- version = "0.4.0"
3843
+ version = "0.5.0"
3844
3844
  dependencies = [
3845
3845
  "clap",
3846
3846
  "env_logger",
@@ -3857,7 +3857,7 @@ dependencies = [
3857
3857
 
3858
3858
  [[package]]
3859
3859
  name = "sqlrite-ffi"
3860
- version = "0.4.0"
3860
+ version = "0.5.0"
3861
3861
  dependencies = [
3862
3862
  "cbindgen",
3863
3863
  "serde",
@@ -3867,7 +3867,7 @@ dependencies = [
3867
3867
 
3868
3868
  [[package]]
3869
3869
  name = "sqlrite-mcp"
3870
- version = "0.4.0"
3870
+ version = "0.5.0"
3871
3871
  dependencies = [
3872
3872
  "clap",
3873
3873
  "libc",
@@ -3878,7 +3878,7 @@ dependencies = [
3878
3878
 
3879
3879
  [[package]]
3880
3880
  name = "sqlrite-nodejs"
3881
- version = "0.4.0"
3881
+ version = "0.5.0"
3882
3882
  dependencies = [
3883
3883
  "napi",
3884
3884
  "napi-build",
@@ -3888,7 +3888,7 @@ dependencies = [
3888
3888
 
3889
3889
  [[package]]
3890
3890
  name = "sqlrite-python"
3891
- version = "0.4.0"
3891
+ version = "0.5.0"
3892
3892
  dependencies = [
3893
3893
  "pyo3",
3894
3894
  "sqlrite-engine",
@@ -27,7 +27,7 @@ resolver = "3"
27
27
  # `package =` key so the import name stays `sqlrite` internally:
28
28
  # sqlrite = { package = "sqlrite-engine", path = "…" }
29
29
  name = "sqlrite-engine"
30
- version = "0.4.0"
30
+ version = "0.5.0"
31
31
  authors = ["Joao Henrique Machado Silva <joaoh82@gmail.com>"]
32
32
  edition = "2024"
33
33
  rust-version = "1.85"
@@ -138,4 +138,4 @@ fs2 = { version = "0.4", optional = true }
138
138
  # crate publishes to crates.io, and a path-only dep without a
139
139
  # version field fails the manifest verification step. See PR #58
140
140
  # retrospective in docs/roadmap.md.
141
- sqlrite-ask = { version = "0.4.0", path = "sqlrite-ask", optional = true }
141
+ sqlrite-ask = { version = "0.5.0", path = "sqlrite-ask", optional = true }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlrite
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sqlrite-desktop-frontend",
3
3
  "private": true,
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -201,6 +201,20 @@ After staging, pages that were live before this save but didn't get restaged thi
201
201
 
202
202
  Format-version side effect: a save that produces a non-empty freelist promotes the file from v4/v5 to v6 (mirrors Phase 8c's v4→v5 FTS rule). VACUUM clears the freelist but doesn't downgrade — v6 is a strict superset.
203
203
 
204
+ ### Auto-VACUUM trigger (SQLR-10)
205
+
206
+ After SQLR-6, the file still required a manual `VACUUM;` to actually shrink — the freelist absorbed orphan pages but the high-water mark stayed put. SQLR-10 adds a heuristic that fires `vacuum_database` automatically after a page-releasing DDL (`DROP TABLE`, `DROP INDEX`, `ALTER TABLE DROP COLUMN`) when the freelist exceeds a configurable fraction of `page_count`.
207
+
208
+ Configuration lives on `Database::auto_vacuum_threshold: Option<f32>` and is exposed at the connection level via `Connection::set_auto_vacuum_threshold` / `auto_vacuum_threshold`. Defaults: `Some(0.25)` (SQLite parity at 25%); pass `None` to opt out per connection. The threshold is per-`Connection` runtime state and is not persisted in the file header — every reopen starts at the default. A SQL-level `PRAGMA auto_vacuum` is tracked separately (out of scope for SQLR-10).
209
+
210
+ The trigger lives at the end of [`process_command_with_render`](../src/sql/mod.rs), immediately after the auto-save. Order matters: the freelist isn't accurate until the bottom-up rebuild runs during save, so we save first, then check the ratio. The check itself is `freelist::should_auto_vacuum(pager, threshold)`, which:
211
+
212
+ - skips databases under `MIN_PAGES_FOR_AUTO_VACUUM` (16 pages = 64 KiB) so tiny files don't churn,
213
+ - counts both leaf and trunk pages in the freelist (trunks are reclaimable bytes too),
214
+ - returns `true` iff `(leaves + trunks) / page_count > threshold`.
215
+
216
+ Auto-VACUUM is also skipped mid-transaction (no save → freelist is stale and the compact would publish in-flight work) and on in-memory databases (no file). The path bypasses `executor::execute_vacuum` — that wrapper builds a user-facing status string and rejects in-transaction calls, both wrong for a silent maintenance hook — and calls `vacuum_database` directly.
217
+
204
218
  ## What it doesn't do (yet)
205
219
 
206
220
  - **No LRU eviction.** `on_disk` + `wal_cache` together grow with the page count. For a 1 GiB database, that's ~1 GiB of page cache. Bounded cache is future work.
@@ -260,7 +260,7 @@ DROP TABLE [IF EXISTS] <table>;
260
260
  - Reserved-name rejection: `DROP TABLE sqlrite_master` errors with the same message `CREATE TABLE` uses.
261
261
  - All indexes attached to the table (auto, explicit, HNSW, FTS) disappear with the table — they live inside the `Table` struct and ride along.
262
262
  - Without `IF EXISTS`, dropping a table that doesn't exist errors. With it, that's a benign 0-tables-dropped no-op.
263
- - **Disk pages move onto the freelist.** Pages the dropped table occupied are pushed onto a persisted free-page list (SQLR-6) so subsequent `CREATE TABLE` or inserts can reuse them. The file doesn't shrink until [`VACUUM;`](#vacuum) compacts it.
263
+ - **Disk pages move onto the freelist.** Pages the dropped table occupied are pushed onto a persisted free-page list (SQLR-6) so subsequent `CREATE TABLE` or inserts can reuse them. The file shrinks automatically when the freelist crosses 25% of `page_count` (SQLR-10 auto-VACUUM, default-on); embedders that need the prior "manual `VACUUM;` only" behavior can call `Connection::set_auto_vacuum_threshold(None)` at open time.
264
264
 
265
265
  ---
266
266
 
@@ -445,6 +445,24 @@ Compacts the database file: rewrites every live table, index, HNSW graph, FTS po
445
445
 
446
446
  When to run it: any time after a string of `DROP TABLE` / `DROP INDEX` / `ALTER TABLE DROP COLUMN` operations if you care about file size. SQLRite reuses freelist pages on subsequent inserts, so a write-heavy workload may not need VACUUM at all — its main use is reclaiming space when you don't expect to grow back.
447
447
 
448
+ ### Auto-VACUUM (SQLR-10)
449
+
450
+ Manual `VACUUM;` is rarely needed in practice: by default, every page-releasing DDL (`DROP TABLE`, `DROP INDEX`, `ALTER TABLE DROP COLUMN`) checks the freelist after committing and runs `vacuum_database` automatically when the freelist exceeds **25%** of `page_count` (SQLite parity). The trigger:
451
+
452
+ - skips databases under 16 pages (64 KiB) so tiny files don't churn,
453
+ - skips inside an explicit transaction (the freelist isn't accurate until `COMMIT`),
454
+ - skips on in-memory and read-only databases.
455
+
456
+ The threshold is tunable per-connection from Rust:
457
+
458
+ ```rust
459
+ let mut conn = Connection::open("db.sqlrite")?;
460
+ conn.set_auto_vacuum_threshold(Some(0.5))?; // fire only when freelist > 50%
461
+ conn.set_auto_vacuum_threshold(None)?; // disable entirely (manual VACUUM only)
462
+ ```
463
+
464
+ The setting is per-`Connection` runtime state — it's not persisted in the file header, so every reopen starts at the default `Some(0.25)`. A SQL-level `PRAGMA auto_vacuum` knob is on the roadmap but not yet implemented (SDK consumers currently configure it via the per-binding glue or fall back to the default).
465
+
448
466
  ---
449
467
 
450
468
  ## Read-only databases
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "sqlrite"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Python bindings for SQLRite — a small, embeddable SQLite clone written in Rust."
9
9
  authors = [{ name = "Joao Henrique Machado Silva", email = "joaoh82@gmail.com" }]
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sqlrite-python"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  authors = ["Joao Henrique Machado Silva <joaoh82@gmail.com>"]
5
5
  edition = "2024"
6
6
  rust-version = "1.85"
@@ -10,7 +10,7 @@
10
10
  # Published to crates.io as `sqlrite-ask`. Joins the lockstep release
11
11
  # wave (`sqlrite-ask-vX.Y.Z` tag) — see `docs/release-plan.md`.
12
12
  name = "sqlrite-ask"
13
- version = "0.4.0"
13
+ version = "0.5.0"
14
14
  authors = ["Joao Henrique Machado Silva <joaoh82@gmail.com>"]
15
15
  edition = "2024"
16
16
  rust-version = "1.85"
@@ -153,6 +153,30 @@ impl Connection {
153
153
  self.db.in_transaction()
154
154
  }
155
155
 
156
+ /// Returns the current auto-VACUUM threshold (SQLR-10). After a
157
+ /// page-releasing DDL (DROP TABLE / DROP INDEX / ALTER TABLE DROP
158
+ /// COLUMN) commits, the engine compacts the file in place if the
159
+ /// freelist exceeds this fraction of `page_count`. New connections
160
+ /// default to `Some(0.25)` (SQLite parity); `None` means the
161
+ /// trigger is disabled. See [`Connection::set_auto_vacuum_threshold`].
162
+ pub fn auto_vacuum_threshold(&self) -> Option<f32> {
163
+ self.db.auto_vacuum_threshold()
164
+ }
165
+
166
+ /// Sets the auto-VACUUM threshold (SQLR-10). `Some(t)` with `t` in
167
+ /// `0.0..=1.0` arms the trigger; `None` disables it. Values outside
168
+ /// `0.0..=1.0` (or NaN / infinite) return a typed error rather than
169
+ /// silently saturating. The setting is per-connection runtime
170
+ /// state — closing the connection drops it; new connections start
171
+ /// at the default `Some(0.25)`.
172
+ ///
173
+ /// Calling this on an in-memory or read-only database is allowed
174
+ /// (it just won't fire — there's nothing to compact / no writes
175
+ /// will reach the trigger).
176
+ pub fn set_auto_vacuum_threshold(&mut self, threshold: Option<f32>) -> Result<()> {
177
+ self.db.set_auto_vacuum_threshold(threshold)
178
+ }
179
+
156
180
  /// Returns `true` if the connection was opened read-only. Mutating
157
181
  /// statements on a read-only connection return a typed error.
158
182
  pub fn is_read_only(&self) -> bool {
@@ -634,6 +658,37 @@ mod tests {
634
658
  assert!(format!("{err}").contains("SELECT"));
635
659
  }
636
660
 
661
+ /// SQLR-10: fresh connections expose the SQLite-parity 25% default,
662
+ /// the setter validates its input, and `None` opts out cleanly.
663
+ #[test]
664
+ fn auto_vacuum_threshold_default_and_setter() {
665
+ let mut conn = Connection::open_in_memory().unwrap();
666
+ assert_eq!(
667
+ conn.auto_vacuum_threshold(),
668
+ Some(0.25),
669
+ "fresh connection should ship with the SQLite-parity default"
670
+ );
671
+
672
+ conn.set_auto_vacuum_threshold(None).unwrap();
673
+ assert_eq!(conn.auto_vacuum_threshold(), None);
674
+
675
+ conn.set_auto_vacuum_threshold(Some(0.5)).unwrap();
676
+ assert_eq!(conn.auto_vacuum_threshold(), Some(0.5));
677
+
678
+ // Out-of-range values must be rejected with a typed error and
679
+ // must not stomp the previously-set value.
680
+ let err = conn.set_auto_vacuum_threshold(Some(1.5)).unwrap_err();
681
+ assert!(
682
+ format!("{err}").contains("auto_vacuum_threshold"),
683
+ "expected typed range error, got: {err}"
684
+ );
685
+ assert_eq!(
686
+ conn.auto_vacuum_threshold(),
687
+ Some(0.5),
688
+ "rejected setter call must not mutate the threshold"
689
+ );
690
+ }
691
+
637
692
  #[test]
638
693
  fn index_out_of_bounds_errors_cleanly() {
639
694
  let mut conn = Connection::open_in_memory().unwrap();
@@ -14,6 +14,13 @@ pub struct TxnSnapshot {
14
14
  pub(crate) tables: HashMap<String, Table>,
15
15
  }
16
16
 
17
+ /// Default fraction of free pages that triggers an auto-VACUUM after
18
+ /// a page-releasing DDL (DROP TABLE / DROP INDEX / ALTER TABLE DROP
19
+ /// COLUMN). Matches SQLite's classic 25% heuristic. Override per
20
+ /// connection with [`Database::set_auto_vacuum_threshold`] (or
21
+ /// `Connection::set_auto_vacuum_threshold`); pass `None` to disable.
22
+ pub const DEFAULT_AUTO_VACUUM_THRESHOLD: f32 = 0.25;
23
+
17
24
  /// The database is represented by this structure.assert_eq!
18
25
  #[derive(Debug)]
19
26
  pub struct Database {
@@ -36,6 +43,14 @@ pub struct Database {
36
43
  /// - nested `BEGIN` is rejected
37
44
  /// - `ROLLBACK` restores `tables` from the snapshot
38
45
  pub txn: Option<TxnSnapshot>,
46
+ /// Auto-VACUUM trigger (SQLR-10). After a page-releasing DDL
47
+ /// (DROP TABLE / DROP INDEX / ALTER TABLE DROP COLUMN) commits and
48
+ /// flushes, if the freelist exceeds this fraction of `page_count`
49
+ /// the engine quietly compacts the file. `None` disables the
50
+ /// trigger; defaults to `Some(DEFAULT_AUTO_VACUUM_THRESHOLD)`
51
+ /// (SQLite parity at 25%). Per-connection runtime state — not
52
+ /// persisted across reopens.
53
+ pub auto_vacuum_threshold: Option<f32>,
39
54
  }
40
55
 
41
56
  impl Database {
@@ -54,9 +69,34 @@ impl Database {
54
69
  source_path: None,
55
70
  pager: None,
56
71
  txn: None,
72
+ auto_vacuum_threshold: Some(DEFAULT_AUTO_VACUUM_THRESHOLD),
57
73
  }
58
74
  }
59
75
 
76
+ /// Returns the current auto-VACUUM threshold, or `None` if disabled.
77
+ /// See [`Database::set_auto_vacuum_threshold`] for semantics.
78
+ pub fn auto_vacuum_threshold(&self) -> Option<f32> {
79
+ self.auto_vacuum_threshold
80
+ }
81
+
82
+ /// Sets the auto-VACUUM threshold (SQLR-10). `Some(t)` with `t` in
83
+ /// `0.0..=1.0` arms the trigger: after a page-releasing DDL
84
+ /// commits, if the freelist exceeds `t * page_count` the engine
85
+ /// runs a full-file compact. `None` disables the trigger. Values
86
+ /// outside `0.0..=1.0` (or NaN / infinite) return a typed error
87
+ /// rather than silently saturating.
88
+ pub fn set_auto_vacuum_threshold(&mut self, threshold: Option<f32>) -> Result<()> {
89
+ if let Some(t) = threshold {
90
+ if !t.is_finite() || !(0.0..=1.0).contains(&t) {
91
+ return Err(SQLRiteError::General(format!(
92
+ "auto_vacuum_threshold must be in 0.0..=1.0, got {t}"
93
+ )));
94
+ }
95
+ }
96
+ self.auto_vacuum_threshold = threshold;
97
+ Ok(())
98
+ }
99
+
60
100
  /// Returns true if the database contains a table with the specified key as a table name.
61
101
  ///
62
102
  pub fn contains_table(&self, table_name: String) -> bool {
@@ -10,7 +10,7 @@ use parser::create::CreateQuery;
10
10
  use parser::insert::InsertQuery;
11
11
  use parser::select::SelectQuery;
12
12
 
13
- use sqlparser::ast::{ObjectType, Statement};
13
+ use sqlparser::ast::{AlterTableOperation, ObjectType, Statement};
14
14
  use sqlparser::dialect::SQLiteDialect;
15
15
  use sqlparser::parser::{Parser, ParserError};
16
16
 
@@ -176,6 +176,22 @@ pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<Com
176
176
  );
177
177
  let is_vacuum = matches!(&query, Statement::Vacuum(_));
178
178
 
179
+ // SQLR-10: statements that release pages onto the freelist.
180
+ // After the auto-save flushes them, we'll consult
181
+ // `db.auto_vacuum_threshold` and possibly compact in place.
182
+ // ALTER TABLE here matches only DROP COLUMN — RENAME / ADD COLUMN
183
+ // don't grow the freelist, so they shouldn't pay the trigger cost.
184
+ let releases_pages = match &query {
185
+ Statement::Drop { object_type, .. } => {
186
+ matches!(object_type, ObjectType::Table | ObjectType::Index)
187
+ }
188
+ Statement::AlterTable(alter) => alter
189
+ .operations
190
+ .iter()
191
+ .any(|op| matches!(op, AlterTableOperation::DropColumn { .. })),
192
+ _ => false,
193
+ };
194
+
179
195
  // Early-reject mutations on a read-only database before they touch
180
196
  // in-memory state. Phase 4e: without this, a user running INSERT
181
197
  // on a `--readonly` REPL would see the row appear in the printed
@@ -389,6 +405,30 @@ pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<Com
389
405
  pager::save_database(db, &path)?;
390
406
  }
391
407
 
408
+ // SQLR-10 auto-VACUUM trigger. Runs *after* the auto-save above so
409
+ // the orphaned pages from the just-executed DROP/ALTER have actually
410
+ // landed on the freelist (the bottom-up rebuild populates it during
411
+ // save). Skipped mid-transaction (no commit yet → no save → freelist
412
+ // is stale), on in-memory DBs (nothing to compact), and when the
413
+ // user has explicitly disabled the trigger via
414
+ // `set_auto_vacuum_threshold(None)`. We deliberately bypass
415
+ // `executor::execute_vacuum` and call `pager::vacuum_database`
416
+ // directly: the executor wrapper builds a user-facing status string
417
+ // and rejects in-transaction calls — both wrong for this silent
418
+ // maintenance path.
419
+ if releases_pages && !db.in_transaction() {
420
+ if let (Some(threshold), Some(path)) = (db.auto_vacuum_threshold(), db.source_path.clone())
421
+ {
422
+ let should = match db.pager.as_ref() {
423
+ Some(p) => pager::freelist::should_auto_vacuum(p, threshold)?,
424
+ None => false,
425
+ };
426
+ if should {
427
+ pager::vacuum_database(db, &path)?;
428
+ }
429
+ }
430
+ }
431
+
392
432
  Ok(CommandOutput {
393
433
  status: message,
394
434
  rendered,
@@ -202,6 +202,39 @@ pub fn freelist_to_deque(leaves: Vec<u32>) -> VecDeque<u32> {
202
202
  VecDeque::from(sorted)
203
203
  }
204
204
 
205
+ /// Auto-VACUUM (SQLR-10) does not fire on databases below this many
206
+ /// pages. The 4 KiB page size makes 16 pages = 64 KiB — small enough
207
+ /// that the cost of a full-file rewrite is negligible if the user
208
+ /// genuinely wants it (manual `VACUUM;` still works), but large enough
209
+ /// that single-table churn doesn't blow past the threshold and trigger
210
+ /// a noisy compact every few statements.
211
+ pub const MIN_PAGES_FOR_AUTO_VACUUM: u32 = 16;
212
+
213
+ /// Returns `true` if the on-disk freelist (counting both leaf and
214
+ /// trunk pages — they're all reclaimable bytes) exceeds `threshold`
215
+ /// of `header.page_count`, i.e. the file is bloated enough to be
216
+ /// worth compacting. Returns `false` for tiny databases (under
217
+ /// [`MIN_PAGES_FOR_AUTO_VACUUM`]) and for empty freelists, both as
218
+ /// fast paths to keep the auto-VACUUM hook cheap on the common case.
219
+ ///
220
+ /// This is a read-only inspection of the pager's current header and
221
+ /// freelist chain — it does not mutate any state. The caller is
222
+ /// responsible for actually invoking
223
+ /// [`crate::sql::pager::vacuum_database`] when this returns `true`.
224
+ pub fn should_auto_vacuum(pager: &Pager, threshold: f32) -> Result<bool> {
225
+ let header = pager.header();
226
+ if header.page_count < MIN_PAGES_FOR_AUTO_VACUUM {
227
+ return Ok(false);
228
+ }
229
+ if header.freelist_head == 0 {
230
+ return Ok(false);
231
+ }
232
+ let (leaves, trunks) = read_freelist(pager, header.freelist_head)?;
233
+ let free_pages = leaves.len() + trunks.len();
234
+ let ratio = free_pages as f32 / header.page_count as f32;
235
+ Ok(ratio > threshold)
236
+ }
237
+
205
238
  #[cfg(test)]
206
239
  mod tests {
207
240
  use super::*;
@@ -1896,6 +1896,7 @@ fn emit_interior(pager: &mut Pager, page_num: u32, interior: &InteriorPage) {
1896
1896
  #[cfg(test)]
1897
1897
  mod tests {
1898
1898
  use super::*;
1899
+ use crate::sql::pager::freelist::MIN_PAGES_FOR_AUTO_VACUUM;
1899
1900
  use crate::sql::process_command;
1900
1901
 
1901
1902
  fn seed_db() -> Database {
@@ -3308,6 +3309,306 @@ mod tests {
3308
3309
  cleanup(&path);
3309
3310
  }
3310
3311
 
3312
+ // ---- SQLR-10: auto-VACUUM trigger after page-releasing DDL ----
3313
+
3314
+ /// Builds a file-backed DB with one small "keep" table and one
3315
+ /// large "bloat" table, sized so the post-drop freelist will
3316
+ /// comfortably cross the default 25% threshold and the
3317
+ /// `MIN_PAGES_FOR_AUTO_VACUUM` floor (16 pages). Used by the
3318
+ /// auto-VACUUM happy-path tests.
3319
+ fn auto_vacuum_setup(path: &std::path::Path) -> Database {
3320
+ let mut db = Database::new("av".to_string());
3321
+ db.source_path = Some(path.to_path_buf());
3322
+ process_command(
3323
+ "CREATE TABLE keep (id INTEGER PRIMARY KEY, n INTEGER);",
3324
+ &mut db,
3325
+ )
3326
+ .unwrap();
3327
+ process_command("INSERT INTO keep (n) VALUES (1);", &mut db).unwrap();
3328
+ process_command(
3329
+ "CREATE TABLE bloat (id INTEGER PRIMARY KEY, payload TEXT);",
3330
+ &mut db,
3331
+ )
3332
+ .unwrap();
3333
+ // Wrap the bulk insert in a transaction so we pay one save at
3334
+ // COMMIT instead of 5000 round-trips through auto-save.
3335
+ process_command("BEGIN;", &mut db).unwrap();
3336
+ for i in 0..5000 {
3337
+ process_command(
3338
+ &format!("INSERT INTO bloat (payload) VALUES ('p-{i:08}');"),
3339
+ &mut db,
3340
+ )
3341
+ .unwrap();
3342
+ }
3343
+ process_command("COMMIT;", &mut db).unwrap();
3344
+ db
3345
+ }
3346
+
3347
+ /// Default threshold (0.25) is engaged for fresh `Database`s and
3348
+ /// fires when a `DROP TABLE` orphans enough pages — file shrinks
3349
+ /// without anyone calling `VACUUM;`.
3350
+ #[test]
3351
+ fn auto_vacuum_default_threshold_triggers_on_drop_table() {
3352
+ let path = tmp_path("av_default_drop_table");
3353
+ let mut db = auto_vacuum_setup(&path);
3354
+ // Sanity: setup respects the shipped default.
3355
+ assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3356
+
3357
+ // Checkpoint before measuring `size_before` so the bloat actually
3358
+ // lives in the main file and not just the WAL — otherwise
3359
+ // `size_before` is the bare 2-page header and any post-vacuum
3360
+ // checkpoint will look like the file *grew*.
3361
+ if let Some(p) = db.pager.as_mut() {
3362
+ let _ = p.checkpoint();
3363
+ }
3364
+ let pages_before = db.pager.as_ref().unwrap().header().page_count;
3365
+ let size_before = std::fs::metadata(&path).unwrap().len();
3366
+ assert!(
3367
+ pages_before >= MIN_PAGES_FOR_AUTO_VACUUM,
3368
+ "setup should produce >= MIN_PAGES_FOR_AUTO_VACUUM ({MIN_PAGES_FOR_AUTO_VACUUM}) \
3369
+ pages so the floor doesn't suppress the trigger; got {pages_before}"
3370
+ );
3371
+
3372
+ // Drop the bloat table — freelist should pass 25% of page_count
3373
+ // and the auto-VACUUM hook should compact in place. Note: no
3374
+ // explicit `VACUUM;` statement is issued.
3375
+ process_command("DROP TABLE bloat;", &mut db).expect("drop");
3376
+
3377
+ let pages_after = db.pager.as_ref().unwrap().header().page_count;
3378
+ let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3379
+ // Second checkpoint so the post-vacuum file shrinks on disk
3380
+ // (auto-VACUUM stages the compact through WAL just like manual
3381
+ // VACUUM does).
3382
+ if let Some(p) = db.pager.as_mut() {
3383
+ let _ = p.checkpoint();
3384
+ }
3385
+ let size_after = std::fs::metadata(&path).unwrap().len();
3386
+
3387
+ assert!(
3388
+ pages_after < pages_before,
3389
+ "auto-VACUUM must reduce page_count: was {pages_before}, now {pages_after}"
3390
+ );
3391
+ assert_eq!(head_after, 0, "auto-VACUUM must clear the freelist");
3392
+ assert!(
3393
+ size_after < size_before,
3394
+ "auto-VACUUM must shrink the file on disk: was {size_before}, now {size_after}"
3395
+ );
3396
+
3397
+ cleanup(&path);
3398
+ }
3399
+
3400
+ /// Setting the threshold to `None` disables the trigger entirely:
3401
+ /// the same workload that shrinks under the default leaves the file
3402
+ /// at its high-water mark.
3403
+ #[test]
3404
+ fn auto_vacuum_disabled_keeps_file_at_hwm() {
3405
+ let path = tmp_path("av_disabled");
3406
+ let mut db = auto_vacuum_setup(&path);
3407
+ db.set_auto_vacuum_threshold(None).expect("disable");
3408
+ assert_eq!(db.auto_vacuum_threshold(), None);
3409
+
3410
+ let pages_before = db.pager.as_ref().unwrap().header().page_count;
3411
+
3412
+ process_command("DROP TABLE bloat;", &mut db).expect("drop");
3413
+
3414
+ let pages_after = db.pager.as_ref().unwrap().header().page_count;
3415
+ let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3416
+ assert_eq!(
3417
+ pages_after, pages_before,
3418
+ "with auto-VACUUM disabled, drop must keep page_count at the HWM"
3419
+ );
3420
+ assert!(
3421
+ head_after != 0,
3422
+ "drop must still populate the freelist (manual VACUUM would be needed to reclaim)"
3423
+ );
3424
+
3425
+ cleanup(&path);
3426
+ }
3427
+
3428
+ /// `DROP INDEX` is the second of three page-releasing DDL paths
3429
+ /// covered by SQLR-10. We bloat the freelist via a separate
3430
+ /// `DROP TABLE` first (with auto-VACUUM disabled so it doesn't
3431
+ /// compact early), then re-arm the trigger and drop a small index
3432
+ /// — the cumulative freelist crosses 25% on the index drop and
3433
+ /// auto-VACUUM fires.
3434
+ ///
3435
+ /// The detour around bloat is necessary because building a
3436
+ /// secondary index on a 5000-row column would need multi-level
3437
+ /// interior nodes, and the cell-decoder's interior-page support
3438
+ /// is a separate work item from SQLR-10.
3439
+ #[test]
3440
+ fn auto_vacuum_triggers_on_drop_index() {
3441
+ let path = tmp_path("av_drop_index");
3442
+ let mut db = auto_vacuum_setup(&path);
3443
+
3444
+ // Phase 1: drop the bloat table with auto-VACUUM disabled so
3445
+ // its pages land on the freelist without being reclaimed.
3446
+ db.set_auto_vacuum_threshold(None).expect("disable");
3447
+ process_command("DROP TABLE bloat;", &mut db).expect("drop bloat");
3448
+ let pages_after_bloat_drop = db.pager.as_ref().unwrap().header().page_count;
3449
+ let head_after_bloat_drop = db.pager.as_ref().unwrap().header().freelist_head;
3450
+ assert!(
3451
+ head_after_bloat_drop != 0,
3452
+ "bloat drop must populate the freelist (else later index drop won't trip the threshold)"
3453
+ );
3454
+
3455
+ // Phase 2: a small index on the surviving `keep` table. The
3456
+ // index reuses one page from the freelist (which is fine —
3457
+ // freelist still holds plenty more).
3458
+ process_command("CREATE INDEX idx_keep_n ON keep (n);", &mut db).expect("create idx");
3459
+
3460
+ // Phase 3: re-arm the trigger and drop the index. The freelist
3461
+ // is already heavily populated from phase 1; this drop just
3462
+ // adds the index page on top, keeping the ratio well above
3463
+ // 25%, so auto-VACUUM should fire.
3464
+ db.set_auto_vacuum_threshold(Some(0.25)).expect("re-arm");
3465
+ process_command("DROP INDEX idx_keep_n;", &mut db).expect("drop index");
3466
+
3467
+ let pages_after = db.pager.as_ref().unwrap().header().page_count;
3468
+ let head_after = db.pager.as_ref().unwrap().header().freelist_head;
3469
+ assert!(
3470
+ pages_after < pages_after_bloat_drop,
3471
+ "DROP INDEX should fire auto-VACUUM and reduce page_count: \
3472
+ was {pages_after_bloat_drop}, now {pages_after}"
3473
+ );
3474
+ assert_eq!(
3475
+ head_after, 0,
3476
+ "auto-VACUUM after DROP INDEX must clear the freelist"
3477
+ );
3478
+
3479
+ cleanup(&path);
3480
+ }
3481
+
3482
+ /// `ALTER TABLE … DROP COLUMN` releases pages too — the third path
3483
+ /// the SQLR-10 trigger covers.
3484
+ #[test]
3485
+ fn auto_vacuum_triggers_on_alter_drop_column() {
3486
+ let path = tmp_path("av_alter_drop_col");
3487
+ let mut db = auto_vacuum_setup(&path);
3488
+ let pages_before = db.pager.as_ref().unwrap().header().page_count;
3489
+
3490
+ // Drop the wide `payload` column — this rewrites every row in
3491
+ // `bloat` without the column, so the old leaf pages get freed.
3492
+ process_command("ALTER TABLE bloat DROP COLUMN payload;", &mut db).expect("alter drop");
3493
+
3494
+ let pages_after = db.pager.as_ref().unwrap().header().page_count;
3495
+ assert!(
3496
+ pages_after < pages_before,
3497
+ "ALTER TABLE DROP COLUMN should fire auto-VACUUM and reduce page_count: \
3498
+ was {pages_before}, now {pages_after}"
3499
+ );
3500
+ assert_eq!(db.pager.as_ref().unwrap().header().freelist_head, 0);
3501
+
3502
+ cleanup(&path);
3503
+ }
3504
+
3505
+ /// A high threshold (0.99) suppresses the trigger when the freelist
3506
+ /// ratio is well below it — the file stays at HWM.
3507
+ #[test]
3508
+ fn auto_vacuum_skips_below_threshold() {
3509
+ let path = tmp_path("av_below_threshold");
3510
+ let mut db = auto_vacuum_setup(&path);
3511
+ db.set_auto_vacuum_threshold(Some(0.99)).expect("set");
3512
+
3513
+ let pages_before = db.pager.as_ref().unwrap().header().page_count;
3514
+
3515
+ process_command("DROP TABLE bloat;", &mut db).expect("drop");
3516
+
3517
+ let pages_after = db.pager.as_ref().unwrap().header().page_count;
3518
+ assert_eq!(
3519
+ pages_after, pages_before,
3520
+ "freelist ratio after a single drop is far below 0.99 — \
3521
+ page_count must stay at the HWM"
3522
+ );
3523
+ assert!(
3524
+ db.pager.as_ref().unwrap().header().freelist_head != 0,
3525
+ "drop must still populate the freelist"
3526
+ );
3527
+
3528
+ cleanup(&path);
3529
+ }
3530
+
3531
+ /// Inside an explicit transaction, the page-releasing DDL doesn't
3532
+ /// flush to disk yet — the freelist isn't accurate, so the trigger
3533
+ /// must skip. The compact would also publish in-flight work out of
3534
+ /// band, which is exactly what the manual `VACUUM;` rejection
3535
+ /// inside a txn already prevents.
3536
+ #[test]
3537
+ fn auto_vacuum_skips_inside_transaction() {
3538
+ let path = tmp_path("av_in_txn");
3539
+ let mut db = auto_vacuum_setup(&path);
3540
+ let pages_before = db.pager.as_ref().unwrap().header().page_count;
3541
+
3542
+ process_command("BEGIN;", &mut db).expect("begin");
3543
+ process_command("DROP TABLE bloat;", &mut db).expect("drop in txn");
3544
+ // Mid-transaction: no save has occurred, so the on-disk
3545
+ // freelist_head must be unchanged and page_count must not have
3546
+ // shifted from a sneaky compact.
3547
+ let pages_mid = db.pager.as_ref().unwrap().header().page_count;
3548
+ assert_eq!(
3549
+ pages_mid, pages_before,
3550
+ "auto-VACUUM must not fire mid-transaction"
3551
+ );
3552
+
3553
+ process_command("ROLLBACK;", &mut db).expect("rollback");
3554
+ cleanup(&path);
3555
+ }
3556
+
3557
+ /// Tiny databases (under `MIN_PAGES_FOR_AUTO_VACUUM`) skip the
3558
+ /// trigger even if the ratio would otherwise qualify — the cost of
3559
+ /// rewriting a 64 KiB file isn't worth the few bytes reclaimed.
3560
+ #[test]
3561
+ fn auto_vacuum_skips_under_min_pages_floor() {
3562
+ let path = tmp_path("av_under_floor");
3563
+ let mut db = seed_db(); // small: just users + notes, ~5 pages
3564
+ db.source_path = Some(path.clone());
3565
+ save_database(&mut db, &path).expect("save");
3566
+ // Confirm we're below the floor so the test is meaningful.
3567
+ let pages_before = db.pager.as_ref().unwrap().header().page_count;
3568
+ assert!(
3569
+ pages_before < MIN_PAGES_FOR_AUTO_VACUUM,
3570
+ "test setup is too large: floor would not apply (got {pages_before} pages, \
3571
+ floor is {MIN_PAGES_FOR_AUTO_VACUUM})"
3572
+ );
3573
+
3574
+ process_command("DROP TABLE users;", &mut db).expect("drop");
3575
+
3576
+ let pages_after = db.pager.as_ref().unwrap().header().page_count;
3577
+ assert_eq!(
3578
+ pages_after, pages_before,
3579
+ "below MIN_PAGES_FOR_AUTO_VACUUM, drop must not trigger compaction"
3580
+ );
3581
+ assert!(
3582
+ db.pager.as_ref().unwrap().header().freelist_head != 0,
3583
+ "drop must still populate the freelist normally"
3584
+ );
3585
+
3586
+ cleanup(&path);
3587
+ }
3588
+
3589
+ /// Setter rejects NaN, infinities, and values outside `0.0..=1.0`
3590
+ /// rather than silently saturating.
3591
+ #[test]
3592
+ fn set_auto_vacuum_threshold_rejects_out_of_range() {
3593
+ let mut db = Database::new("t".to_string());
3594
+ for bad in [-0.01_f32, 1.01, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
3595
+ let err = db.set_auto_vacuum_threshold(Some(bad)).unwrap_err();
3596
+ assert!(
3597
+ format!("{err}").contains("auto_vacuum_threshold"),
3598
+ "expected a typed range error for {bad}, got: {err}"
3599
+ );
3600
+ }
3601
+ // The default survives the rejected sets unchanged.
3602
+ assert_eq!(db.auto_vacuum_threshold(), Some(0.25));
3603
+ // And valid values land.
3604
+ db.set_auto_vacuum_threshold(Some(0.0)).unwrap();
3605
+ assert_eq!(db.auto_vacuum_threshold(), Some(0.0));
3606
+ db.set_auto_vacuum_threshold(Some(1.0)).unwrap();
3607
+ assert_eq!(db.auto_vacuum_threshold(), Some(1.0));
3608
+ db.set_auto_vacuum_threshold(None).unwrap();
3609
+ assert_eq!(db.auto_vacuum_threshold(), None);
3610
+ }
3611
+
3311
3612
  /// VACUUM modifiers (FULL, REINDEX, table targets, …) are rejected
3312
3613
  /// with NotImplemented — only bare `VACUUM;` is supported.
3313
3614
  #[test]
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
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
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