/ code / docs / ADDING_VERTICAL_SLICES.md
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