Files
hapi/hub/scripts/compact-db.ts
weishu 05ba050eb8 feat(hub): add message content codec for storage optimization
Implement two-stage content codec: truncate oversized agent messages
(48KB head + 12KB tail + marker, idempotent, never user messages) and
compress message content JSON ≥256 bytes via zstd with fallback.

- contentCodec.ts: new codec with truncation and compression routines
- messages.ts: wire codec into addMessage (truncate+compress),
  copyMessageToSession (lossless), toStoredMessage (decode both formats)
- index.ts: schema version 15→16 with no-op migration (enforces
  schema match on downgrade, no DDL change)
- codexDesktop.ts: pass content through idempotent truncation for
  canonical comparison in transcript-import
- cleanup-sessions.ts: decode compressed rows, replace full-scan with
  per-session batched scan (LIMIT 50) stopping at first user message
- compact-db.ts: new offline compactor that retroactively applies
  truncate+compress+VACUUM to existing DBs; guards against schema
  version mismatch, symlink aliasing, and handles re-runs
- Tests: contentCodec round-trip + truncation + idempotence + legacy
  decode; messages integration (compressed/truncated round-trip);
  migration tests updated to version 16
- README.md: document codec and both maintenance scripts

Measured on 2.26GB production DB: 2159MB→768MB (-64%) in 12s,
748k messages intact, integrity verified.
2026-08-02 13:21:32 +08:00

273 lines
11 KiB
TypeScript

