17 KiB
17 KiB
GitHub Copilot Instructions for Blogging Desktop Server (bDS)
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
Project Overview
Blogging Desktop Server (bDS) is a desktop blogging application built with:
- Electron v28+ for cross-platform desktop
- TypeScript for all code (strict mode)
- React for the renderer UI
- Drizzle ORM for type-safe database access
- @libsql/client for SQLite (local) and Turso (cloud sync)
- Zustand for React state management
Architecture Principles
Separation of Concerns
-
Engine Classes (
src/main/engine/): All business logic lives herePostEngine: Blog post CRUD, file I/O, markdown handlingMediaEngine: Media import, metadata managementSyncEngine: Remote database synchronizationTaskManager: Async task queue with progress tracking
-
IPC Layer (
src/main/ipc/): Bridge between main and renderer- Handlers call engine methods, never contain business logic
- Always use
ipcMain.handlefor async operations
-
UI Components (
src/renderer/components/): Presentation only- Components should be stateless where possible
- Use Zustand store for shared state
- Never call IPC directly from deeply nested components
File Storage Pattern
- Posts: Markdown files with YAML frontmatter in
posts/directory - Media: Binary files with
.metaJSON sidecar files inmedia/directory - Database: Index/cache only, source of truth is filesystem
TypeScript Best Practices
Type Safety
// ✅ DO: Use explicit return types for public methods
async function getPost(id: string): Promise<PostData | null> {
// ...
}
// ✅ DO: Use discriminated unions for status types
type SyncStatus = 'pending' | 'syncing' | 'synced' | 'error';
// ✅ DO: Use Drizzle's inferred types
import { posts, type Post, type NewPost } from './schema';
// ❌ DON'T: Use `any` type
function processData(data: any) { } // Bad
// ❌ DON'T: Ignore null/undefined possibilities
const post = await getPost(id);
console.log(post.title); // Bad - post might be null
Async/Await Patterns
// ✅ DO: Use try-catch with specific error handling
try {
await fileOperation();
} catch (error) {
if (error instanceof Error && error.message.includes('ENOENT')) {
// Handle file not found
}
throw error;
}
// ✅ DO: Use Promise.all for concurrent operations
const [posts, media] = await Promise.all([
postEngine.getAllPosts(),
mediaEngine.getAllMedia()
]);
// ❌ DON'T: Forget to await async operations
savePost(post); // Bad - fire and forget
Error Handling
// ✅ DO: Create typed error classes
class SyncError extends Error {
constructor(
message: string,
public readonly code: 'AUTH_FAILED' | 'NETWORK_ERROR' | 'CONFLICT'
) {
super(message);
this.name = 'SyncError';
}
}
// ✅ DO: Return result objects for operations that can fail
interface OperationResult<T> {
success: boolean;
data?: T;
error?: string;
}
Electron Best Practices
IPC Communication
// ✅ DO: Use contextBridge for secure IPC exposure
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
posts: {
getAll: () => ipcRenderer.invoke('posts:getAll'),
create: (data: CreatePostInput) => ipcRenderer.invoke('posts:create', data),
}
});
// ✅ DO: Validate all IPC inputs in main process
ipcMain.handle('posts:create', async (_event, data: unknown) => {
const validated = validateCreatePostInput(data);
return postEngine.createPost(validated);
});
// ❌ DON'T: Use nodeIntegration: true
// ❌ DON'T: Expose Node.js APIs directly to renderer
Window Management
// ✅ DO: Check window existence before sending messages
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('event', data);
}
// ✅ DO: Clean up IPC handlers on window close
mainWindow.on('closed', () => {
ipcMain.removeHandler('posts:getAll');
});
Process Separation
// Main process: File system, database, native APIs
// Renderer process: UI only, no direct fs/db access
// ✅ DO: All file operations in main process
// src/main/engine/PostEngine.ts
await fs.writeFile(filePath, content);
// ❌ DON'T: Import 'fs' in renderer
// src/renderer/components/Editor.tsx
import fs from 'fs'; // Bad!
SQLite & Drizzle ORM Best Practices
Schema Definition
// ✅ DO: Use proper column types and constraints
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
syncStatus: text('sync_status').$type<'pending' | 'synced'>().default('pending'),
});
// ✅ DO: Create indexes for frequently queried columns
// CREATE INDEX idx_posts_slug ON posts(slug);
// CREATE INDEX idx_posts_sync_status ON posts(sync_status);
Query Patterns
import { eq, and, desc, sql } from 'drizzle-orm';
// ✅ DO: Use Drizzle query builder
const drafts = await db
.select()
.from(posts)
.where(eq(posts.status, 'draft'))
.orderBy(desc(posts.updatedAt));
// ✅ DO: Use transactions for multi-table updates
await db.transaction(async (tx) => {
await tx.update(posts).set({ status: 'published' }).where(eq(posts.id, id));
await tx.insert(syncLog).values({ entityId: id, operation: 'update' });
});
// ❌ DON'T: Write raw SQL unless absolutely necessary
// ❌ DON'T: Use string interpolation in queries
const result = db.run(`SELECT * FROM posts WHERE id = '${id}'`); // SQL injection risk!
Connection Management
// ✅ DO: Use singleton pattern for database connection
let dbInstance: DatabaseConnection | null = null;
export function getDatabase(): DatabaseConnection {
if (!dbInstance) {
dbInstance = new DatabaseConnection();
}
return dbInstance;
}
// ✅ DO: Close connection gracefully on app quit
app.on('before-quit', async () => {
await getDatabase().close();
});
Remote Sync Best Practices (Turso/LibSQL)
Sync Strategy
// ✅ DO: Track sync status per entity
interface SyncableEntity {
syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict';
syncedAt: number | null;
checksum: string;
}
// ✅ DO: Use checksums to detect conflicts
const localChecksum = calculateChecksum(localContent);
const remoteChecksum = await fetchRemoteChecksum(id);
if (localChecksum !== remoteChecksum) {
// Handle conflict
}
// ✅ DO: Implement retry logic with exponential backoff
async function syncWithRetry(maxAttempts = 3): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await sync();
return;
} catch (error) {
if (attempt === maxAttempts) throw error;
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
Offline-First Approach
// ✅ DO: Always write to local first
async function savePost(post: PostData): Promise<void> {
// 1. Save to local file system
await writePostFile(post);
// 2. Update local database
await updateLocalDatabase(post);
// 3. Mark for sync (don't wait for network)
await markForSync(post.id);
}
// ✅ DO: Queue sync operations
const syncQueue: SyncOperation[] = [];
function queueSync(operation: SyncOperation): void {
syncQueue.push(operation);
processSyncQueue(); // Non-blocking
}
// ✅ DO: Handle network state changes
window.addEventListener('online', () => {
syncEngine.processPendingChanges();
});
Conflict Resolution
// ✅ DO: Implement clear conflict resolution strategy
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
interface ConflictInfo {
entityId: string;
localVersion: EntityVersion;
remoteVersion: EntityVersion;
resolution?: ConflictResolution;
}
// ✅ DO: Allow user to resolve conflicts manually when needed
async function resolveConflict(
conflict: ConflictInfo,
resolution: ConflictResolution
): Promise<void> {
switch (resolution) {
case 'local-wins':
await pushLocalToRemote(conflict.entityId);
break;
case 'remote-wins':
await pullRemoteToLocal(conflict.entityId);
break;
case 'manual':
await showConflictResolutionUI(conflict);
break;
}
}
Sync Logging
// ✅ DO: Log all sync operations for debugging
await db.insert(syncLog).values({
id: generateId(),
entityType: 'post',
entityId: postId,
operation: 'push',
status: 'success',
timestamp: Date.now(),
});
// ✅ DO: Implement sync status visibility in UI
const pendingCount = await syncEngine.getPendingChangesCount();
statusBar.setSyncStatus({ pending: pendingCount });
React & State Management
Zustand Patterns
// ✅ DO: Keep store slices focused
interface AppState {
// UI state
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
// Data
posts: PostData[];
media: MediaData[];
// Actions
setActiveView: (view: AppState['activeView']) => void;
setPosts: (posts: PostData[]) => void;
}
// ✅ DO: Use selectors for derived state
const draftCount = useAppStore((state) =>
state.posts.filter(p => p.status === 'draft').length
);
// ❌ DON'T: Store derived data
interface BadState {
posts: PostData[];
draftCount: number; // Bad - derived from posts
}
Component Patterns
// ✅ DO: Use composition over prop drilling
<EditorProvider postId={selectedPostId}>
<EditorToolbar />
<EditorContent />
<EditorStatusBar />
</EditorProvider>
// ✅ DO: Separate container and presentation components
// Container: handles data fetching/IPC
// Presentation: pure rendering based on props
// ❌ DON'T: Put IPC calls in deeply nested components
const DeepComponent = () => {
// Bad - hard to test and maintain
const handleClick = async () => {
await window.electronAPI.posts.save(data);
};
};
File Naming Conventions
src/
main/
database/
schema.ts # Drizzle schema definitions
connection.ts # Database connection management
engine/
PostEngine.ts # PascalCase for classes
TaskManager.ts
ipc/
handlers.ts # camelCase for modules
renderer/
components/
Editor/
Editor.tsx # PascalCase for components
Editor.css # Matching CSS file
index.ts # Barrel exports
store/
appStore.ts # camelCase for stores
types/
electron.d.ts # Type declarations
Test-Driven Development (TDD) Requirements
This project follows strict TDD practices. All new features MUST have tests written BEFORE implementation.
TDD Workflow (Red-Green-Refactor)
// 1. RED: Write a failing test first
describe('PostEngine.createPost', () => {
it('should create a post with generated slug from title', async () => {
const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world');
});
});
// 2. GREEN: Write minimal code to pass the test
// 3. REFACTOR: Improve the code while keeping tests green
Test File Structure
tests/
├── setup.ts # Global test configuration
├── utils/
│ ├── factories.ts # Mock data factories
│ └── index.ts # Utility exports
└── engine/
├── TaskManager.test.ts # TaskManager unit tests
├── PostEngine.test.ts # PostEngine unit tests
├── MediaEngine.test.ts # MediaEngine unit tests
└── SyncEngine.test.ts # SyncEngine unit tests
Test Naming Conventions
// ✅ DO: Use descriptive test names that explain behavior
it('should generate slug from title with lowercase and hyphens', () => {});
it('should emit taskCompleted event when task finishes', () => {});
it('should return null when post not found', () => {});
// ❌ DON'T: Use vague test names
it('works', () => {});
it('test createPost', () => {});
Mock Factory Patterns
// ✅ DO: Use factory functions with overrides
import { createMockPost, createMockMedia } from '../utils/factories';
const post = createMockPost({
title: 'Custom Title',
status: 'published'
});
// ✅ DO: Create specialized factories for common scenarios
const draftPost = createMockPost({ status: 'draft' });
const publishedPost = createMockPublishedPost();
const imageMedia = createMockMedia({ mimeType: 'image/jpeg' });
const pdfMedia = createMockPdfMedia();
Testing Async Code
// ✅ DO: Use async/await in tests
it('should save post to filesystem', async () => {
const post = createMockPost();
await postEngine.createPost(post);
expect(fs.writeFile).toHaveBeenCalled();
});
// ✅ DO: Test event emissions
it('should emit postCreated event', async () => {
const handler = vi.fn();
postEngine.on('postCreated', handler);
await postEngine.createPost({ title: 'Test' });
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Test' })
);
});
// ✅ DO: Test error cases
it('should throw when file write fails', async () => {
vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC'));
await expect(postEngine.createPost({ title: 'Test' }))
.rejects.toThrow('ENOSPC');
});
Mocking Dependencies
// ✅ DO: Mock at module level for consistent behavior
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => mockDatabase),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
// ✅ DO: Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
Test Coverage Requirements
- Minimum coverage: 80% for engine classes
- Critical paths: 100% coverage for data mutations
- Edge cases: Null handling, empty arrays, error conditions
# Run tests with coverage
npm run test:coverage
# View coverage report
open coverage/index.html
When to Write Tests
| Scenario | Test Requirement |
|---|---|
| New engine method | Unit tests BEFORE implementation |
| Bug fix | Failing test that reproduces bug FIRST |
| Refactoring | Ensure existing tests pass BEFORE and AFTER |
| IPC handler | Integration test with mocked engine |
| UI component | Behavior tests for user interactions |
Test Commands
npm run test # Run all tests once
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage report
npm run test:ui # Open Vitest UI
Vitest Configuration
Tests are configured in vitest.config.ts with:
- Global test utilities (describe, it, expect, vi)
- Node environment for main process tests
- Coverage reports via v8
- Custom setup file for Electron mocks
Testing Considerations
// ✅ DO: Design for testability
// - Engine classes should accept dependencies via constructor
// - Use interfaces for external dependencies
// - Keep pure business logic separate from I/O
class PostEngine {
constructor(
private db: DatabaseConnection,
private fs: FileSystemAdapter, // Mockable
private eventEmitter: EventEmitter
) {}
}
// ✅ DO: Use factory functions for test data
function createTestPost(overrides?: Partial<PostData>): PostData {
return {
id: 'test-id',
title: 'Test Post',
status: 'draft',
...overrides,
};
}
// ✅ DO: Test pure functions in isolation
describe('generateSlug', () => {
it('should convert to lowercase', () => {
expect(generateSlug('Hello World')).toBe('hello-world');
});
it('should replace special characters', () => {
expect(generateSlug('Hello, World!')).toBe('hello-world');
});
});
Common Patterns in This Codebase
Creating a New Engine Method
// 1. Add method to engine class
async newOperation(input: InputType): Promise<OutputType> {
// Validate input
// Perform operation
// Emit event for UI updates
this.emit('operationCompleted', result);
return result;
}
// 2. Add IPC handler
ipcMain.handle('entity:operation', async (_event, input) => {
return engine.newOperation(input);
});
// 3. Expose in preload
entity: {
operation: (input) => ipcRenderer.invoke('entity:operation', input),
}
// 4. Update store and UI
Adding a New Database Table
// 1. Define in schema.ts with proper types
export const newTable = sqliteTable('new_table', {
id: text('id').primaryKey(),
// ... columns
});
// 2. Export types
export type NewTableRow = typeof newTable.$inferSelect;
export type NewTableInsert = typeof newTable.$inferInsert;
// 3. Add CREATE TABLE to migrations in connection.ts
// 4. Create corresponding engine class
Security Reminders
- Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing
- Use
contextIsolation: trueandsandbox: falseonly when necessary - Store Turso auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention)