ADDING_VERTICAL_SLICES.md
1 # Adding a Vertical Slice to DearDiary 2 3 This guide documents the pattern for adding a new vertical slice (feature) to the DearDiary app. A "vertical slice" means a feature that spans from the database layer all the way up to the UI. 4 5 We'll use the `LinkPreview` feature as a running example. 6 7 ## Overview 8 9 A vertical slice typically touches these layers: 10 11 1. **Database Schema** (`packages/persistence-drizzle/src/schema.ts`) 12 2. **Types & Interfaces** (`packages/persistence/src/`) 13 3. **Service Implementation** (`packages/persistence-drizzle/src/services/`) 14 4. **Tauri Backend** (`apps/DearDiary/src-tauri/src/`) 15 5. **Persistence Tauri Glue** (`packages/persistence-tauri/src/`) 16 6. **Frontend Store** (`apps/DearDiary/src/lib/stores/`) 17 7. **UI Component** (`apps/DearDiary/src/lib/components/`) 18 19 --- 20 21 ## Step-by-Step Guide 22 23 ### Step 1: Add Database Schema 24 25 **File:** `packages/persistence-drizzle/src/schema.ts` 26 27 Define your table using Drizzle's sqliteTable: 28 29 ```typescript 30 export const linkPreviews = sqliteTable('link_previews', { 31 url: text('url').primaryKey(), 32 title: text('title'), 33 description: text('description'), 34 imageUrl: text('image_url'), 35 siteName: text('site_name'), 36 fetchedAt: integer('fetched_at', { mode: 'timestamp_ms' }).notNull(), 37 error: text('error') 38 }); 39 ``` 40 41 **Generate migration:** 42 ```bash 43 cd packages/persistence-drizzle 44 pnpm db:generate 45 ``` 46 47 --- 48 49 ### Step 2: Define Types & Service Interface 50 51 **File:** `packages/persistence/src/types.ts` 52 53 Add your entity type: 54 55 ```typescript 56 export interface CachedLinkPreview { 57 url: string; 58 title?: string; 59 description?: string; 60 imageUrl?: string; 61 siteName?: string; 62 fetchedAt: Date; 63 error?: string; 64 } 65 ``` 66 67 **File:** `packages/persistence/src/services.ts` 68 69 Add the service interface: 70 71 ```typescript 72 export interface ILinkPreviewService { 73 getForURL(url: string): Promise<CachedLinkPreview>; 74 getCached(url: string): Promise<CachedLinkPreview | null>; 75 store(preview: CachedLinkPreview): Promise<void>; 76 deleteOlderThan(maxAgeMs: number): Promise<void>; 77 } 78 ``` 79 80 Update `IPersistenceServices` to include the new service: 81 82 ```typescript 83 export interface IPersistenceServices { 84 post: IPostService; 85 view: IViewService; 86 session: ISessionService; 87 person: IPersonService; 88 linkPreview: ILinkPreviewService; // Add this 89 } 90 ``` 91 92 --- 93 94 ### Step 3: Implement Service in persistence-drizzle 95 96 **File:** `packages/persistence-drizzle/src/services/linkPreview.service.ts` 97 98 ```typescript 99 import { eq, lt } from 'drizzle-orm'; 100 import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'; 101 import type { CachedLinkPreview, ILinkPreviewService } from '@repo/persistence'; 102 import { linkPreviews } from '../schema.js'; 103 104 // For external fetching (injected from Tauri layer) 105 export type FetchLinkPreviewFn = (url: string) => Promise<{ 106 title?: string; 107 description?: string; 108 imageUrl?: string; 109 siteName?: string; 110 }>; 111 112 export class LinkPreviewService implements ILinkPreviewService { 113 private inFlight = new Map<string, Promise<CachedLinkPreview>>(); 114 115 constructor( 116 private db: BaseSQLiteDatabase<any, any>, 117 private fetcher?: FetchLinkPreviewFn 118 ) {} 119 120 async getForURL(url: string): Promise<CachedLinkPreview> { 121 // Deduplication: return existing promise if already fetching 122 const existing = this.inFlight.get(url); 123 if (existing) return existing; 124 125 const promise = this.fetchPreview(url); 126 this.inFlight.set(url, promise); 127 128 try { 129 return await promise; 130 } finally { 131 this.inFlight.delete(url); 132 } 133 } 134 135 // ... other methods 136 } 137 ``` 138 139 **Export from index:** 140 141 **File:** `packages/persistence-drizzle/src/services/index.ts` 142 ```typescript 143 export { LinkPreviewService } from './linkPreview.service.js'; 144 ``` 145 146 **Update createServices:** 147 148 **File:** `packages/persistence-drizzle/src/index.ts` 149 ```typescript 150 export function createServices( 151 db: BaseSQLiteDatabase<any, any>, 152 options?: { 153 linkPreviewFetcher?: FetchLinkPreviewFn; 154 } 155 ): IPersistenceServices { 156 return { 157 post: new PostService(db), 158 view: new ViewService(db), 159 session: new SessionService(db), 160 person: new PersonService(db), 161 linkPreview: new LinkPreviewService(db, options?.linkPreviewFetcher) 162 }; 163 } 164 ``` 165 166 --- 167 168 ### Step 4: Add Tauri Backend Command 169 170 **File:** `apps/DearDiary/src-tauri/Cargo.toml` 171 172 Add required crates: 173 ```toml 174 [dependencies] 175 webpage = { version = "2.0", features = ["serde"] } 176 ``` 177 178 **File:** `apps/DearDiary/src-tauri/src/link_preview.rs` 179 180 ```rust 181 use serde::Serialize; 182 use webpage::{Webpage, WebpageOptions}; 183 184 #[derive(Serialize)] 185 pub struct LinkPreviewJSON { 186 pub title: Option<String>, 187 pub description: Option<String>, 188 pub image_url: Option<String>, 189 pub site_name: Option<String>, 190 } 191 192 #[tauri::command] 193 pub async fn link_preview_json(url: String) -> Result<LinkPreviewJSON, String> { 194 let options = WebpageOptions::default(); 195 let webpage = Webpage::from_url(&url, options) 196 .map_err(|e| format!("Failed to fetch: {}", e))?; 197 198 let html = webpage.html; 199 let meta = html.meta; 200 201 Ok(LinkPreviewJSON { 202 title: meta.get("og:title").map(|s| s.to_string()), 203 description: meta.get("og:description").map(|s| s.to_string()), 204 image_url: meta.get("og:image").map(|s| s.to_string()), 205 site_name: meta.get("og:site_name").map(|s| s.to_string()), 206 }) 207 } 208 ``` 209 210 **Register command:** 211 212 **File:** `apps/DearDiary/src-tauri/src/lib.rs` 213 ```rust 214 mod link_preview; 215 216 .invoke_handler(tauri::generate_handler![ 217 drizzle_proxy::run_sql, 218 link_preview::link_preview_json // Add this 219 ]) 220 ``` 221 222 --- 223 224 ### Step 5: Wire Up in persistence-tauri 225 226 **File:** `packages/persistence-tauri/src/index.ts` 227 228 ```typescript 229 import { invoke } from '@tauri-apps/api/core'; 230 import { createServices as createDrizzleServices, type FetchLinkPreviewFn } from '@repo/persistence-drizzle'; 231 232 const linkPreviewFetcher: FetchLinkPreviewFn = async (url: string) => { 233 const result = await invoke<{ 234 title?: string; 235 description?: string; 236 image_url?: string; 237 site_name?: string; 238 }>('link_preview_json', { url }); 239 240 return { 241 title: result.title, 242 description: result.description, 243 imageUrl: result.image_url, 244 siteName: result.site_name 245 }; 246 }; 247 248 export function createServices(dbName = "database.db"): IPersistenceServices { 249 return createDrizzleServices(createDrizzleProxy(dbName), { 250 linkPreviewFetcher 251 }); 252 } 253 ``` 254 255 --- 256 257 ### Step 6: Create Frontend Store 258 259 **File:** `apps/DearDiary/src/lib/stores/linkPreview.svelte.ts` 260 261 ```typescript 262 import type { CachedLinkPreview, ILinkPreviewService } from '@repo/persistence'; 263 264 let linkPreviewService: ILinkPreviewService | null = null; 265 let previews = $state<Map<string, CachedLinkPreview>>(new Map()); 266 let loading = $state<Set<string>>(new Set()); 267 268 export function setLinkPreviewService(service: ILinkPreviewService): void { 269 linkPreviewService = service; 270 } 271 272 export async function getPreview(url: string): Promise<CachedLinkPreview | null> { 273 if (!linkPreviewService) return null; 274 275 loading = new Set([...loading, url]); 276 277 try { 278 const preview = await linkPreviewService.getForURL(url); 279 previews = new Map([...previews, [url, preview]]); 280 return preview; 281 } finally { 282 const newLoading = new Set(loading); 283 newLoading.delete(url); 284 loading = newLoading; 285 } 286 } 287 288 export function isLoading(url: string): boolean { 289 return loading.has(url); 290 } 291 ``` 292 293 **Wire up in app initialization:** 294 295 **File:** `apps/DearDiary/src/lib/stores/app.svelte.ts` 296 ```typescript 297 import { setLinkPreviewService } from './linkPreview.svelte'; 298 299 // In initializeApp: 300 setLinkPreviewService(services.linkPreview); 301 ``` 302 303 --- 304 305 ### Step 7: Use in UI Components 306 307 **File:** `apps/DearDiary/src/lib/components/inputs/InputArea.svelte` 308 309 ```svelte 310 <script> 311 import { getPreview, isLoading } from '$lib/stores/linkPreview.svelte'; 312 313 let currentPreview = $state<LinkPreview | null>(null); 314 315 // Fetch preview when URL changes 316 $effect(() => { 317 if (linkValue && isValidUrl(linkValue)) { 318 getPreview(linkValue).then(preview => { 319 if (preview) { 320 currentPreview = { 321 title: preview.title, 322 description: preview.description, 323 imageUri: preview.imageUrl, 324 siteName: preview.siteName 325 }; 326 } 327 }); 328 } 329 }); 330 </script> 331 332 {#if currentPreview} 333 <div class="link-preview"> 334 <strong>{currentPreview.title}</strong> 335 <span>{currentPreview.description}</span> 336 </div> 337 {/if} 338 ``` 339 340 --- 341 342 ## Key Patterns 343 344 ### 1. Deduplication of Concurrent Requests 345 346 The service should return the same promise for multiple simultaneous requests: 347 348 ```typescript 349 private inFlight = new Map<string, Promise<Result>>(); 350 351 async getForURL(url: string): Promise<Result> { 352 const existing = this.inFlight.get(url); 353 if (existing) return existing; 354 355 const promise = this.fetch(url); 356 this.inFlight.set(url, promise); 357 358 try { 359 return await promise; 360 } finally { 361 this.inFlight.delete(url); 362 } 363 } 364 ``` 365 366 ### 2. Separation of Concerns 367 368 - **persistence**: Pure types and interfaces 369 - **persistence-drizzle**: Database operations using Drizzle ORM 370 - **persistence-tauri**: Tauri-specific glue (commands, invoke) 371 - **DearDiary stores**: Reactive frontend state 372 373 ### 3. Dependency Injection 374 375 External dependencies (like Tauri commands) are injected via constructor or options: 376 377 ```typescript 378 // persistence-drizzle accepts a fetcher function 379 new LinkPreviewService(db, fetcher); 380 381 // persistence-tauri provides the Tauri-specific fetcher 382 const fetcher = (url) => invoke('link_preview_json', { url }); 383 ``` 384 385 ### 4. Error Handling 386 387 Store errors in the cache to avoid retrying failed requests too often: 388 389 ```typescript 390 const errorPreview = { 391 url, 392 fetchedAt: new Date(), 393 error: error.message 394 }; 395 await this.store(errorPreview); 396 ``` 397 398 --- 399 400 ## Testing Checklist 401 402 - [ ] TypeScript builds without errors (`pnpm check`) 403 - [ ] Tauri builds without errors (`cargo build`) 404 - [ ] Database migrations run successfully 405 - [ ] Service methods work as expected 406 - [ ] UI displays data correctly 407 - [ ] Concurrent requests are deduplicated 408 - [ ] Errors are handled gracefully 409 410 --- 411 412 ## Summary 413 414 Adding a vertical slice requires: 415 416 1. **Schema** - Define the database table 417 2. **Types** - Define entities and service interfaces 418 3. **Service** - Implement business logic with Drizzle 419 4. **Tauri** - Add backend commands 420 5. **Glue** - Wire up Tauri commands to the service 421 6. **Store** - Create reactive frontend state 422 7. **UI** - Display and interact with the data 423 424 The key insight is the **clean separation** between layers: 425 - Core types know nothing about the database 426 - Drizzle services know nothing about Tauri 427 - Tauri glue knows nothing about the UI 428 - UI only talks to stores