feat: draft-in-db, publishd-in-file workflow

This commit is contained in:
2026-02-10 16:05:36 +01:00
parent 0a6710b684
commit 8c118b8b38
9 changed files with 528 additions and 311 deletions

View File

@@ -54,8 +54,23 @@ Posts in draft are automatically saved during edit every 20 seconds and a dot in
information about its state as unsaved. The user can force the save with the standard hotkey for that
purpose or just wait. Switching to another post will also save a draft automatically.
Important for the handling of posts is that draft content is always kept in the database and only when
the user uses the publish button the content is moved to the filesystem and the content in the database
is set to empty again. This is meant to keep draft content in the database, as it is volatile, and only
published content in filesystem, where it is then later used by the publishing pipeline.
So only posts in state draft have content in the database, but whenever something goes to state published,
the draft content is set to empty. draft content contains text content as well as metadata content that
was changed. So even if the user changes tags or the category or the title or whatnot, the actual data
is first only kept in the database and only on publish moved to the file.
Database rebuild at startup is not overwriting draft content, it is only recreating missing posts.
Published content is only ever updated when the publish action is done by the user.
Deletion warns if some media file or post is dependent on the one you are about to delete, because that
will break the relation.
## UI and UX specifics
The UI and UX should be aligned with modern applications like vscode. I want iconbar and left sidebar and the

View File

@@ -5,14 +5,16 @@
"description": "A desktop blogging application with offline-first capabilities and cloud sync",
"main": "dist/main/main.js",
"scripts": {
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev": "concurrently --kill-others \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json --watch",
"dev:renderer": "node ./node_modules/vite/bin/vite.js",
"dev:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
"start": "concurrently --kill-others \"npm run dev:renderer\" \"npm run start:electron\"",
"start:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
"build": "npm run build:main && npm run build:renderer",
"build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json",
"build:renderer": "node ./node_modules/vite/bin/vite.js build",
"start": "node ./node_modules/electron/cli.js .",
"start:prod": "node ./node_modules/electron/cli.js .",
"start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
"test": "vitest run",
"test:watch": "vitest",

View File

@@ -259,6 +259,14 @@ export class DatabaseConnection {
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT");
}
// Migration: Add content column for draft body text stored in DB
const contentCol = await this.localClient.execute(
"SELECT name FROM pragma_table_info('posts') WHERE name = 'content'"
);
if (contentCol.rows.length === 0) {
await this.localClient.execute("ALTER TABLE posts ADD COLUMN content TEXT");
}
// Create FTS5 virtual table for full-text search
await this.localClient.execute(`
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(

View File

@@ -12,28 +12,33 @@ export const projects = sqliteTable('projects', {
});
// Posts table - stores metadata for blog posts
// Draft content is stored in the `content` column.
// Published content lives on the filesystem; `filePath` points to the .md file.
// When a post is published, `content` is cleared (moved to file).
// When a published post is edited, `content` holds draft changes and status becomes 'draft'.
export const posts = sqliteTable('posts', {
projectId: text('project_id').notNull(),
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
content: text('content'), // Draft body text (null/empty when published — content is in the file)
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
author: text('author'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }),
filePath: text('file_path').notNull(),
filePath: text('file_path').notNull().default(''), // Empty for never-published drafts
syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text
// Published snapshot - stores the last published version for discard functionality
// Legacy columns (kept for migration compatibility, no longer written)
publishedTitle: text('published_title'),
publishedContent: text('published_content'),
publishedTags: text('published_tags'), // JSON array stored as text
publishedCategories: text('published_categories'), // JSON array stored as text
publishedTags: text('published_tags'),
publishedCategories: text('published_categories'),
publishedExcerpt: text('published_excerpt'),
});

View File

@@ -245,23 +245,22 @@ export class PostEngine extends EventEmitter {
categories: data.categories || [],
};
// Write to filesystem first
const filePath = await this.writePostFile(post);
const checksum = this.calculateChecksum(post.content);
// Then update database
// Draft content lives in the database only — no file written
const dbPost: NewPost = {
id: post.id,
projectId: post.projectId,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
content: post.content,
status: post.status,
author: post.author,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
filePath,
filePath: '',
syncStatus: 'pending',
checksum,
tags: JSON.stringify(post.tags),
@@ -291,38 +290,41 @@ export class PostEngine extends EventEmitter {
return null;
}
// If post is currently published and content/metadata is changing,
// automatically transition to draft status (content moves from file to DB)
const isContentOrMetadataChange = data.content !== undefined ||
data.title !== undefined ||
data.tags !== undefined ||
data.categories !== undefined ||
data.excerpt !== undefined;
let newStatus = data.status || existing.status;
if (existing.status === 'published' && isContentOrMetadataChange && !data.status) {
newStatus = 'draft';
}
const updated: PostData = {
...existing,
...data,
id, // Ensure ID doesn't change
projectId: existing.projectId, // Ensure projectId doesn't change
status: newStatus as 'draft' | 'published' | 'archived',
updatedAt: new Date(),
};
// Handle slug change - need to rename file
const postsDir = this.getPostsDir();
if (data.slug && data.slug !== existing.slug) {
const oldPath = path.join(postsDir, `${existing.slug}.md`);
try {
await fs.unlink(oldPath);
} catch {
// Old file might not exist
}
}
const filePath = await this.writePostFile(updated);
const checksum = this.calculateChecksum(updated.content);
// All updates go to DB only — no file writes
await db.update(posts)
.set({
title: updated.title,
slug: updated.slug,
excerpt: updated.excerpt,
content: updated.content,
status: updated.status,
author: updated.author,
updatedAt: updated.updatedAt,
publishedAt: updated.publishedAt,
filePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(updated.tags),
@@ -357,13 +359,19 @@ export class PostEngine extends EventEmitter {
return false;
}
// Delete file
try {
await fs.unlink(existing.filePath);
} catch {
// File might not exist
// Only delete file if the post was published (has a file on disk)
if (existing.filePath) {
try {
await fs.unlink(existing.filePath);
} catch {
// File might not exist
}
}
// Delete post links
await db.delete(postLinks).where(eq(postLinks.sourcePostId, id));
await db.delete(postLinks).where(eq(postLinks.targetPostId, id));
// Delete from database
await db.delete(posts).where(eq(posts.id, id));
@@ -376,6 +384,27 @@ export class PostEngine extends EventEmitter {
return true;
}
/**
* Build a PostData object from a DB row, using the given body content.
*/
private dbRowToPostData(dbPost: Post, body: string): PostData {
return {
id: dbPost.id,
projectId: dbPost.projectId,
title: dbPost.title,
slug: dbPost.slug,
excerpt: dbPost.excerpt || undefined,
content: body,
status: dbPost.status as 'draft' | 'published' | 'archived',
author: dbPost.author || undefined,
createdAt: dbPost.createdAt,
updatedAt: dbPost.updatedAt,
publishedAt: dbPost.publishedAt || undefined,
tags: JSON.parse(dbPost.tags || '[]'),
categories: JSON.parse(dbPost.categories || '[]'),
};
}
async getPost(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
@@ -384,29 +413,21 @@ export class PostEngine extends EventEmitter {
return null;
}
// Read content from file
const postData = await this.readPostFile(dbPost.filePath);
if (!postData) {
// File doesn't exist, reconstruct from database
return {
id: dbPost.id,
projectId: dbPost.projectId,
title: dbPost.title,
slug: dbPost.slug,
excerpt: dbPost.excerpt || undefined,
content: '',
status: dbPost.status as 'draft' | 'published' | 'archived',
author: dbPost.author || undefined,
createdAt: dbPost.createdAt,
updatedAt: dbPost.updatedAt,
publishedAt: dbPost.publishedAt || undefined,
tags: JSON.parse(dbPost.tags || '[]'),
categories: JSON.parse(dbPost.categories || '[]'),
};
// Draft content lives in the DB
if (dbPost.content) {
return this.dbRowToPostData(dbPost, dbPost.content);
}
return postData;
// Published content lives in the filesystem
if (dbPost.filePath) {
const fileData = await this.readPostFile(dbPost.filePath);
if (fileData) {
return this.dbRowToPostData(dbPost, fileData.content);
}
}
// Fallback: no content available
return this.dbRowToPostData(dbPost, '');
}
async getAllPosts(): Promise<PostData[]> {
@@ -590,68 +611,205 @@ export class PostEngine extends EventEmitter {
async publishPost(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const client = getDatabase().getLocalClient();
const existing = await this.getPost(id);
if (!existing) {
return null;
}
// First update the post with published status
const result = await this.updatePost(id, {
status: 'published',
publishedAt: new Date(),
});
const now = new Date();
const publishedAt = existing.publishedAt || now;
if (result) {
// Save the published snapshot for discard functionality
await db.update(posts)
.set({
publishedTitle: result.title,
publishedContent: result.content,
publishedExcerpt: result.excerpt,
publishedTags: JSON.stringify(result.tags),
publishedCategories: JSON.stringify(result.categories),
})
.where(eq(posts.id, id));
const published: PostData = {
...existing,
status: 'published',
publishedAt,
updatedAt: now,
};
// Write content + metadata to the filesystem
const newFilePath = await this.writePostFile(published);
// If there was a previous file with a different path (slug changed), remove it
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (dbPost && dbPost.filePath && dbPost.filePath !== newFilePath && dbPost.filePath !== '') {
try {
await fs.unlink(dbPost.filePath);
} catch {
// Old file might not exist
}
}
return result;
const checksum = this.calculateChecksum(published.content);
// Update DB: clear draft content (it lives in the file now), set filePath
await db.update(posts)
.set({
title: published.title,
slug: published.slug,
excerpt: published.excerpt,
content: null,
status: 'published',
author: published.author,
updatedAt: published.updatedAt,
publishedAt: published.publishedAt,
filePath: newFilePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(published.tags),
categories: JSON.stringify(published.categories),
})
.where(eq(posts.id, id));
// Update FTS index
if (client) {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
await client.execute({
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
args: [published.id, published.title, published.content, published.excerpt || '', published.tags.join(' '), published.categories.join(' ')],
});
}
// Update post links based on published content
await this.updatePostLinks(id, published.content);
this.emit('postUpdated', published);
return published;
}
async discardChanges(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const client = getDatabase().getLocalClient();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!dbPost || !dbPost.publishedContent) {
// No published version to revert to
if (!dbPost) {
return null;
}
// Revert to the published snapshot
return this.updatePost(id, {
title: dbPost.publishedTitle || dbPost.title,
content: dbPost.publishedContent,
excerpt: dbPost.publishedExcerpt || undefined,
tags: JSON.parse(dbPost.publishedTags || '[]'),
categories: JSON.parse(dbPost.publishedCategories || '[]'),
});
// Can only discard if there's a published file to revert to
if (!dbPost.filePath) {
return null;
}
// Read the published version from the filesystem
const publishedData = await this.readPostFile(dbPost.filePath);
if (!publishedData) {
return null;
}
const now = new Date();
// Restore DB metadata from the published file, clear draft content
await db.update(posts)
.set({
title: publishedData.title,
slug: publishedData.slug,
excerpt: publishedData.excerpt,
content: null,
status: 'published',
author: publishedData.author,
updatedAt: now,
publishedAt: publishedData.publishedAt,
tags: JSON.stringify(publishedData.tags),
categories: JSON.stringify(publishedData.categories),
})
.where(eq(posts.id, id));
const reverted: PostData = {
id: dbPost.id,
projectId: dbPost.projectId,
title: publishedData.title,
slug: publishedData.slug,
excerpt: publishedData.excerpt,
content: publishedData.content,
status: 'published',
author: publishedData.author,
createdAt: dbPost.createdAt,
updatedAt: now,
publishedAt: publishedData.publishedAt,
tags: publishedData.tags || [],
categories: publishedData.categories || [],
};
// Update FTS index
if (client) {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
await client.execute({
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
args: [reverted.id, reverted.title, reverted.content, reverted.excerpt || '', reverted.tags.join(' '), reverted.categories.join(' ')],
});
}
this.emit('postUpdated', reverted);
return reverted;
}
async hasPublishedVersion(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
return !!(dbPost && dbPost.publishedContent);
return !!(dbPost && dbPost.filePath && dbPost.filePath !== '');
}
async unpublishPost(id: string): Promise<PostData | null> {
return this.updatePost(id, {
const db = getDatabase().getLocal();
const client = getDatabase().getLocalClient();
const existing = await this.getPost(id);
if (!existing) {
return null;
}
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!dbPost) {
return null;
}
// Delete the published file (content moves to DB)
if (dbPost.filePath) {
try {
await fs.unlink(dbPost.filePath);
} catch {
// File might not exist
}
}
const updated: PostData = {
...existing,
status: 'draft',
publishedAt: undefined,
});
updatedAt: new Date(),
};
const checksum = this.calculateChecksum(updated.content);
// Store content in DB, clear filePath
await db.update(posts)
.set({
content: updated.content,
status: 'draft',
filePath: '',
updatedAt: updated.updatedAt,
publishedAt: null,
syncStatus: 'pending',
checksum,
})
.where(eq(posts.id, id));
// Update FTS index
if (client) {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
await client.execute({
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
args: [updated.id, updated.title, updated.content, updated.excerpt || '', updated.tags.join(' '), updated.categories.join(' ')],
});
}
this.emit('postUpdated', updated);
return updated;
}
async rebuildDatabaseFromFiles(): Promise<void> {
const postsDir = this.getPostsDir();
const postsBaseDir = this.getPostsBaseDir();
const task: Task<void> = {
id: uuidv4(),
name: 'Rebuild database from post files',
@@ -661,22 +819,38 @@ export class PostEngine extends EventEmitter {
onProgress(0, 'Scanning posts directory...');
let files: string[] = [];
// Recursively find all .md files in the posts directory tree
const mdFiles: string[] = [];
const scanDir = async (dir: string) => {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await scanDir(fullPath);
} else if (entry.name.endsWith('.md')) {
mdFiles.push(fullPath);
}
}
} catch {
// Directory might not exist
}
};
try {
files = await fs.readdir(postsDir);
await fs.mkdir(postsBaseDir, { recursive: true });
} catch {
// Directory might not exist
await fs.mkdir(postsDir, { recursive: true });
// Already exists
}
const mdFiles = files.filter(f => f.endsWith('.md'));
await scanDir(postsBaseDir);
onProgress(10, `Found ${mdFiles.length} post files`);
for (let i = 0; i < mdFiles.length; i++) {
const file = mdFiles[i];
const filePath = path.join(postsDir, file);
const filePath = mdFiles[i];
const fileName = path.basename(filePath);
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${fileName}...`);
const postData = await this.readPostFile(filePath);
@@ -685,21 +859,25 @@ export class PostEngine extends EventEmitter {
const checksum = this.calculateChecksum(postData.content);
if (existing) {
await db.update(posts)
.set({
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
status: postData.status,
author: postData.author,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
})
.where(eq(posts.id, postData.id));
// Only update if no active draft content in DB (don't overwrite edits)
if (!existing.content) {
await db.update(posts)
.set({
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
content: null, // Content lives in the file, not DB
status: 'published', // Files on disk = published
author: postData.author,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
})
.where(eq(posts.id, postData.id));
}
} else {
await db.insert(posts).values({
id: postData.id,
@@ -707,11 +885,12 @@ export class PostEngine extends EventEmitter {
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
status: postData.status,
content: null, // Content lives in the file, not DB
status: 'published', // Files on disk = published
author: postData.author,
createdAt: postData.createdAt,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
publishedAt: postData.publishedAt || postData.updatedAt,
filePath,
syncStatus: 'pending',
checksum,
@@ -720,7 +899,7 @@ export class PostEngine extends EventEmitter {
});
}
// Update FTS index
// Update FTS index (use file content for search)
if (client) {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] });
await client.execute({
@@ -854,17 +1033,28 @@ export class PostEngine extends EventEmitter {
// Clear all existing links
await db.delete(postLinks);
// Get all posts
// Get all posts with content source info
const allPosts = await db
.select({ id: posts.id, filePath: posts.filePath })
.select({ id: posts.id, filePath: posts.filePath, content: posts.content })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
for (const post of allPosts) {
try {
const fileContent = await fs.readFile(post.filePath, 'utf-8');
const { content } = matter(fileContent);
await this.updatePostLinks(post.id, content);
let postContent: string;
// Draft content is in DB, published content is in file
if (post.content) {
postContent = post.content;
} else if (post.filePath) {
const fileContent = await fs.readFile(post.filePath, 'utf-8');
const { content } = matter(fileContent);
postContent = content;
} else {
continue;
}
await this.updatePostLinks(post.id, postContent);
} catch (error) {
console.error(`Failed to update links for post ${post.id}:`, error);
}

View File

@@ -37,8 +37,18 @@ export function registerIpcHandlers(): void {
});
ipcMain.handle('projects:getActive', async () => {
const engine = getProjectEngine();
return engine.getActiveProject();
const projectEngine = getProjectEngine();
const project = await projectEngine.getActiveProject();
// Ensure post and media engines have the correct project context
if (project) {
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
postEngine.setProjectContext(project.id);
mediaEngine.setProjectContext(project.id);
}
return project;
});
ipcMain.handle('projects:setActive', async (_, id: string) => {

View File

@@ -30,7 +30,10 @@ const App: React.FC = () => {
const loadData = async () => {
setLoading(true);
try {
// Load posts
// First, get active project to set the correct context in backend engines
await window.electronAPI?.projects.getActive();
// Load posts (now with correct project context)
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
setPosts(posts as PostData[]);

View File

@@ -102,7 +102,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
return () => {
const pending = pendingChangesRef.current;
if (pending && pending.postId === prevPostId && pending.isDirty) {
// Only auto-save if the post still exists in the store (not deleted/discarded)
const postStillExists = useAppStore.getState().posts.some(p => p.id === prevPostId);
if (pending && pending.postId === prevPostId && pending.isDirty && postStillExists) {
// Fire and forget auto-save
window.electronAPI?.posts.update(pending.postId, {
title: pending.title,
@@ -245,6 +247,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
} else {
// Never published - delete the post entirely
await window.electronAPI?.posts.delete(post.id);
// Clear pending ref to prevent auto-save on unmount from resurrecting the post
pendingChangesRef.current = null;
useAppStore.getState().removePost(post.id);
useAppStore.getState().setSelectedPost(null);
showToast.success('Draft deleted');
@@ -264,6 +268,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
if (confirm('Are you sure you want to delete this post?')) {
try {
await window.electronAPI?.posts.delete(post.id);
// Clear pending ref to prevent auto-save on unmount from resurrecting the post
pendingChangesRef.current = null;
useAppStore.getState().removePost(post.id);
useAppStore.getState().setSelectedPost(null);
showToast.success('Post deleted');
@@ -341,18 +347,20 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
Unpublish
</button>
)}
{hasPublishedVersion && (
{post.status === 'draft' && (
<button
onClick={handleDiscard}
className="secondary"
title="Revert to last published version"
className="secondary danger"
title={hasPublishedVersion ? "Revert to last published version" : "Delete this draft permanently"}
>
Discard Changes
{hasPublishedVersion ? 'Discard Changes' : 'Discard Draft'}
</button>
)}
{post.status === 'published' && (
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
Delete
</button>
)}
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
Delete
</button>
</div>
</div>

View File

@@ -275,12 +275,33 @@ describe('PostEngine', () => {
expect(post.projectId).toBe('my-project');
});
it('should write post to filesystem', async () => {
it('should NOT write to filesystem (draft content stays in DB)', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.writeFile).mockClear();
await postEngine.createPost({ title: 'File Test' });
expect(fs.writeFile).toHaveBeenCalled();
expect(fs.mkdir).toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should store content in database with empty filePath', async () => {
const insertValues: any[] = [];
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => {
insertValues.push(data);
if (data && data.id) mockPosts.set(data.id, data);
return Promise.resolve();
}),
}));
await postEngine.createPost({
title: 'DB Content Test',
content: '# Hello World',
});
const postInsert = insertValues.find(v => v.title === 'DB Content Test');
expect(postInsert).toBeDefined();
expect(postInsert.content).toBe('# Hello World');
expect(postInsert.filePath).toBe('');
});
it('should insert into database', async () => {
@@ -357,25 +378,35 @@ describe('PostEngine', () => {
});
});
describe('Post Creation writes correct file format', () => {
it('should write markdown file with YAML frontmatter', async () => {
describe('Post creation stores content in database only', () => {
it('should store draft content and metadata in database, not filesystem', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.writeFile).mockClear();
const insertValues: any[] = [];
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => {
insertValues.push(data);
if (data && data.id) mockPosts.set(data.id, data);
return Promise.resolve();
}),
}));
await postEngine.createPost({
title: 'Frontmatter Test',
title: 'DB Store Test',
content: '# Hello World',
tags: ['test'],
});
expect(fs.writeFile).toHaveBeenCalled();
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const filePath = writeCall[0] as string;
const content = writeCall[1] as string;
// No file written for drafts
expect(fs.writeFile).not.toHaveBeenCalled();
expect(filePath).toContain('frontmatter-test.md');
expect(content).toContain('---');
expect(content).toContain('title: Frontmatter Test');
expect(content).toContain('# Hello World');
// Content saved to DB
const postInsert = insertValues.find(v => v.title === 'DB Store Test');
expect(postInsert).toBeDefined();
expect(postInsert.content).toBe('# Hello World');
expect(postInsert.filePath).toBe('');
expect(postInsert.tags).toBe('["test"]');
});
});
@@ -632,7 +663,7 @@ Content for retrieval test`);
);
});
it('should handle slug change by deleting old file', async () => {
it('should NOT touch filesystem on slug change (handled at publish time)', async () => {
const fs = await import('fs/promises');
const created = await postEngine.createPost({ title: 'Slug Change Test' });
@@ -646,7 +677,8 @@ Content for retrieval test`);
title: created.title,
slug: created.slug,
status: created.status,
filePath: `/mock/userData/projects/default/posts/${created.slug}.md`,
content: created.content || '',
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
@@ -657,10 +689,57 @@ Content for retrieval test`);
});
vi.mocked(fs.unlink).mockClear();
vi.mocked(fs.writeFile).mockClear();
await postEngine.updatePost(created.id, { slug: 'new-slug' });
// Should try to delete old file
expect(fs.unlink).toHaveBeenCalled();
// No file operations on update — filesystem is only touched on publish
expect(fs.unlink).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should auto-transition published post to draft when content changes', async () => {
const created = await postEngine.createPost({ title: 'Auto Draft Test' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'published',
content: null,
filePath: '/mock/published-file.md',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
}),
});
return chain;
});
// Mock file read for published content
mockFiles.set('/mock/published-file.md', `---
id: ${created.id}
projectId: default
title: ${created.title}
slug: ${created.slug}
status: published
createdAt: ${created.createdAt.toISOString()}
updatedAt: ${created.updatedAt.toISOString()}
tags: []
categories: []
---
Original content`);
const result = await postEngine.updatePost(created.id, { content: 'New draft content' });
expect(result).not.toBeNull();
expect(result?.status).toBe('draft');
expect(result?.content).toBe('New draft content');
});
it('should update tags and categories', async () => {
@@ -873,9 +952,17 @@ Content for retrieval test`);
});
});
describe('Metadata roundtrip (write -> read integrity)', () => {
it('should preserve all fields through write and read cycle', async () => {
const fs = await import('fs/promises');
describe('Metadata roundtrip (create -> DB storage integrity)', () => {
it('should preserve all fields when storing to database', async () => {
const insertValues: any[] = [];
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => {
insertValues.push(data);
if (data && data.id) mockPosts.set(data.id, data);
return Promise.resolve();
}),
}));
const publishDate = new Date('2024-03-15T10:30:00.000Z');
const original = await postEngine.createPost({
@@ -890,29 +977,27 @@ Content for retrieval test`);
categories: ['testing'],
});
// Get the written file content
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('roundtrip-test.md')
);
expect(writeCall).toBeDefined();
const fileContent = writeCall![1] as string;
// Verify frontmatter contains all fields
expect(fileContent).toContain('title: Roundtrip Test Post');
expect(fileContent).toContain('slug: roundtrip-test');
expect(fileContent).toContain('status: published');
expect(fileContent).toContain('author: Test Author');
expect(fileContent).toContain('excerpt: Testing the roundtrip');
expect(fileContent).toContain('publishedAt:');
expect(fileContent).toContain('- roundtrip');
expect(fileContent).toContain('- integrity');
expect(fileContent).toContain('- test');
expect(fileContent).toContain('- testing');
expect(fileContent).toContain('# Roundtrip');
// Verify data was stored in DB correctly
const postInsert = insertValues.find(v => v.slug === 'roundtrip-test');
expect(postInsert).toBeDefined();
expect(postInsert.title).toBe('Roundtrip Test Post');
expect(postInsert.content).toBe('# Roundtrip\n\nTesting data integrity.');
expect(postInsert.excerpt).toBe('Testing the roundtrip');
expect(postInsert.author).toBe('Test Author');
expect(postInsert.tags).toBe('["roundtrip","integrity","test"]');
expect(postInsert.categories).toBe('["testing"]');
expect(postInsert.filePath).toBe('');
});
it('should handle empty tags and categories', async () => {
const fs = await import('fs/promises');
it('should handle empty tags and categories in DB', async () => {
const insertValues: any[] = [];
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => {
insertValues.push(data);
if (data && data.id) mockPosts.set(data.id, data);
return Promise.resolve();
}),
}));
await postEngine.createPost({
title: 'No Tags Post',
@@ -921,39 +1006,26 @@ Content for retrieval test`);
categories: [],
});
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('no-tags-post.md')
);
const fileContent = writeCall![1] as string;
expect(fileContent).toContain('tags: []');
expect(fileContent).toContain('categories: []');
const postInsert = insertValues.find(v => v.title === 'No Tags Post');
expect(postInsert.tags).toBe('[]');
expect(postInsert.categories).toBe('[]');
});
it('should not include undefined optional fields in frontmatter', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({
it('should handle optional fields as undefined', async () => {
const post = await postEngine.createPost({
title: 'Minimal Post',
content: 'Just content',
});
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('minimal-post.md')
);
const fileContent = writeCall![1] as string;
// These optional fields should NOT appear if not set
expect(fileContent).not.toContain('excerpt:');
expect(fileContent).not.toContain('author:');
expect(fileContent).not.toContain('publishedAt:');
// Optional fields should be undefined
expect(post.excerpt).toBeUndefined();
expect(post.author).toBeUndefined();
expect(post.publishedAt).toBeUndefined();
});
});
describe('Edge cases - special characters and unicode', () => {
it('should handle unicode characters in content', async () => {
const fs = await import('fs/promises');
const post = await postEngine.createPost({
title: 'Unicode Test',
content: '# 你好世界\n\nЗдравствуй мир\n\nこんにちは\n\nEmoji: 🚀💻📝',
@@ -963,14 +1035,6 @@ Content for retrieval test`);
expect(post.content).toContain('Здравствуй');
expect(post.content).toContain('こんにちは');
expect(post.content).toContain('🚀💻📝');
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('unicode-test.md')
);
const fileContent = writeCall![1] as string;
expect(fileContent).toContain('你好世界');
expect(fileContent).toContain('🚀💻📝');
});
it('should handle special characters in title', async () => {
@@ -985,24 +1049,15 @@ Content for retrieval test`);
});
it('should handle YAML special characters in excerpt', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({
const post = await postEngine.createPost({
title: 'YAML Safe Test',
excerpt: 'Contains: colons, "quotes", and #hash',
});
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('yaml-safe-test.md')
);
const fileContent = writeCall![1] as string;
// gray-matter should properly escape these
expect(fileContent).toContain('excerpt:');
expect(post.excerpt).toBe('Contains: colons, "quotes", and #hash');
});
it('should handle multiline content correctly', async () => {
const fs = await import('fs/promises');
const multilineContent = `# Heading 1
Some paragraph text.
@@ -1018,20 +1073,14 @@ const code = 'example';
> A blockquote`;
await postEngine.createPost({
const post = await postEngine.createPost({
title: 'Multiline Test',
content: multilineContent,
});
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('multiline-test.md')
);
const fileContent = writeCall![1] as string;
expect(fileContent).toContain('# Heading 1');
expect(fileContent).toContain('## Heading 2');
expect(fileContent).toContain('```javascript');
expect(fileContent).toContain('> A blockquote');
expect(post.content).toContain('# Heading 1');
expect(post.content).toContain('## Heading 2');
expect(post.content).toContain('> A blockquote');
});
it('should handle empty content', async () => {
@@ -1052,18 +1101,12 @@ const code = 'example';
});
it('should handle newlines in excerpt', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({
const post = await postEngine.createPost({
title: 'Newline Excerpt',
excerpt: 'First line.\nSecond line.',
});
// Should be written without breaking YAML
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('newline-excerpt.md')
);
expect(writeCall).toBeDefined();
expect(post.excerpt).toBe('First line.\nSecond line.');
});
});
@@ -1114,10 +1157,20 @@ const code = 'example';
});
describe('rebuildDatabaseFromFiles', () => {
it('should scan posts directory for markdown files', async () => {
// Helper for Dirent-like objects (readdir with withFileTypes)
const mockDirent = (name: string, isDir = false) => ({
name,
isDirectory: () => isDir,
});
it('should scan posts directory for markdown files recursively', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce(['post1.md', 'post2.md', 'other.txt'] as any);
vi.mocked(fs.readdir).mockResolvedValueOnce([
mockDirent('post1.md'),
mockDirent('post2.md'),
mockDirent('other.txt'),
] as any);
// Mock readFile to return valid post content
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
@@ -1127,7 +1180,7 @@ id: post-1-id
projectId: default
title: Post 1
slug: post1
status: draft
status: published
createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-01T00:00:00.000Z
tags: []
@@ -1163,7 +1216,7 @@ Content 2`;
const handler = vi.fn();
postEngine.on('databaseRebuilt', handler);
vi.mocked(fs.readdir).mockResolvedValueOnce([]);
vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
await postEngine.rebuildDatabaseFromFiles();
@@ -1173,7 +1226,7 @@ Content 2`;
it('should handle empty posts directory', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce([]);
vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
await postEngine.rebuildDatabaseFromFiles();
@@ -1194,7 +1247,7 @@ Content 2`;
it('should update existing posts in database', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce(['existing.md'] as any);
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('existing.md')] as any);
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: existing-id
@@ -1230,7 +1283,7 @@ Updated content`);
it('should insert new posts not in database', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce(['new-post.md'] as any);
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('new-post.md')] as any);
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: new-post-id
@@ -1263,7 +1316,7 @@ New content`);
it('should update FTS index for each processed post', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce(['fts-test.md'] as any);
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('fts-test.md')] as any);
vi.mocked(fs.readFile).mockResolvedValueOnce(`---
id: fts-test-id
@@ -1301,7 +1354,7 @@ Searchable content`);
it('should skip invalid/corrupted post files', async () => {
const fs = await import('fs/promises');
vi.mocked(fs.readdir).mockResolvedValueOnce(['valid.md', 'corrupted.md'] as any);
vi.mocked(fs.readdir).mockResolvedValueOnce([mockDirent('valid.md'), mockDirent('corrupted.md')] as any);
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
if (filePath.includes('valid.md')) {
@@ -1339,83 +1392,6 @@ Valid content`;
});
describe('Date-based folder structure', () => {
it('should store posts in YYYY/MM folder based on createdAt date', async () => {
const fs = await import('fs/promises');
const post = await postEngine.createPost({
title: 'Date Folder Test',
content: 'Testing date-based folder structure',
});
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('date-folder-test.md')
);
expect(writeCall).toBeDefined();
const filePath = writeCall![0] as string;
const year = post.createdAt.getFullYear();
const month = (post.createdAt.getMonth() + 1).toString().padStart(2, '0');
// Path should contain YYYY/MM structure (handle both / and \ separators)
expect(filePath).toMatch(new RegExp(`[/\\\\]${year}[/\\\\]${month}[/\\\\]`));
expect(filePath).toContain('date-folder-test.md');
});
it('should generate correct path for posts in different months', async () => {
const fs = await import('fs/promises');
// Create a post - the current date will be used
await postEngine.createPost({
title: 'Current Month Post',
content: 'This post should go in current month folder',
});
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('current-month-post.md')
);
const filePath = writeCall![0] as string;
// Should have year/month in the path (handle both / and \ separators)
expect(filePath).toMatch(/[/\\]\d{4}[/\\]\d{2}[/\\]/);
});
it('should use zero-padded month numbers (01-12)', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({
title: 'Zero Padded Month Test',
});
const writeCall = vi.mocked(fs.writeFile).mock.calls.find(
(call) => (call[0] as string).includes('zero-padded-month-test.md')
);
const filePath = writeCall![0] as string;
// Month should be zero-padded (01, 02, ..., 09, 10, 11, 12)
expect(filePath).toMatch(/[/\\]\d{4}[/\\](?:0[1-9]|1[0-2])[/\\]/);
});
it('should create nested year/month directories on post creation', async () => {
const fs = await import('fs/promises');
await postEngine.createPost({
title: 'Nested Dirs Test',
});
// mkdir should be called with recursive: true
expect(fs.mkdir).toHaveBeenCalled();
const mkdirCalls = vi.mocked(fs.mkdir).mock.calls;
// Should have created directory containing year/month structure
const yearMonthDirCall = mkdirCalls.find((call) => {
const dirPath = call[0] as string;
return dirPath.match(/[/\\]\d{4}[/\\]\d{2}$/);
});
expect(yearMonthDirCall).toBeDefined();
});
it('should return correct path via getPostPath method', async () => {
const now = new Date();
const year = now.getFullYear();