sqlrite 0.3.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.3.0 → sqlrite-0.5.0}/Cargo.lock +7 -7
  2. {sqlrite-0.3.0 → sqlrite-0.5.0}/Cargo.toml +2 -2
  3. {sqlrite-0.3.0 → sqlrite-0.5.0}/PKG-INFO +1 -1
  4. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/package.json +1 -1
  5. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/file-format.md +7 -4
  6. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/pager.md +28 -1
  7. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/supported-sql.md +38 -1
  8. {sqlrite-0.3.0 → sqlrite-0.5.0}/pyproject.toml +1 -1
  9. {sqlrite-0.3.0 → sqlrite-0.5.0}/scripts/bump-version.sh +38 -3
  10. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/python/Cargo.toml +1 -1
  11. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/Cargo.toml +1 -1
  12. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/connection.rs +55 -0
  13. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/db/database.rs +40 -0
  14. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/executor.rs +54 -0
  15. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/mod.rs +71 -2
  16. sqlrite-0.5.0/src/sql/pager/allocator.rs +222 -0
  17. sqlrite-0.5.0/src/sql/pager/freelist.rs +291 -0
  18. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/header.rs +26 -7
  19. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/mod.rs +965 -112
  20. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/overflow.rs +39 -22
  21. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/pager.rs +21 -0
  22. {sqlrite-0.3.0 → sqlrite-0.5.0}/.github/workflows/ci.yml +0 -0
  23. {sqlrite-0.3.0 → sqlrite-0.5.0}/.github/workflows/release-pr.yml +0 -0
  24. {sqlrite-0.3.0 → sqlrite-0.5.0}/.github/workflows/release.yml +0 -0
  25. {sqlrite-0.3.0 → sqlrite-0.5.0}/.github/workflows/rust.yml +0 -0
  26. {sqlrite-0.3.0 → sqlrite-0.5.0}/.gitignore +0 -0
  27. {sqlrite-0.3.0 → sqlrite-0.5.0}/CLAUDE.md +0 -0
  28. {sqlrite-0.3.0 → sqlrite-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  29. {sqlrite-0.3.0 → sqlrite-0.5.0}/LICENSE +0 -0
  30. {sqlrite-0.3.0 → sqlrite-0.5.0}/MAINTAINERS +0 -0
  31. {sqlrite-0.3.0 → sqlrite-0.5.0}/Makefile +0 -0
  32. {sqlrite-0.3.0 → sqlrite-0.5.0}/README.md +0 -0
  33. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/index.html +0 -0
  34. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/package-lock.json +0 -0
  35. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/src/App.svelte +0 -0
  36. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/src/app.css +0 -0
  37. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/src/main.ts +0 -0
  38. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/src/vite-env.d.ts +0 -0
  39. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/svelte.config.js +0 -0
  40. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/tsconfig.json +0 -0
  41. {sqlrite-0.3.0 → sqlrite-0.5.0}/desktop/vite.config.ts +0 -0
  42. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/_index.md +0 -0
  43. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/architecture.md +0 -0
  44. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/ask-backend-examples.md +0 -0
  45. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/ask.md +0 -0
  46. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/design-decisions.md +0 -0
  47. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/desktop.md +0 -0
  48. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/embedding.md +0 -0
  49. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/fts.md +0 -0
  50. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/getting-started.md +0 -0
  51. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/mcp.md +0 -0
  52. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/phase-7-plan.md +0 -0
  53. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/phase-8-plan.md +0 -0
  54. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/release-plan.md +0 -0
  55. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/release-secrets.md +0 -0
  56. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/roadmap.md +0 -0
  57. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/smoke-test.md +0 -0
  58. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/sql-engine.md +0 -0
  59. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/storage-model.md +0 -0
  60. {sqlrite-0.3.0 → sqlrite-0.5.0}/docs/usage.md +0 -0
  61. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/README.md +0 -0
  62. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/c/Makefile +0 -0
  63. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/c/hello.c +0 -0
  64. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/go/go.mod +0 -0
  65. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/go/hello.go +0 -0
  66. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/hybrid-retrieval/README.md +0 -0
  67. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/hybrid-retrieval/hybrid_retrieval.rs +0 -0
  68. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/nodejs/hello.mjs +0 -0
  69. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/python/hello.py +0 -0
  70. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/rust/quickstart.rs +0 -0
  71. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/wasm/Makefile +0 -0
  72. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/wasm/index.html +0 -0
  73. {sqlrite-0.3.0 → sqlrite-0.5.0}/examples/wasm/server.mjs +0 -0
  74. {sqlrite-0.3.0 → sqlrite-0.5.0}/images/SQLRite - Desktop.png +0 -0
  75. {sqlrite-0.3.0 → sqlrite-0.5.0}/images/SQLRite Data Structures.png +0 -0
  76. {sqlrite-0.3.0 → sqlrite-0.5.0}/images/SQLRite Simple SQL Execution High Level Diagram.png +0 -0
  77. {sqlrite-0.3.0 → sqlrite-0.5.0}/images/SQLRite Simple SQL INSERT Execution High Level Diagram (Insert Row).png +0 -0
  78. {sqlrite-0.3.0 → sqlrite-0.5.0}/images/SQLRite Simple SQL INSERT Execution High Level Diagram.png +0 -0
  79. {sqlrite-0.3.0 → sqlrite-0.5.0}/images/SQLRite_logo.png +0 -0
  80. {sqlrite-0.3.0 → sqlrite-0.5.0}/images/architecture.png +0 -0
  81. {sqlrite-0.3.0 → sqlrite-0.5.0}/rust-toolchain.toml +0 -0
  82. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/AST.delete.example +0 -0
  83. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/AST.insert.exemple +0 -0
  84. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/AST.select.example +0 -0
  85. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/AST.update.example +0 -0
  86. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/CREATE TABLE sqlrite_schema.sql +0 -0
  87. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/CREATE_TABLE with duplicate.sql +0 -0
  88. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/CREATE_TABLE.sql +0 -0
  89. {sqlrite-0.3.0 → sqlrite-0.5.0}/samples/INSERT.sql +0 -0
  90. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/README.md +0 -0
  91. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/ask.go +0 -0
  92. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/ask_test.go +0 -0
  93. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/conn.go +0 -0
  94. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/go.mod +0 -0
  95. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/rows.go +0 -0
  96. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/sqlrite.go +0 -0
  97. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/sqlrite_test.go +0 -0
  98. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/go/stmt.go +0 -0
  99. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/python/README.md +0 -0
  100. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/python/src/lib.rs +0 -0
  101. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/python/tests/test_ask.py +0 -0
  102. {sqlrite-0.3.0 → sqlrite-0.5.0}/sdk/python/tests/test_sqlrite.py +0 -0
  103. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/README.md +0 -0
  104. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/src/lib.rs +0 -0
  105. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/src/prompt.rs +0 -0
  106. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/src/provider/anthropic.rs +0 -0
  107. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/src/provider/mock.rs +0 -0
  108. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/src/provider/mod.rs +0 -0
  109. {sqlrite-0.3.0 → sqlrite-0.5.0}/sqlrite-ask/tests/anthropic_http.rs +0 -0
  110. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/ask/mod.rs +0 -0
  111. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/ask/schema.rs +0 -0
  112. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/error.rs +0 -0
  113. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/lib.rs +0 -0
  114. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/main.rs +0 -0
  115. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/meta_command/mod.rs +0 -0
  116. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/repl/mod.rs +0 -0
  117. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/db/mod.rs +0 -0
  118. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/db/secondary_index.rs +0 -0
  119. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/db/table.rs +0 -0
  120. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/fts/bm25.rs +0 -0
  121. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/fts/mod.rs +0 -0
  122. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/fts/posting_list.rs +0 -0
  123. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/fts/tokenizer.rs +0 -0
  124. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/hnsw.rs +0 -0
  125. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/cell.rs +0 -0
  126. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/file.rs +0 -0
  127. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/fts_cell.rs +0 -0
  128. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/hnsw_cell.rs +0 -0
  129. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/index_cell.rs +0 -0
  130. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/interior_page.rs +0 -0
  131. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/page.rs +0 -0
  132. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/table_page.rs +0 -0
  133. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/varint.rs +0 -0
  134. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/pager/wal.rs +0 -0
  135. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/parser/create.rs +0 -0
  136. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/parser/insert.rs +0 -0
  137. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/parser/mod.rs +0 -0
  138. {sqlrite-0.3.0 → sqlrite-0.5.0}/src/sql/parser/select.rs +0 -0
  139. {sqlrite-0.3.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.3.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.3.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.3.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.3.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.3.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.3.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.3.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.3.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.3", 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.3.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.3.0",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -4,7 +4,7 @@ A SQLRite database is a single file, by convention named `*.sqlrite`. The file i
4
4
 
5
5
  All multi-byte integers in this format are **little-endian**.
6
6
 
7
- The current on-disk format is **version 4** (Phase 7) by default, with **version 5** written on demand whenever an FTS index is attached to the database (Phase 8c). Decoders accept both v4 and v5; writers preserve the existing version on no-op resaves so a v4 database without FTS stays v4. Files produced by versions 1 – 3 are rejected on open.
7
+ The current on-disk format is **version 4** (Phase 7) by default, with **version 5** written on demand whenever an FTS index is attached to the database (Phase 8c) and **version 6** written on demand whenever a save produces a non-empty freelist (SQLR-6). Decoders accept v4, v5, and v6; writers preserve the existing version on no-op resaves so a v4 database without FTS or freelist stays v4. Files produced by versions 1 – 3 are rejected on open.
8
8
 
9
9
  ## Page 0 — the database header
10
10
 
@@ -15,17 +15,18 @@ The first 4096 bytes of every file are the header page. Only the first 28 bytes
15
15
  │ offset │ length │ content │
16
16
  ├────────┼────────┼─────────────────────────────────────────────────┤
17
17
  │ 0 │ 16 │ magic: "SQLRiteFormat\0\0\0" │
18
- │ 16 │ 2 │ format version (u16 LE) = 4 or 5
18
+ │ 16 │ 2 │ format version (u16 LE) = 4, 5, or 6
19
19
  │ 18 │ 2 │ page size (u16 LE) = 4096 │
20
20
  │ 20 │ 4 │ total page count (u32 LE), includes page 0 │
21
21
  │ 24 │ 4 │ root page of sqlrite_master (u32 LE) │
22
- │ 28 │ 4068reserved / zero
22
+ │ 28 │ 4freelist head (u32 LE; 0 = empty) — v6 only
23
+ │ 32 │ 4064 │ reserved / zero │
23
24
  └────────┴────────┴─────────────────────────────────────────────────┘
24
25
  ```
25
26
 
26
27
  The magic string is 14 ASCII bytes (`SQLRiteFormat`) padded with two NUL bytes to fill 16 bytes. It's deliberately different from SQLite's `"SQLite format 3\0"` so the two formats can't be confused on inspection.
27
28
 
28
- `decode_header` in [`src/sql/pager/header.rs`](../src/sql/pager/header.rs) validates all three of (magic, format version, page size) on open. A wrong magic produces `not a SQLRite database`; a wrong version or page size produces `unsupported ...` errors. The decoder accepts both v4 and v5 (anything else is rejected); the parsed `format_version` is propagated through the in-memory `DbHeader` so the writer can preserve it on resave when no version-bumping feature has been added.
29
+ `decode_header` in [`src/sql/pager/header.rs`](../src/sql/pager/header.rs) validates all three of (magic, format version, page size) on open. A wrong magic produces `not a SQLRite database`; a wrong version or page size produces `unsupported ...` errors. The decoder accepts v4, v5, and v6 (anything else is rejected); the parsed `format_version` is propagated through the in-memory `DbHeader` so the writer can preserve it on resave when no version-bumping feature has been added. `freelist_head` is read from bytes [28..32]: v4/v5 files leave that region zero so it always decodes as `0` (an empty freelist), and v6 files store the page number of the first freelist trunk there.
29
30
 
30
31
  ## Pages 1..page_count — payload pages
31
32
 
@@ -53,6 +54,7 @@ Every non-header page starts with a 7-byte header:
53
54
  | `2` | `TableLeaf` | Holds a slot directory and a set of cells representing rows of a table. Leaves for one table are linked by sibling `next_page` pointers. |
54
55
  | `3` | `Overflow` | Continuation page carrying the spilled body of a single oversized cell. |
55
56
  | `4` | `InteriorNode` | Interior B-Tree node. Holds a slot directory of divider cells routing to child pages plus a rightmost-child pointer in the payload header. |
57
+ | `5` | `FreelistTrunk` | One link of the persisted free-page list (SQLR-6). Payload carries `count: u16` followed by `count × u32` free leaf-page numbers; `next_page` chains to the next trunk (0 = end). |
56
58
 
57
59
  Tag `1` is reserved (it was `SchemaRoot` in format v1; unused in v2). Any other tag on open is a corruption error.
58
60
 
@@ -307,6 +309,7 @@ These are not all enforced on open — we validate the header strictly and rely
307
309
  - **v3** (Phase 3e) — `sqlrite_master` gains a `type` column; secondary indexes persist as their own cell-based B-Trees whose leaves carry `KIND_INDEX` cells.
308
310
  - **v4** (Phase 7a) — value block dispatch gains the `0x04 Vector` tag for the new `VECTOR(N)` column type. Per the [Phase 7 plan's Q8](phase-7-plan.md#q8-file-format-version-bump), later Phase 7 sub-phases (JSON storage, HNSW indexes) added their own value/cell tags inside this same v4 envelope. The `CREATE TABLE` SQL stored in `sqlrite_master` carries vector columns as `VECTOR(N)` in the type position; on open, the engine re-parses that SQL and reconstructs `DataType::Vector(N)` from the `Custom` AST node sqlparser produces.
309
311
  - **v5** (Phase 8c, current for FTS-bearing files) — adds the `KIND_FTS_POSTING` cell tag for persisted FTS posting lists. Bumped **on demand** per the [Phase 8 plan's Q10](phase-8-plan.md#q10-file-format-version-bump-strategy): existing v4 databases without FTS keep writing v4 across non-FTS saves; the first save with at least one FTS index attached promotes the file to v5. Decoders accept both v4 and v5; opening a v4 file with a build that supports v5 is a no-op until the user creates an FTS index.
312
+ - **v6** (SQLR-6, current for files with persisted free-page lists) — adds the `freelist_head` field at header bytes [28..32] and the `FreelistTrunk` page tag (`5`). Bumped **on demand**: a save that ends with an empty freelist preserves the existing version; the first save that produces a non-empty freelist promotes the file to v6. Decoders accept v4, v5, and v6; v6 is a strict superset, so opening a v4/v5 file with a v6-aware build is a no-op until the user creates a freelist (e.g., by dropping a table or index). VACUUM clears the freelist but doesn't downgrade.
310
313
 
311
314
  The page header (7 bytes) and chaining mechanism are stable across future phases. Phase 4's WAL introduces a sibling file (`.sqlrite-wal`) rather than changing the main file format.
312
315
 
@@ -187,10 +187,37 @@ Without the diff, step 3's "re-serialize every table" would trigger a full file
187
187
 
188
188
  This only works because `save_database` iterates tables in sorted order — if the order were random, a table that didn't change might land at a different page number, appearing dirty. See [Design decisions §7](design-decisions.md#7-deterministic-page-number-ordering-when-saving).
189
189
 
190
+ ## Free-page list and VACUUM (SQLR-6)
191
+
192
+ Save now uses a [`PageAllocator`](../src/sql/pager/allocator.rs) instead of a bare `next_free_page` counter. The allocator pulls pages from three sources, in preference order:
193
+
194
+ 1. **Per-table preferred pool** — every table/index/master is given the page numbers it occupied last save (collected by walking from its old `rootpage`). An unchanged table re-stages byte-identical pages at the same numbers, so the diff pager skips every write for it.
195
+ 2. **Global freelist** — pages from dropped tables/indexes that are recorded in the persisted freelist (rooted at `header.freelist_head`).
196
+ 3. **Extend** — `next_extend++`, monotonic past the high-water mark.
197
+
198
+ After staging, pages that were live before this save but didn't get restaged this round (e.g., the leaves of a dropped table) move onto the new freelist. The freelist itself is encoded into a chain of `FreelistTrunk` pages — each trunk holds up to 1021 free leaf-page numbers plus a `next_page` pointer to the following trunk. Trunks consume some of the free pages they describe (a trunk page IS a free page borrowed for metadata), so a freelist of N pages takes `ceil(N / 1022)` trunks and persists `N − T` leaf entries.
199
+
200
+ `VACUUM;` (a SQL statement) calls [`vacuum_database`](../src/sql/pager/mod.rs), which is `save_database` with empty per-table preferred pools and an empty initial freelist. Allocation falls through to extend on every page → contiguous layout from page 1, no freelist trunks, file truncates to the new high-water mark on the next checkpoint.
201
+
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
+
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
+
190
218
  ## What it doesn't do (yet)
191
219
 
192
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.
193
- - **No free-page management.** When a table shrinks, the main file's tail pages are truncated at checkpoint, but there's no free-list to reuse pages inside a grown file.
194
221
  - **No per-statement granularity.** The whole database is re-serialized on every commit; the diff keeps the *written* set small but the CPU cost of reserialization is unchanged.
195
222
  - **No concurrent reader-and-writer.** Phase 4e graduated to shared/exclusive lock modes (multi-reader *or* single-writer), but POSIX flock can't give us both at once. True concurrent access would need a shared-memory coordination file with read marks — not on the roadmap.
196
223
  - **Savepoints / nested transactions.** Phase 4f added top-level `BEGIN` / `COMMIT` / `ROLLBACK` (snapshot-based rollback, auto-save suppressed inside a transaction), but nested `BEGIN` is rejected — real savepoints aren't on the roadmap.
@@ -17,6 +17,7 @@ If you're looking for _how_ to use SQLRite (REPL flow, meta-commands, history, e
17
17
  | [`ALTER TABLE`](#alter-table) | `RENAME TO`, `RENAME COLUMN`, `ADD COLUMN`, `DROP COLUMN` (one operation per statement) |
18
18
  | [`DROP TABLE`](#drop-table) / [`DROP INDEX`](#drop-index) | `IF EXISTS`; single target; auto-indexes refused for `DROP INDEX` |
19
19
  | [`BEGIN`](#transactions) / [`COMMIT`](#transactions) / [`ROLLBACK`](#transactions) | Snapshot-based; single-level; WAL-backed commit; auto-rollback on COMMIT disk failure |
20
+ | [`VACUUM`](#vacuum) | Compacts the file: rewrites every live B-Tree contiguously from page 1 and clears the freelist. Bare `VACUUM;` only — no modifiers. |
20
21
 
21
22
  Statements the parser accepts (because sqlparser understands them in the SQLite dialect) but SQLRite doesn't execute yet return `SQL Statement not supported yet`. The [Not yet supported](#not-yet-supported) section below enumerates the common ones.
22
23
 
@@ -259,7 +260,7 @@ DROP TABLE [IF EXISTS] <table>;
259
260
  - Reserved-name rejection: `DROP TABLE sqlrite_master` errors with the same message `CREATE TABLE` uses.
260
261
  - All indexes attached to the table (auto, explicit, HNSW, FTS) disappear with the table — they live inside the `Table` struct and ride along.
261
262
  - Without `IF EXISTS`, dropping a table that doesn't exist errors. With it, that's a benign 0-tables-dropped no-op.
262
- - **Disk pages are orphaned, not freed.** SQLRite has no free-list yet the file size doesn't shrink until a future `VACUUM`. The behavior is safe (orphan pages are unreachable from `sqlrite_master` after reopen) but means a write-heavy schema churn won't reclaim space until VACUUM lands.
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.
263
264
 
264
265
  ---
265
266
 
@@ -428,6 +429,42 @@ ROLLBACK; -- nothing was actually deleted
428
429
 
429
430
  ---
430
431
 
432
+ ## `VACUUM`
433
+
434
+ ```sql
435
+ VACUUM;
436
+ ```
437
+
438
+ Compacts the database file: rewrites every live table, index, HNSW graph, FTS posting tree, and `sqlrite_master` itself contiguously from page 1, drops the freelist, and lets the next checkpoint truncate the tail.
439
+
440
+ - **Bare `VACUUM;` only.** Modifiers — `VACUUM FULL`, `VACUUM REINDEX`, table targets, `TO ... PERCENT`, `BOOST` — are parsed (sqlparser supports them) but rejected at execution with `VACUUM modifiers (FULL, REINDEX, table targets, etc.) are not supported`.
441
+ - **Refused inside a transaction.** `BEGIN; VACUUM;` errors with `VACUUM cannot run inside a transaction`. Use `COMMIT;` first, then `VACUUM;`.
442
+ - **No-op on in-memory databases.** Returns a `VACUUM is a no-op for in-memory databases` status string and does nothing — there's no file to compact.
443
+ - **Status string** carries pages and bytes reclaimed: `VACUUM completed. <N> pages reclaimed (<B> bytes).`
444
+ - **Format-version side effect.** A v4/v5 file that has been promoted to v6 by an earlier drop stays at v6 after VACUUM (v6 is a strict superset; we don't downgrade). A file that's already at v4/v5 because no drop ever happened on it doesn't get bumped by VACUUM.
445
+
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
+
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
+
466
+ ---
467
+
431
468
  ## Read-only databases
432
469
 
433
470
  A REPL launched with `sqlrite --readonly foo.sqlrite` (or `sqlrite::open_database_read_only(path, name)` programmatically) takes a shared POSIX advisory lock instead of an exclusive one. In that mode:
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "sqlrite"
7
- version = "0.3.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" }
@@ -6,8 +6,8 @@
6
6
  # scripts/bump-version.sh 0.2.0
7
7
  #
8
8
  # Rewrites the version field in every manifest that carries one
9
- # (eight Cargo.toml / pyproject.toml files, plus three JSON manifests
10
- # — eleven files total). Then you run `cargo build` yourself to
9
+ # (nine Cargo.toml / pyproject.toml files, plus three JSON manifests
10
+ # — twelve files total). Then you run `cargo build` yourself to
11
11
  # refresh Cargo.lock. Idempotent: running twice with the same version
12
12
  # is a no-op; running twice with different versions lands on the
13
13
  # second.
@@ -84,6 +84,25 @@ for file in "${TOML_FILES[@]}"; do
84
84
  fi
85
85
  sed "s/^version = \"[^\"]*\"/version = \"${VERSION}\"/" "$file" > "$file.tmp"
86
86
  mv "$file.tmp" "$file"
87
+
88
+ # Inter-workspace dep pins — lines like:
89
+ # sqlrite-ask = { version = "0.3", path = "sqlrite-ask", ... }
90
+ # sqlrite = { package = "sqlrite-engine", path = "..", version = "0.3", ... }
91
+ #
92
+ # These carry BOTH version and path because crates.io publishing
93
+ # rejects path-only deps (see PR #58 retrospective). The version
94
+ # field has to track the workspace bump or `cargo build` fails to
95
+ # resolve a candidate (SQLR-9; failed run for v0.3.0 hit exactly
96
+ # this — `failed to select a version for the requirement
97
+ # sqlrite-ask = "^0.2"`).
98
+ #
99
+ # Detection: any line containing both `version = "..."` and
100
+ # `path = "..."`. The package-level `^version = "..."` line at
101
+ # the top of each manifest has no `path` on it and can't match.
102
+ # Both inline-table orderings (version-first and path-first) work
103
+ # because sed acts per-line, not per-token-order.
104
+ sed -E '/path *= *"[^"]*"/ s/version *= *"[^"]*"/version = "'"${VERSION}"'"/' "$file" > "$file.tmp"
105
+ mv "$file.tmp" "$file"
87
106
  done
88
107
 
89
108
  # ---------------------------------------------------------------------------
@@ -147,6 +166,22 @@ for file in "${JSON_FILES[@]}"; do
147
166
  fi
148
167
  done
149
168
 
169
+ # Inter-workspace pin sweep — any surviving `version = "X"` on a TOML
170
+ # line that also has `path = "..."` and isn't already at $VERSION is a
171
+ # pin we missed. Catches future refactors that change pin shape (e.g.
172
+ # someone splits a long dep line across multiple TOML lines, where the
173
+ # single-line address would no longer match).
174
+ for file in "${TOML_FILES[@]}"; do
175
+ bad="$(grep -nE 'path *= *"[^"]*"' "$file" \
176
+ | grep -E 'version *= *"[^"]*"' \
177
+ | grep -vE "version *= *\"${VERSION}\"" || true)"
178
+ if [[ -n "$bad" ]]; then
179
+ echo " ✗ $file — inter-workspace pin not at ${VERSION}:" >&2
180
+ echo "$bad" | sed 's/^/ /' >&2
181
+ FAILURES=$((FAILURES + 1))
182
+ fi
183
+ done
184
+
150
185
  if [[ $FAILURES -gt 0 ]]; then
151
186
  echo
152
187
  echo "error: $FAILURES file(s) did not update as expected." >&2
@@ -157,5 +192,5 @@ fi
157
192
  echo
158
193
  echo "Done. Next steps:"
159
194
  echo " cargo build # refresh Cargo.lock with the new versions"
160
- echo " git diff # inspect the ten-file bump"
195
+ echo " git diff # inspect the twelve-file bump"
161
196
  echo " git checkout . # or back out if it looks wrong"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sqlrite-python"
3
- version = "0.3.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.3.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 {
@@ -717,6 +717,60 @@ pub fn execute_alter_table(alter: AlterTable, db: &mut Database) -> Result<Strin
717
717
  }
718
718
  }
