Files
Blog/src/db/index.ts

419 lines
13 KiB
TypeScript

import { Database } from 'bun:sqlite';
import path from 'path';
import { calculateReadTime } from '../frontend/utils';
/**
* 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) : calculateReadTime(content),
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, readingTime: post.reading_time}
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();