Services & Data Loading

Service layer architecture and data fetching patterns in DesQTA

Service Layer Overview

The service layer in DesQTA encapsulates business logic, API communication, and data transformation. Services provide a clean interface between components and the backend/APIs.

Service Architecture

Component
    │
    ▼
Service Layer (Business Logic)
    │
    ├──► Tauri Commands (Backend)
    ├──► SEQTA API
    ├──► BetterSEQTA Cloud API
    └──► Local Cache

Service Patterns

Pattern 1: Simple Service Object

// src/lib/services/authService.ts
import { invoke } from '@tauri-apps/api/core';
import { logger } from '../../utils/logger';

export interface UserInfo {
  id: number;
  email: string;
  userName: string;
  displayName?: string;
}

export const authService = {
  async checkSession(): Promise<boolean> {
    try {
      return await invoke<boolean>('check_session_exists');
    } catch (error) {
      logger.error('authService', 'checkSession', `Failed: ${error}`, { error });
      throw error;
    }
  },
  
  async loadUserInfo(): Promise<UserInfo | undefined> {
    try {
      const cached = cache.get<UserInfo>('userInfo');
      if (cached) return cached;
      
      const user = await invoke<UserInfo>('get_user_info');
      cache.set('userInfo', user, 60);
      return user;
    } catch (error) {
      logger.error('authService', 'loadUserInfo', `Failed: ${error}`, { error });
      return undefined;
    }
  },
  
  async logout(): Promise<boolean> {
    cache.delete('userInfo');
    return await invoke<boolean>('logout');
  }
};

Pattern 2: Service Class

// src/lib/services/notesService.ts
export class NotesService {
  private cache: Map<string, Note> = new Map();
  
  async loadNote(id: string): Promise<Note | null> {
    // Check cache
    if (this.cache.has(id)) {
      return this.cache.get(id)!;
    }
    
    // Load from backend
    const note = await invoke<Note>('load_note', { id });
    if (note) {
      this.cache.set(id, note);
    }
    
    return note;
  }
  
  async saveNote(note: Note): Promise<void> {
    await invoke('save_note', { note });
    this.cache.set(note.id, note);
  }
  
  clearCache() {
    this.cache.clear();
  }
}

export const notesService = new NotesService();

useDataLoader Hook

DesQTA provides a powerful useDataLoader utility for consistent data loading with caching.

Basic Usage

import { useDataLoader } from '$lib/utils/useDataLoader';

const data = await useDataLoader({
  cacheKey: 'assessments',
  ttlMinutes: 10,
  context: 'assessments',
  functionName: 'loadAssessments',
  fetcher: async () => {
    return await invoke('get_assessments');
  },
  onDataLoaded: (data) => {
    assessments = data;
  }
});

Advanced Options

const data = await useDataLoader({
  cacheKey: 'user_profile',
  ttlMinutes: 30,
  context: 'user',
  functionName: 'loadProfile',
  skipCache: false, // Set to true to bypass cache
  fetcher: async () => {
    return await invoke('get_user_profile');
  },
  onDataLoaded: (data) => {
    userProfile = data;
  },
  shouldSyncInBackground: (data) => {
    // Only sync if data exists
    return data !== null;
  }
});

How useDataLoader Works

  1. Check Memory Cache: First checks in-memory cache
  2. Check IndexedDB: Falls back to IndexedDB if memory cache expired
  3. Fetch Fresh: If no cache, fetches fresh data
  4. Background Sync: Updates cache in background if online
  5. Error Handling: Returns null on error, logs appropriately

Service Examples

AuthService

// src/lib/services/authService.ts
export const authService = {
  async checkSession(): Promise<boolean> {
    return await invoke<boolean>('check_session_exists');
  },
  
  async startLogin(seqtaUrl: string): Promise<void> {
    await invoke('create_login_window', { url: seqtaUrl });
  },
  
  async loadUserInfo(): Promise<UserInfo | undefined> {
    const cached = cache.get<UserInfo>('userInfo');
    if (cached) return cached;
    
    const user = await invoke<UserInfo>('get_user_info');
    if (user) {
      cache.set('userInfo', user, 60);
    }
    return user;
  },
  
  async logout(): Promise<boolean> {
    cache.delete('userInfo');
    return await invoke<boolean>('logout');
  }
};

CloudAuthService

// src/lib/services/cloudAuthService.ts
export class CloudAuthService {
  private token: string | null = null;
  private user: CloudUser | null = null;
  
  async init(): Promise<CloudUser | null> {
    const tokenData = await invoke<CloudToken>('get_cloud_token');
    if (tokenData?.token) {
      this.token = tokenData.token;
      this.user = tokenData.user || null;
      return this.user;
    }
    return null;
  }
  