719
719
 
720
+ /// Executes `VACUUM;` (SQLR-6). Compacts the database file: rewrites
721
+ /// every live table, index, and the catalog contiguously from page 1,
722
+ /// drops the freelist, and truncates the tail at the next checkpoint.
723
+ ///
724
+ /// Refuses to run inside a transaction (would publish in-flight writes
725
+ /// out of band); refuses on read-only databases (handled upstream by
726
+ /// the read-only mutation gate); and is a no-op on in-memory databases
727
+ /// (no file to compact). Bare `VACUUM;` only — non-default options
728
+ /// (`FULL`, `REINDEX`, table targets, etc.) are rejected.
729
+ pub fn execute_vacuum(db: &mut Database) -> Result<String> {
730
+ if db.in_transaction() {
731
+ return Err(SQLRiteError::General(
732
+ "VACUUM cannot run inside a transaction".to_string(),
733
+ ));
734
+ }
735
+ let path = match db.source_path.clone() {
736
+ Some(p) => p,
737
+ None => {
738
+ return Ok("VACUUM is a no-op for in-memory databases".to_string());
739
+ }
740
+ };
741
+ // Checkpoint before AND after VACUUM so the main-file size we report
742
+ // reflects only what VACUUM actually reclaimed — without the leading
743
+ // checkpoint, `size_before` would be the stale main-file snapshot
744
+ // (typically 2 pages) while WAL holds the live bytes, making the
745
+ // bytes-reclaimed delta meaningless.
746
+ if let Some(pager) = db.pager.as_mut() {
747
+ let _ = pager.checkpoint();
748
+ }
749
+ let size_before = std::fs::metadata(&path).ok().map(|m| m.len()).unwrap_or(0);
750
+ let pages_before = db
751
+ .pager
752
+ .as_ref()
753
+ .map(|p| p.header().page_count)
754
+ .unwrap_or(0);
755
+ crate::sql::pager::vacuum_database(db, &path)?;
756
+ // Second checkpoint so the main file shrinks now — VACUUM's whole
757
+ // purpose is to reclaim bytes, so paying the I/O up front is fair.
758
+ if let Some(pager) = db.pager.as_mut() {
759
+ let _ = pager.checkpoint();
760
+ }
761
+ let size_after = std::fs::metadata(&path).ok().map(|m| m.len()).unwrap_or(0);
762
+ let pages_after = db
763
+ .pager
764
+ .as_ref()
765
+ .map(|p| p.header().page_count)
766
+ .unwrap_or(0);
767
+ let pages_reclaimed = pages_before.saturating_sub(pages_after);
768
+ let bytes_reclaimed = size_before.saturating_sub(size_after);
769
+ Ok(format!(
770
+ "VACUUM completed. {pages_reclaimed} pages reclaimed ({bytes_reclaimed} bytes)."
771
+ ))
772
+ }
773
+
720
774
  /// Renames a table in `db.tables`. Updates `tb_name`, every secondary
721
775
  /// index's `table_name` field, and any auto-index whose name embedded
722
776
  /// the old table name. HNSW / FTS index entries don't carry a