Cleanup AI slop and simplify db interface
This commit is contained in:
431
src/db/index.ts
431
src/db/index.ts
@@ -1,8 +1,425 @@
|
||||
// Import the database connection
|
||||
import { db } from './db';
|
||||
export { db } from './db';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
|
||||
// Export all functions related to database operations
|
||||
export * from './posts';
|
||||
export * from './tags';
|
||||
export * from './queries';
|
||||
/**
|
||||
* Singleton database connection class for managing blog posts and tags
|
||||
*/
|
||||
class DatabaseConnection {
|
||||
private static instance: DatabaseConnection;
|
||||
private db: Database;
|
||||
|
||||
/**
|
||||
* Private constructor to initialize database connection and schema
|
||||
*/
|
||||
private constructor() {
|
||||
// Initialize the database if it doesn't exist
|
||||
const dbPath = path.join(process.cwd(), 'blog.sqlite');
|
||||
this.db = new Database(dbPath);
|
||||
|
||||
// Initialize database schema
|
||||
this.initializeDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of the DatabaseConnection
|
||||
*
|
||||
* @returns The DatabaseConnection singleton instance
|
||||
*/
|
||||
public static getInstance(): DatabaseConnection {
|
||||
if (!DatabaseConnection.instance) {
|
||||
DatabaseConnection.instance = new DatabaseConnection();
|
||||
}
|
||||
return DatabaseConnection.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the database connection
|
||||
*
|
||||
* @returns The SQLite database instance
|
||||
*/
|
||||
public getDatabase(): Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database schema with posts, tags, and post_tags tables
|
||||
*/
|
||||
private initializeDatabase() {
|
||||
// Create the posts table if it doesn't exist
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
date TEXT,
|
||||
reading_time TEXT,
|
||||
summary TEXT,
|
||||
content TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Create the tags table if it doesn't exist
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
url_normalized TEXT GENERATED ALWAYS AS (lower(replace(name, ' ', '-'))) STORED
|
||||
)
|
||||
`);
|
||||
|
||||
// Create the post_tags junction table for many-to-many relationship
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id INTEGER,
|
||||
tag_id INTEGER,
|
||||
PRIMARY KEY (post_id, tag_id),
|
||||
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a post to the database with its metadata and content
|
||||
*
|
||||
* @param filePath - The path to the post file
|
||||
* @param data - An object containing the post metadata (title, date, tags, etc.)
|
||||
* @param content - The full content of the post
|
||||
*/
|
||||
public addPost(filePath: string, data: { [key: string]: any }, content: string) {
|
||||
if (!data) return;
|
||||
|
||||
// Use a transaction to ensure data consistency
|
||||
const transaction = this.db.transaction(() => {
|
||||
try {
|
||||
// Extract common fields
|
||||
const { title, date, readingTime, tags, excerpt } = data;
|
||||
|
||||
// Convert all values to strings or null explicitly (except tags)
|
||||
// Ensure date is stored in ISO format for consistent sorting
|
||||
const values = [
|
||||
filePath ? String(filePath) : null,
|
||||
title ? String(title) : null,
|
||||
date ? (date instanceof Date ? date.toISOString().split('T')[0] : String(date)) : null,
|
||||
readingTime ? String(readingTime) : null,
|
||||
excerpt ? String(excerpt) : null,
|
||||
content ? String(content) : null
|
||||
];
|
||||
|
||||
// Query to insert or replace metadata (without tags)
|
||||
const insertPost = this.db.query(`
|
||||
INSERT OR REPLACE INTO posts
|
||||
(path, title, date, reading_time, summary, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertPost.run(...values);
|
||||
|
||||
// Get the post ID
|
||||
const getPostId = this.db.query('SELECT id FROM posts WHERE path = ?');
|
||||
const postResult = getPostId.get(filePath) as { id: number } | undefined;
|
||||
if (!postResult) {
|
||||
throw new Error(`Failed to retrieve post ID for ${filePath}`);
|
||||
}
|
||||
|
||||
// Delete existing tag associations for this post
|
||||
const deleteExistingTags = this.db.query('DELETE FROM post_tags WHERE post_id = ?');
|
||||
deleteExistingTags.run(postResult.id);
|
||||
|
||||
// If tags exist, process them
|
||||
if (tags && Array.isArray(tags)) {
|
||||
// Insert into junction table
|
||||
const insertPostTag = this.db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)');
|
||||
|
||||
// Create or get tag and associate with post
|
||||
const createOrGetTag = this.db.query(`
|
||||
INSERT OR IGNORE INTO tags (name) VALUES (?)
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
const getTag = this.db.query('SELECT id FROM tags WHERE name = ?');
|
||||
|
||||
for (const tag of tags) {
|
||||
let tagResult = createOrGetTag.get(String(tag)) as { id: number } | undefined;
|
||||
|
||||
// If no result from insert, the tag already exists, so get it
|
||||
if (!tagResult) {
|
||||
tagResult = getTag.get(String(tag)) as { id: number } | undefined;
|
||||
}
|
||||
|
||||
if (!tagResult) {
|
||||
throw new Error(`Failed to retrieve tag ID for ${tag}`);
|
||||
}
|
||||
|
||||
insertPostTag.run(postResult.id, tagResult.id);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to store ${filePath}:`, error);
|
||||
throw error; // Re-throw to make the transaction fail
|
||||
}
|
||||
});
|
||||
|
||||
// Execute the transaction
|
||||
try {
|
||||
transaction();
|
||||
} catch (error) {
|
||||
console.error(`Transaction failed for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total number of posts in the database
|
||||
*
|
||||
* @param tags - Optional array of tag names to filter posts by
|
||||
* @returns The number of posts, optionally filtered by tags
|
||||
*/
|
||||
public getNumOfPosts(tags: string[] = []) {
|
||||
if (tags.length === 0) {
|
||||
const queryCount = this.db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\'');
|
||||
const numPosts = queryCount.get() as { count: number };
|
||||
return numPosts.count;
|
||||
} else {
|
||||
// Use url_normalized field for tag filtering
|
||||
const placeholders = tags.map(() => '?').join(',');
|
||||
|
||||
// Get count of posts that have ALL the specified tags using url_normalized
|
||||
const countQuery = this.db.query(`
|
||||
SELECT COUNT(*) AS count FROM (
|
||||
SELECT p.id, COUNT(DISTINCT t.id) as tag_count
|
||||
FROM posts p
|
||||
JOIN post_tags pt ON p.id = pt.post_id
|
||||
JOIN tags t ON pt.tag_id = t.id
|
||||
WHERE t.url_normalized IN (${placeholders})
|
||||
AND p.path NOT LIKE '%.md'
|
||||
GROUP BY p.id
|
||||
HAVING COUNT(DISTINCT t.id) = ?
|
||||
)
|
||||
`);
|
||||
|
||||
const result = countQuery.get(...tags, tags.length) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of posts
|
||||
*
|
||||
* @param limit - Maximum number of posts to return (default: 10)
|
||||
* @param offset - Number of posts to skip for pagination (default: 0)
|
||||
* @param tags - Optional array of tag names to filter posts by
|
||||
* @returns Array of posts with metadata and cleaned paths
|
||||
*/
|
||||
public getPosts(limit: number = 10, offset: number = 0, tags: string[] = []):
|
||||
Array<{
|
||||
id: number,
|
||||
path: string,
|
||||
title: string,
|
||||
date: string,
|
||||
readingTime: string,
|
||||
summary: string,
|
||||
content: string,
|
||||
tags: string[],
|
||||
}>
|
||||
{
|
||||
let posts;
|
||||
|
||||
if (tags.length === 0) {
|
||||
// No tags specified, use the original query
|
||||
const query = this.db.query(`
|
||||
SELECT * FROM posts
|
||||
WHERE path NOT LIKE '%.md'
|
||||
ORDER BY date DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
posts = query.all(limit, offset) as any[];
|
||||
} else {
|
||||
// Filter by tags using url_normalized field
|
||||
// and ensure posts have ALL the specified tags
|
||||
const placeholders = tags.map(() => '?').join(',');
|
||||
const tagFilter = this.db.query(`
|
||||
SELECT p.* FROM posts p
|
||||
JOIN post_tags pt ON p.id = pt.post_id
|
||||
JOIN tags t ON pt.tag_id = t.id
|
||||
WHERE t.url_normalized IN (${placeholders})
|
||||
AND p.path NOT LIKE '%.md'
|
||||
GROUP BY p.id
|
||||
HAVING COUNT(DISTINCT t.id) = ?
|
||||
ORDER BY p.date DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
// Use the tags directly since we're now comparing with url_normalized
|
||||
posts = tagFilter.all(...tags, tags.length, limit, offset) as any[];
|
||||
}
|
||||
|
||||
// Helper function to get tags for a post
|
||||
const getPostTags = (postId: number): string[] => {
|
||||
const query = this.db.query(`
|
||||
SELECT t.name FROM tags t
|
||||
JOIN post_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = ?
|
||||
`);
|
||||
|
||||
const results = query.all(postId) as { name: string }[];
|
||||
return results.map(row => row.name);
|
||||
};
|
||||
|
||||
// Add tags to each post and clean up paths
|
||||
return posts.map(post => {
|
||||
const basePost = post satisfies {
|
||||
id: number,
|
||||
path: string,
|
||||
title: string,
|
||||
date: string,
|
||||
readingTime: string,
|
||||
summary: string,
|
||||
content: string
|
||||
};
|
||||
|
||||
return {
|
||||
...basePost,
|
||||
tags: getPostTags(post.id),
|
||||
path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single post by its file path
|
||||
*
|
||||
* @param path - The file path of the post to retrieve
|
||||
* @returns The post object with metadata and tags, or null if not found
|
||||
*/
|
||||
public getPost(path: string) {
|
||||
const query = this.db.query(`
|
||||
SELECT * FROM posts
|
||||
WHERE path = ?
|
||||
`);
|
||||
|
||||
const post = query.get(path) as any;
|
||||
if (!post) return null;
|
||||
|
||||
// Helper function to get tags for a post
|
||||
const getPostTags = (postId: number): string[] => {
|
||||
const query = this.db.query(`
|
||||
SELECT t.name FROM tags t
|
||||
JOIN post_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = ?
|
||||
`);
|
||||
|
||||
const results = query.all(postId) as { name: string }[];
|
||||
return results.map(row => row.name);
|
||||
};
|
||||
|
||||
// Get tags for this post
|
||||
post.tags = getPostTags(post.id);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the chronologically adjacent posts (previous and next) for navigation
|
||||
*
|
||||
* @param currentPostPath - The file path of the current post
|
||||
* @returns Object with previousPost and nextPost properties, each containing title and path, or null if no adjacent post exists
|
||||
*/
|
||||
public getAdjacentPosts(currentPostPath: string) {
|
||||
const allPostsQuery = this.db.query(`
|
||||
SELECT path, title, date
|
||||
FROM posts
|
||||
WHERE path NOT LIKE '%.md'
|
||||
ORDER BY date DESC
|
||||
`);
|
||||
|
||||
const allPosts = allPostsQuery.all() as any[];
|
||||
|
||||
// Find the current post index
|
||||
const currentIndex = allPosts.findIndex(post => post.path === currentPostPath);
|
||||
|
||||
// If not found, return empty navigation
|
||||
if (currentIndex === -1) {
|
||||
return { previousPost: null, nextPost: null };
|
||||
}
|
||||
|
||||
// Get previous post (newer post, which comes before current in reverse chronological order)
|
||||
let previousPost = null;
|
||||
if (currentIndex > 0) {
|
||||
const prevPost = allPosts[currentIndex - 1];
|
||||
// Clean up the path to match the URL structure
|
||||
const cleanPath = prevPost.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||
previousPost = {
|
||||
title: prevPost.title,
|
||||
path: cleanPath
|
||||
};
|
||||
}
|
||||
|
||||
// Get next post (older post, which comes after current in reverse chronological order)
|
||||
let nextPost = null;
|
||||
if (currentIndex < allPosts.length - 1) {
|
||||
const post = allPosts[currentIndex + 1];
|
||||
// Clean up the path to match the URL structure
|
||||
const cleanPath = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||
nextPost = {
|
||||
title: post.title,
|
||||
path: cleanPath
|
||||
};
|
||||
}
|
||||
|
||||
return { previousPost, nextPost };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all unique tags from the database with their respective post counts.
|
||||
*
|
||||
* This method implements caching to avoid repeated database queries. After the first
|
||||
* call, subsequent calls will return the cached results. Be aware that on db build, this
|
||||
* likely won't be accurate because of the natural race condition and the server should be restarted.
|
||||
*
|
||||
* @returns {Array<{ name: string; post_count: number; urlNormalized: string }>}
|
||||
* An array of tag objects containing:
|
||||
* - name: The display name of the tag
|
||||
* - post_count: Number of posts associated with this tag
|
||||
* - urlNormalized: The URL-safe version of the tag name
|
||||
*/
|
||||
public getAllTags(): { name: string; post_count: number; urlNormalized: string }[] {
|
||||
if (!this.getAllTagsCache) {
|
||||
this.getAllTagsCache = this.initGetAllTags();
|
||||
}
|
||||
return this.getAllTagsCache;
|
||||
}
|
||||
|
||||
private getAllTagsCache: { name: string; post_count: number; urlNormalized: string }[] | null = null
|
||||
|
||||
private initGetAllTags() {
|
||||
const query = db.query(`
|
||||
SELECT t.name, t.url_normalized, COUNT(pt.post_id) as post_count
|
||||
FROM tags t
|
||||
JOIN post_tags pt ON t.id = pt.tag_id
|
||||
JOIN posts p ON pt.post_id = p.id
|
||||
GROUP BY t.id, t.name, t.url_normalized
|
||||
ORDER BY post_count DESC, t.name ASC
|
||||
`);
|
||||
|
||||
const results = query.all() as { name: string; url_normalized: string; post_count: number }[];
|
||||
return results.map(row => ({
|
||||
name: row.name,
|
||||
urlNormalized: row.url_normalized,
|
||||
post_count: row.post_count
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQLite database connection class
|
||||
* Use this by default for using helper query functions
|
||||
*/
|
||||
export const dbConnection = DatabaseConnection.getInstance();
|
||||
|
||||
/**
|
||||
* The SQLite database connection instance
|
||||
* Use this exported instance for direct database operations
|
||||
*/
|
||||
export const db = dbConnection.getDatabase();
|
||||
|
||||
Reference in New Issue
Block a user