  async login(email: string, password: string): Promise<CloudUser> {
    const response = await fetch(`${CLOUD_API_URL}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    });
    
    const data = await response.json();
    this.token = data.token;
    this.user = data.user;
    
    await invoke('save_cloud_token', { token: this.token, user: this.user });
    return this.user;
  }
  
  async logout(): Promise<void> {
    this.token = null;
    this.user = null;
    await invoke('clear_cloud_token');
  }
}

export const cloudAuthService = new CloudAuthService();

ThemeService

// src/lib/services/themeService.ts
export class ThemeService {
  private themes: Map<string, ThemeManifest> = new Map();
  
  async loadTheme(themeName: string): Promise<void> {
    if (this.themes.has(themeName)) {
      return; // Already loaded
    }
    
    const manifest = await this.getThemeManifest(themeName);
    if (manifest) {
      this.themes.set(themeName, manifest);
      await this.injectThemeCSS(manifest);
    }
  }
  
  async getThemeManifest(themeName: string): Promise<ThemeManifest | null> {
    try {
      const manifestPath = `/themes/${themeName}/manifest.json`;
      const response = await fetch(manifestPath);
      return await response.json();
    } catch (error) {
      logger.error('themeService', 'getThemeManifest', `Failed: ${error}`, { themeName, error });
      return null;
    }
  }
  
  private async injectThemeCSS(manifest: ThemeManifest): Promise<void> {
    const cssPath = `/themes/${manifest.name}/theme.css`;
    const css = await fetch(cssPath).then(r => r.text());
    
    // Inject CSS into document
    const style = document.createElement('style');
    style.id = `theme-${manifest.name}`;
    style.textContent = css;
    document.head.appendChild(style);
  }
}

export const themeService = new ThemeService();

Data Fetching Patterns

Pattern 1: Simple Fetch

async function fetchData() {
  const response = await fetch('/api/data');
  return await response.json();
}

Pattern 2: With Error Handling

async function fetchData(): Promise<Data | null> {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    logger.error('service', 'fetchData', `Failed: ${error}`, { error });
    return null;
  }
}

Pattern 3: With Retry Logic

async function fetchWithRetry(url: string, retries = 3): Promise<Data | null> {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url);
      if (response.ok) {
        return await response.json();
      }
    } catch (error) {
      if (i === retries - 1) {
        logger.error('service', 'fetchWithRetry', `Failed after ${retries} retries`, { error });
        return null;
      }
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
  return null;
}

Pattern 4: Using Tauri Commands

import { invoke } from '@tauri-apps/api/core';

async function fetchFromBackend(): Promise<Data> {
  return await invoke<Data>('get_data');
}

async function saveToBackend(data: Data): Promise<void> {
  await invoke('save_data', { data });
}

Caching in Services

Memory Cache

import { cache } from '../../utils/cache';

async function getCachedData(key: string): Promise<Data | null> {
  // Check cache first
  const cached = cache.get<Data>(key);
  if (cached) {
    return cached;
  }
  
  // Fetch fresh data
  const data = await fetchData();
  
  // Cache for 10 minutes
  if (data) {
    cache.set(key, data, 10);
  }
  
  return data;
}

IndexedDB Cache

import { setIdb, getWithIdbFallback } from '$lib/services/idbCache';

async function getPersistedData(key: string): Promise<Data | null> {
  // Check IndexedDB
  const cached = await getWithIdbFallback<Data>(key, key, () => null);
  if (cached) {
    return cached;
  }
  
  // Fetch fresh
  const data = await fetchData();
  
  // Persist
  if (data) {
    await setIdb(key, data);
  }
  
  return data;
}

Error Handling in Services

Standard Error Handling

export const dataService = {
  async loadData(): Promise<Data | null> {
    try {
      return await invoke<Data>('get_data');
    } catch (error) {
      logger.error('dataService', 'loadData', `Failed: ${error}`, { error });
      errorService.handleError(error);
      return null;
    }
  }
};

Error Service Integration

import { errorService } from './errorService';

export const apiService = {
  async callAPI(endpoint: string): Promise<Response | null> {
    try {
      const response = await fetch(endpoint);
      if (!response.ok) {
        throw new Error(`API error: ${response.status}`);
      }
      return response;
    } catch (error) {
      errorService.handleError(error, {
        context: 'apiService',
        endpoint,
        retryable: true
      });
      return null;
    }
  }
};

Service Composition

Combining Services

import { authService } from './authService';
import { cloudAuthService } from './cloudAuthService';

export const syncService = {
  async syncSettings(): Promise<void> {
    // Check local auth
    const hasLocalSession = await authService.checkSession();
    if (!hasLocalSession) {
      throw new Error('No local session');
    }
    
    // Check cloud auth
    const cloudUser = await cloudAuthService.init();
    if (!cloudUser) {
      throw new Error('No cloud session');
    }
    
    // Sync settings
    await cloudSettingsService.sync();
  }
};

Best Practices

1. Single Responsibility

// ✅ Good - Focused service
export const authService = {
  checkSession,
  login,
  logout
};

// ❌ Avoid - Too many responsibilities
export const megaService = {
  checkSession,
  login,
  loadAssessments,
  saveSettings,
  fetchWeather
};

2. Error Handling

// ✅ Good - Always handle errors
async function loadData(): Promise<Data | null> {
  try {
    return await fetchData();
  } catch (error) {
    logger.error('service', 'loadData', `Failed: ${error}`, { error });
    return null;
  }
}

3. Caching Strategy

// ✅ Good - Use appropriate TTL
cache.set('userInfo', user, 60); // Long-lived
cache.set('assessments', data, 10); // Short-lived
cache.set('config', config, 1440); // Very long-lived

4. Type Safety

// ✅ Good - Type everything
export interface UserInfo {
  id: number;
  email: string;
}

async function loadUser(): Promise<UserInfo | null> {
  return await invoke<UserInfo>('get_user');
}

Next Steps