fix(datastore): reclaim catalog space via manual rebuild instead of VACUUM (swamp-club#494)#1480
Conversation
…ACUUM (swamp-club#494)
SQLite's native VACUUM (and VACUUM INTO) cannot run under Deno's node:sqlite
binding: both ATTACH a temporary database internally, but the binding hardcodes
SQLITE_LIMIT_ATTACHED to 0, so the attach is rejected with "too many attached
databases - max 0". As a result `swamp datastore compact` always skipped VACUUM
and never reclaimed fragmented pages — only the WAL checkpoint freed space.
Replace the VACUUM call with a manual rebuild that never uses ATTACH: copy the
schema and stream every row into a fresh single-file database via a second
connection, then atomically swap it into place and reopen. Rows are paged by
rowid in ITERATE_PAGE_SIZE batches so a large catalog is never loaded into
memory. close() is now idempotent so the CLI's finally{close()} cannot mask a
reopen failure.
Co-authored-by: Sean Escriva <webframp@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
datastore_compact.ts:37— progress message still says "Vacuuming"
Thevacuumingevent still logsVacuuming catalog database (this may take a moment).... The underlying operation is now a manual row-by-row rebuild, not a SQLite VACUUM. Updating to something likeRebuilding catalog database (this may take a moment)...would keep terminology consistent with the updated skip message (Catalog rebuild skipped). Low priority since users just want compaction to work, but the mismatch between the progress line and the skip/success lines is mildly confusing. -
vacuumSkippedJSON field name — now that the operation is a rebuild, the field name is slightly misleading for script consumers. Renaming torebuildSkippedwould be cleaner, but that is a breaking change to the JSON contract and not worth the churn here. Worth noting in a follow-up.
Verdict
PASS — the one changed user-visible line is an improvement (removes the technical VACUUM skipped (runtime limitation) jargon in favour of plain Catalog rebuild skipped). JSON shape is unchanged. No new flags, help text, or error-message regressions.
There was a problem hiding this comment.
Code Review
Clean, well-tested fix. The manual-rebuild approach correctly works around the SQLITE_LIMIT_ATTACHED=0 limitation. Changes are properly contained to the infrastructure persistence layer — the domain/application boundary (DatastoreCompactDeps, vacuum(): boolean contract) is unchanged, which is good DDD practice.
Blocking Issues
None.
Suggestions
-
closedflag not set duringvacuum()swap (catalog_store.ts:626):vacuum()callsthis.db.close()without settingthis.closed = true. If the subsequent rename or reopen throws, the store has a closeddbhandle butclosed === false, so a caller'sfinally { store.close() }would attempt a double-close and crash — defeating the idempotent-close guarantee this PR introduces. Consider settingthis.closed = truebeforethis.db.close()at line 626, then resettingthis.closed = falseafter successful reopen at line 645. -
SQL identifier quoting in
copyTableRows(catalog_store.ts:698-708): Table and column names are interpolated with double-quote wrapping ("${table}","${c}") but embedded double-quotes are not escaped (the standard SQL escape is""). The names all come fromsqlite_master/PRAGMA table_infoon the application's own schema, so this is safe for the known catalog tables — but if a future migration ever introduced a name containing", the rebuild would silently produce broken SQL. Low risk, minor hardening. -
Verbose JSDoc blocks: Several new multi-line comment blocks were added (e.g.,
vacuum(),openConnection(),rebuildInto(),copyTableRows()). CLAUDE.md says "one short line max" for comments. The why here is genuinely non-obvious (VACUUM limitation, rebuild strategy), which justifies commenting, but the blocks could be condensed. Existing code in this file uses similar JSDoc style so this is consistent with local convention.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found that block this merge.
The finding below is notable but ultimately MEDIUM because the preconditions (filesystem failures during rename/reopen) are unlikely on any platform where the compact command can successfully write the rebuilt temp file.
Medium
-
vacuum()bypasses theclosedflag, allowing double-close to mask errors —catalog_store.ts:626The PR adds
closedspecifically sofinally { close() }cannot mask a reopen failure. Butvacuum()callsthis.db.close()directly on line 626, never settingthis.closed = true. If any operation between line 626 and the successful reopen (line 644) throws — e.g.Deno.renameSyncfails on both the POSIX and Windows paths, oropenConnection/PRAGMA journal_mode=WALfails — the exception propagates to the caller. The CLI'sfinally { catalogStore.close() }(datastore_compact.ts:92-94) then entersclose(), seesthis.closed === false, and callsthis.db.close()on the already-closed handle.node:sqlitethrows "database is not open", which replaces the original exception.Breaking scenario: Disk full after
rebuildIntosucceeds —Deno.renameSyncfails. The user sees "database is not open" instead of the real ENOSPC error.Suggested fix: Set
this.closed = trueimmediately afterthis.db.close()on line 626, and setthis.closed = falseafter the successful reopen on line 644. This way thefinally { close() }is a safe no-op if the swap/reopen fails.Classified as MEDIUM rather than HIGH because: (a) the precondition (rename or open failing after a successful rebuild-and-write of the same filesystem) is unlikely, and (b) the user still sees a crash either way — only the error message is worse.
-
removeDbFilesswallows all errors, not just "file not found" —catalog_store.ts:729-738The catch block ignores every exception from
Deno.removeSync, includingPermissionDeniedorBusy. If sidecars can't be removed (e.g. antivirus holding a lock on Windows), the stale-walor-shmcould shadow the rebuilt database after the rename, causing WAL corruption or stale reads on the next open. A targeted catch forDeno.errors.NotFoundwould be safer.
Low
-
Number(row.__rowid)truncates bigint rowids above 2^53 —catalog_store.ts:719SQLite rowids are 64-bit.
Number()loses precision aboveNumber.MAX_SAFE_INTEGER. Two distinct high rowids could map to the sameNumber, makinglastRowidnever advance — an infinite loop. In practice, no catalog will have > 9 quadrillion rows, so this is theoretical only. -
Identifier quoting in
copyTableRowsdoesn't escape embedded double-quotes —catalog_store.ts:700,706Column and table names are interpolated with
"${name}"quoting. A name containing"would break the SQL (SQLite requires""escaping). All current names are simple ASCII identifiers fromcreateSchema(), so this is safe today but would silently break if a future schema introduces quoted identifiers.
Verdict
PASS — The core rebuild logic is well-structured: schema-then-data ordering, transaction-wrapped row copy, paged cursor to bound memory, cross-platform rename fallback, and idempotent close for the happy path. The medium findings are edge cases in error paths that are unlikely to trigger in practice. The test coverage for the happy path (including multi-page batches and empty catalogs) is solid.
Summary
swamp datastore compactalways failed its VACUUM step withVACUUM skipped: too many attached databases - max 0(ERR_SQLITE_ERROR), so it never reclaimed fragmented pages — only the WAL checkpoint freed any space.Root cause: SQLite's native
VACUUM(andVACUUM INTO) cannot run under Deno'snode:sqlitebinding. Both ATTACH a temporary database internally, but the binding hardcodesSQLITE_LIMIT_ATTACHEDto0, so the attach is rejected. Confirmed reproducible under bothdeno runand the compiled binary — VACUUM has never functioned in this runtime (PR #1340 only made the failure skip gracefully). Reproduced end-to-end on a fresh repo with no datastore configured, confirming the fault is purely in the local catalog.Fix: Replace the
VACUUMcall inCatalogStore.vacuum()with a manual rebuild that never uses ATTACH:sqlite_master(tables before indexes/triggers/views) into a fresh single-file database via a second connection.ITERATE_PAGE_SIZE(1000) batches inside one transaction, so a large (giga-swamp) catalog is never materialized in memory.close()is now idempotent so the CLI'sfinally { close() }cannot mask a reopen failure (node:sqlitethrows on double-close).catalog_meta(schema_version,populated) is copied by the generic table pass, so reopening does not trigger a destructive re-migration or needless backfill. Thevacuum()(): booleancontract and theDatastoreCompactDepsboundary are unchanged, so the libswamp compact operation and CLI command are untouched. The renderer's skip wording is softened (JSON shape unchanged).Test Plan
catalog_store_test.ts:close()is a no-opdeno check,deno lint,deno fmt --checkall clean.VACUUM skippedwarning is gone andswamp datastore compact --jsonreports"vacuumSkipped": false(previouslytrue).Fixes swamp-club#494.
🤖 Generated with Claude Code