#!/usr/bin/env bun
/**
* Offline DB compactor: retroactively applies the message content codec
* (truncate oversized agent tool output + zstd-compress content, see
* hub/src/store/contentCodec.ts) to an existing hapi.db, then VACUUMs.
*
* The source database is only ever opened read-only. All work happens on a
* snapshot; the result is written to a NEW file which you swap in manually.
*
* Usage:
* bun run hub/scripts/compact-db.ts [options]
*
* Options:
* --db=PATH Source database (default: $HAPI_HOME/hapi.db or ~/.hapi/hapi.db)
* --out=PATH Output path (default: <db>.compacted)
* --keep-oversized Do not truncate oversized agent output, compress only
* --force Overwrite an existing output file
* --help Show this help
*
* Swap procedure (after the script succeeds):
* 1. Stop the hub (and re-run this script if it wrote during the run)
* 2. mv ~/.hapi/hapi.db ~/.hapi/hapi.db.pre-compact
* 3. mv ~/.hapi/hapi.db.compacted ~/.hapi/hapi.db
* 4. rm -f ~/.hapi/hapi.db-wal ~/.hapi/hapi.db-shm (they belong to the old file)
* 5. Start the hub; keep the .pre-compact backup until satisfied
*/
import { Database } from 'bun:sqlite'
import { chmodSync, existsSync, realpathSync, rmSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import {
COMPRESS_MIN_CHARS,
compressContentJson,
decodeMessageContent,
truncateOversizedMessageContent
} from '../src/store/contentCodec'
/** Minimum schema version this script understands; also what it stamps, since
* compressed BLOB content is a V16 concept (see Store.migrateFromV15ToV16). */
const CODEC_SCHEMA_VERSION = 16
function getDefaultDbPath(): string {
const dataDir = process.env.HAPI_HOME
? process.env.HAPI_HOME.replace(/^~/, homedir())
: join(homedir(), '.hapi')
return join(dataDir, 'hapi.db')
}
function parseArgs(argv: string[]) {
const args = { db: getDefaultDbPath(), out: '', keepOversized: false, force: false, help: false }
for (const arg of argv) {
if (arg === '--help' || arg === '-h') args.help = true
else if (arg === '--keep-oversized') args.keepOversized = true
else if (arg === '--force') args.force = true
else if (arg.startsWith('--db=')) args.db = arg.slice('--db='.length).replace(/^~/, homedir())
else if (arg.startsWith('--out=')) args.out = arg.slice('--out='.length).replace(/^~/, homedir())
else {
console.error(`Unknown option: ${arg} (see --help)`)
process.exit(1)
}
}
if (!args.out) args.out = `${args.db}.compacted`
return args
}
function sqlPathLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/** Resolve symlinks so an aliased spelling of the source (or its WAL/SHM)
* cannot slip past the identity check below. The path may not exist yet, so
* fall back to canonicalizing its parent and re-attaching the basename. */
function canonicalize(path: string): string {
const absolute = resolve(path)
try {
return realpathSync(absolute)
} catch {
try {
return join(realpathSync(dirname(absolute)), basename(absolute))
} catch {
return absolute
}
}
}
function fmtMB(bytes: number): string {
return `${(bytes / 1048576).toFixed(1)} MB`
}
function contentByteLength(value: string | Uint8Array): number {
return typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : value.byteLength
}
const args = parseArgs(process.argv.slice(2))
if (args.help) {
console.log(`Offline DB compactor: truncates oversized agent output, zstd-compresses
message content and VACUUMs — writing a NEW file, never touching the source.
Usage: bun run hub/scripts/compact-db.ts [options]
Options:
--db=PATH Source database (default: $HAPI_HOME/hapi.db or ~/.hapi/hapi.db)
--out=PATH Output path (default: <db>.compacted)
--keep-oversized Do not truncate oversized agent output, compress only
--force Overwrite an existing output file
After it succeeds, follow the printed swap instructions.`)
process.exit(0)
}
if (!existsSync(args.db)) {
console.error(`Database not found: ${args.db}`)
process.exit(1)
}
const workPath = `${args.out}.work`
// The source must never be written. Refuse output paths that alias the source
// database or its WAL/SHM (e.g. --out="$DB" --force would otherwise delete the
// source right here, before the snapshot is taken). Sidecar names must derive
// from both the lexical spelling and the canonical target: with a symlinked
// --db, real.db-wal is not the canonicalized form of link.db-wal, yet deleting
// it would corrupt the real database.
const protectedPaths = new Set<string>()
for (const dbSpelling of new Set([resolve(args.db), canonicalize(args.db)])) {
for (const sidecar of [dbSpelling, `${dbSpelling}-wal`, `${dbSpelling}-shm`]) {
protectedPaths.add(canonicalize(sidecar))
}
}
for (const [label, path] of [['--out', args.out], ['work file', workPath]] as const) {
if (protectedPaths.has(canonicalize(path))) {
console.error(`Refusing to use ${path} as ${label}: it aliases the source database files.`)
process.exit(1)
}
}
if (existsSync(args.out)) {
if (!args.force) {
console.error(`Output already exists: ${args.out} (use --force to overwrite)`)
process.exit(1)
}
rmSync(args.out)
}
rmSync(workPath, { force: true })
const sourceBytes = statSync(args.db).size
console.log(`Source: ${args.db} (${fmtMB(sourceBytes)})`)
console.log(`Output: ${args.out}`)
console.log('Note: the source is opened read-only; stop the hub before swapping so no new writes land after this snapshot.')
console.log('')
// 1. Consistent snapshot without touching the source
console.log('Snapshotting source → work copy...')
const src = new Database(args.db, { readonly: true })
src.exec(`VACUUM INTO ${sqlPathLiteral(workPath)}`)
src.close()
// 2. Transform the work copy
const work = new Database(workPath)
work.exec('PRAGMA journal_mode = OFF')
work.exec('PRAGMA synchronous = OFF')
const versionRow = work.prepare('PRAGMA user_version').get() as { user_version: number }
if (versionRow.user_version < CODEC_SCHEMA_VERSION - 1) {
console.error(`Schema version ${versionRow.user_version} is older than V${CODEC_SCHEMA_VERSION - 1}; start the hub once to migrate, then re-run.`)
work.close()
rmSync(workPath)
process.exit(1)
}
if (versionRow.user_version > CODEC_SCHEMA_VERSION) {
console.error(`Schema version ${versionRow.user_version} is newer than this script understands (V${CODEC_SCHEMA_VERSION}); update hapi and use its compact script.`)
work.close()
rmSync(workPath)
process.exit(1)
}
const totalRow = work.prepare('SELECT COUNT(*) AS n FROM messages').get() as { n: number }
console.log(`Transforming ${totalRow.n} messages (${args.keepOversized ? 'compress only' : 'truncate + compress'})...`)
const selectBatch = work.prepare(
'SELECT rowid AS rid, content FROM messages WHERE rowid > ? ORDER BY rowid LIMIT 2000'
)
const updateRow = work.prepare('UPDATE messages SET content = ? WHERE rowid = ?')
let scanned = 0
let truncated = 0
let compressed = 0
let bytesBefore = 0
let bytesAfter = 0
let lastRid = 0
while (true) {
const rows = selectBatch.all(lastRid) as Array<{ rid: number; content: string | Uint8Array }>
if (rows.length === 0) break
lastRid = rows[rows.length - 1]!.rid
work.transaction(() => {
for (const row of rows) {
scanned++
const before = contentByteLength(row.content)
bytesBefore += before
const isBlob = typeof row.content !== 'string'
// Fast skips: small plaintext rows need neither transform; BLOBs
// are already compressed, so with --keep-oversized nothing is left
// to do for them either.
if (typeof row.content === 'string' ? row.content.length < COMPRESS_MIN_CHARS : args.keepOversized) {
bytesAfter += before
continue
}
const parsed = decodeMessageContent(row.content)
if (parsed === null) {
bytesAfter += before
continue
}
const transformed = args.keepOversized ? parsed : truncateOversizedMessageContent(parsed)
if (isBlob && transformed === parsed) {
// Compressed BLOBs are decoded above rather than skipped outright:
// rows from copyMessageToSession or a --keep-oversized run may
// still carry untruncated agent output.
bytesAfter += before
continue
}
const json = !isBlob && transformed === parsed ? (row.content as string) : JSON.stringify(transformed)
const encoded = compressContentJson(json)
if (!isBlob && transformed === parsed && typeof encoded === 'string') {
bytesAfter += before
continue
}
updateRow.run(encoded as string | Uint8Array, row.rid)
if (transformed !== parsed) truncated++
if (!isBlob && typeof encoded !== 'string') compressed++
bytesAfter += contentByteLength(encoded)
}
})()
if (scanned % 100_000 < 2000) {
console.log(` ${scanned}/${totalRow.n} scanned...`)
}
}
work.exec(`PRAGMA user_version = ${CODEC_SCHEMA_VERSION}`)
// 3. Reclaim freed pages into the final output
console.log('Vacuuming into final output...')
work.exec(`VACUUM INTO ${sqlPathLiteral(args.out)}`)
work.close()
rmSync(workPath)
// 4. Verify
const out = new Database(args.out, { readonly: true })
const integrity = out.prepare('PRAGMA integrity_check').get() as { integrity_check: string }
const outCount = out.prepare('SELECT COUNT(*) AS n FROM messages').get() as { n: number }
out.close()
if (integrity.integrity_check !== 'ok' || outCount.n !== totalRow.n) {
console.error(`Verification FAILED (integrity: ${integrity.integrity_check}, messages: ${outCount.n}/${totalRow.n}); output left at ${args.out} for inspection.`)
process.exit(1)
}
chmodSync(args.out, 0o600)
const outBytes = statSync(args.out).size
console.log('')
console.log(`Done. Verified: integrity ok, ${outCount.n} messages.`)
console.log(` content bytes: ${fmtMB(bytesBefore)}${fmtMB(bytesAfter)} (${truncated} truncated, ${compressed} compressed)`)
console.log(` file size: ${fmtMB(sourceBytes)}${fmtMB(outBytes)}`)
console.log('')
console.log('To swap in the compacted DB:')
console.log(' 1. Stop the hub')
console.log(` 2. mv ${args.db} ${args.db}.pre-compact`)
console.log(` 3. mv ${args.out} ${args.db}`)
console.log(` 4. rm -f ${args.db}-wal ${args.db}-shm`)
console.log(' 5. Start the hub (keep the .pre-compact backup until satisfied)')