#!/usr/bin/env bun
/**
* Migrate documents table from collection_id to collection name
*
* This script updates the database schema to use collection names
* instead of collection_id foreign keys, preparing for YAML-based
* collection management.
*/
import { Database } from "bun:sqlite";
import { join } from "path";
import { homedir } from "os";
const c = {
reset: "\x1b[0m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
dim: "\x1b[2m",
};
const dbPath = join(homedir(), ".cache", "qmd", "index.sqlite");
console.log(`${c.cyan}Migrating database schema...${c.reset}\n`);
console.log(`Database: ${dbPath}\n`);
const db = new Database(dbPath);
try {
db.exec("BEGIN TRANSACTION");
// Step 1: Add collection column to documents
console.log(`${c.yellow}1. Adding 'collection' column to documents table...${c.reset}`);
db.exec(`ALTER TABLE documents ADD COLUMN collection TEXT`);
console.log(` ${c.green}✓${c.reset} Column added`);
// Step 2: Populate collection names from collections table
console.log(`\n${c.yellow}2. Populating collection names...${c.reset}`);
const result = db.exec(`
UPDATE documents
SET collection = (
SELECT name FROM collections WHERE collections.id = documents.collection_id
)
WHERE collection IS NULL
`);
console.log(` ${c.green}✓${c.reset} Updated ${result} rows`);
// Step 3: Verify no NULL values
const nullCount = db.query<{ count: number }, []>(
`SELECT COUNT(*) as count FROM documents WHERE collection IS NULL`
).get();
if (nullCount && nullCount.count > 0) {
throw new Error(`Found ${nullCount.count} documents with NULL collection names`);
}
console.log(` ${c.green}✓${c.reset} All documents have collection names`);
// Step 4: Create new documents table without collection_id
console.log(`\n${c.yellow}3. Creating new documents table...${c.reset}`);
db.exec(`
CREATE TABLE documents_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection TEXT NOT NULL,
path TEXT NOT NULL,
title TEXT NOT NULL,
hash TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
active INTEGER DEFAULT 1,
FOREIGN KEY (hash) REFERENCES content(hash) ON DELETE CASCADE,
UNIQUE(collection, path)
)
`);
console.log(` ${c.green}✓${c.reset} New table created`);
// Step 5: Copy data
console.log(`\n${c.yellow}4. Copying data to new table...${c.reset}`);
db.exec(`
INSERT INTO documents_new (id, collection, path, title, hash, created_at, modified_at, active)
SELECT id, collection, path, title, hash, created_at, modified_at, active
FROM documents
`);
const rowCount = db.query<{ count: number }, []>(
`SELECT COUNT(*) as count FROM documents_new`
).get();
console.log(` ${c.green}✓${c.reset} Copied ${rowCount?.count} documents`);
// Step 6: Drop old table and rename new one
console.log(`\n${c.yellow}5. Replacing old table...${c.reset}`);
db.exec(`DROP TABLE documents`);
db.exec(`ALTER TABLE documents_new RENAME TO documents`);
console.log(` ${c.green}✓${c.reset} Table replaced`);
// Step 7: Recreate indices
console.log(`\n${c.yellow}6. Recreating indices...${c.reset}`);
db.exec(`CREATE INDEX idx_documents_collection ON documents(collection, active)`);
db.exec(`CREATE INDEX idx_documents_hash ON documents(hash)`);
console.log(` ${c.green}✓${c.reset} Indices created`);
// Step 8: Update FTS trigger to use collection name
console.log(`\n${c.yellow}7. Updating FTS trigger...${c.reset}`);
db.exec(`DROP TRIGGER IF EXISTS documents_ai`);
db.exec(`
CREATE TRIGGER documents_ai AFTER INSERT ON documents
WHEN new.active = 1
BEGIN
INSERT INTO documents_fts(rowid, filepath, title, body)
SELECT
new.id,
new.collection || '/' || new.path,
new.title,
(SELECT doc FROM content WHERE hash = new.hash)
WHERE new.active = 1;
END
`);
db.exec(`DROP TRIGGER IF EXISTS documents_au`);
db.exec(`
CREATE TRIGGER documents_au AFTER UPDATE ON documents
BEGIN
-- Delete from FTS if no longer active
DELETE FROM documents_fts WHERE rowid = old.id AND new.active = 0;
-- Update FTS if still/newly active
INSERT OR REPLACE INTO documents_fts(rowid, filepath, title, body)
SELECT
new.id,
new.collection || '/' || new.path,
new.title,
(SELECT doc FROM content WHERE hash = new.hash)
WHERE new.active = 1;
END
`);
console.log(` ${c.green}✓${c.reset} Triggers updated`);
// Commit transaction
db.exec("COMMIT");
console.log(`\n${c.green}✓ Migration completed successfully!${c.reset}`);
// Show summary
const collections = db.query<{ collection: string; count: number }, []>(`
SELECT collection, COUNT(*) as count
FROM documents
WHERE active = 1
GROUP BY collection
ORDER BY collection
`).all();
console.log(`\n${c.dim}Documents by collection:${c.reset}`);
for (const coll of collections) {
console.log(` ${coll.collection}: ${coll.count} files`);
}
} catch (error) {
db.exec("ROLLBACK");
console.error(`\n${c.yellow}✗ Migration failed:${c.reset} ${error}`);
console.error(`${c.dim}Database rolled back to previous state${c.reset}`);
process.exit(1);
} finally {
db.close();
}