Files
2025-11-29 18:50:06 +08:00

15 KiB

Detailed explanation of the Odoo PWA architecture, patterns, and design decisions.

What this command does:

  • Explains the architectural patterns used in generated PWAs
  • Details the data flow and state management
  • Describes the caching strategy
  • Explains offline-first design principles
  • Provides insights into technical decisions

Architecture Overview 🏗️

The Odoo PWA Generator creates offline-first Progressive Web Apps with a three-layer architecture that ensures data availability, performance, and seamless Odoo integration.

┌─────────────────────────────────────────┐
│         UI Layer (Components)           │
│  - Forms, Lists, Navigation             │
│  - Framework-specific (Svelte/React/Vue)│
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│      State Layer (Cache Stores)         │
│  - Smart Caching Logic                  │
│  - Dual Storage (localStorage +  IndexedDB) │
│  - Background Sync                      │
│  - Optimistic Updates                   │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│       API Layer (Odoo Client)           │
│  - JSON-RPC Communication               │
│  - CRUD Operations                      │
│  - Field Formatting                     │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│    Server Layer (API Routes)            │
│  - Credential Management                │
│  - UID Caching                          │
│  - Error Handling                       │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│         Odoo Backend                    │
│  - Studio Models                        │
│  - Business Logic                       │
│  - Data Persistence                     │
└─────────────────────────────────────────┘

Layer 1: UI Components 🎨

Purpose:

Present data to users and capture user input.

Responsibilities:

  • Render data from cache stores
  • Handle user interactions
  • Validate form inputs
  • Display loading and error states
  • Provide responsive, mobile-friendly interface

Framework-Specific Implementation:

SvelteKit

<script>
  import { expenseCache } from '$lib/stores/expenseCache';

  // Reactive to cache updates
  $effect(() => {
    expenseCache.load();
  });
</script>

{#each $expenseCache as expense}
  <ExpenseCard {expense} />
{/each}

React

import { useExpense } from './contexts/ExpenseContext';

function ExpenseList() {
  const { records, isLoading } = useExpense();

  useEffect(() => {
    // Load on mount
  }, []);

  return records.map(expense => (
    <ExpenseCard key={expense.id} expense={expense} />
  ));
}

Vue

<script setup>
import { useExpenseStore } from '@/stores/expenseStore';

const expenseStore = useExpenseStore();
expenseStore.load();
</script>

<template>
  <ExpenseCard
    v-for="expense in expenseStore.records"
    :key="expense.id"
    :expense="expense"
  />
</template>

Design Principles:

  • Reactive by default - UI updates automatically when data changes
  • Loading states - Show skeleton loaders while data fetches
  • Error boundaries - Graceful error handling
  • Optimistic UI - Show changes immediately, sync in background

Layer 2: Cache Stores (State Management) 💾

Purpose:

Manage application state with offline-first caching.

The Smart Caching Pattern:

// Two-phase load strategy
export async function load() {
  // Phase 1: Load from cache (instant)
  const cached = await loadFromCache();
  records.set(cached); // UI shows data immediately

  // Phase 2: Check if stale and sync
  if (isCacheStale()) {
    await syncInBackground(); // Update in background
  }
}

Dual Storage Strategy:

localStorage (Metadata)

Stores lightweight metadata:

  • lastSyncTime - When was last successful sync
  • lastRecordId - Highest ID fetched so far
  • version - Cache schema version
localStorage.setItem('expenseCache', JSON.stringify({
  lastSyncTime: Date.now(),
  lastRecordId: 123,
  version: 1
}));

IndexedDB (Master Data)

Stores actual records:

  • All record data
  • Larger storage capacity
  • Async API
  • Structured data
await db.expenses.bulkPut(records);

Stale Detection:

function isCacheStale() {
  const cacheData = JSON.parse(localStorage.getItem('expenseCache'));
  const CACHE_VALIDITY = 5 * 60 * 1000; // 5 minutes

  return !cacheData ||
         (Date.now() - cacheData.lastSyncTime) > CACHE_VALIDITY;
}

Incremental Sync Pattern:

// Only fetch new records, not everything
async function syncInBackground() {
  const { lastRecordId } = getCacheMetadata();

  // Fetch only records with id > lastRecordId
  const newRecords = await odoo.searchRecords(
    'x_expense',
    [['id', '>', lastRecordId]],
    fields
  );

  if (newRecords.length > 0) {
    await appendToCache(newRecords);
    updateLastRecordId(newRecords[newRecords.length - 1].id);
  }
}

Optimistic Updates:

export async function create(data) {
  // 1. Generate temporary ID
  const tempId = `temp-${Date.now()}`;
  const tempRecord = { id: tempId, ...data };

  // 2. Update UI immediately
  records.update(r => [...r, tempRecord]);

  // 3. Create in Odoo (background)
  try {
    const realId = await odoo.createRecord('x_expense', data);

    // 4. Replace temp ID with real ID
    records.update(r =>
      r.map(rec => rec.id === tempId ? { ...rec, id: realId } : rec)
    );
  } catch (error) {
    // 5. Rollback on error
    records.update(r => r.filter(rec => rec.id !== tempId));
    throw error;
  }
}

Background Sync Timer:

let syncInterval;

export function startAutoSync() {
  syncInterval = setInterval(() => {
    syncInBackground();
  }, 3 * 60 * 1000); // Every 3 minutes
}

export function stopAutoSync() {
  clearInterval(syncInterval);
}

Partner Resolution Pattern:

// Many2one fields return [id, name] or just id
// Resolve partner names and cache them

async function resolvePartnerNames(records) {
  const partnerIds = new Set();

  // Collect all unique partner IDs
  records.forEach(record => {
    if (record.x_studio_employee) {
      if (Array.isArray(record.x_studio_employee)) {
        partnerIds.add(record.x_studio_employee[0]);
      } else {
        partnerIds.add(record.x_studio_employee);
      }
    }
  });

  // Fetch partner names in batch
  const partners = await odoo.fetchPartners(Array.from(partnerIds));
  const partnerMap = new Map(partners.map(p => [p.id, p.name]));

  // Cache for future use
  localStorage.setItem('partnerCache', JSON.stringify(
    Array.from(partnerMap.entries())
  ));

  return partnerMap;
}

Layer 3: API Client (Odoo Communication) 🔌

Purpose:

Abstract Odoo API communication with clean, reusable methods.

JSON-RPC Communication:

async function jsonRpc(url, params) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',
      method: 'call',
      params: params,
      id: Math.random()
    })
  });

  const data = await response.json();

  if (data.error) {
    throw new Error(data.error.message);
  }

  return data.result;
}

CRUD Methods:

Create

export async function createRecord(model, fields) {
  return await fetch('/api/odoo', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      action: 'create',
      model,
      fields
    })
  });
}

Read

export async function searchRecords(model, domain, fields) {
  return await fetch('/api/odoo', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      action: 'search',
      model,
      domain,
      fields
    })
  });
}

Update

export async function updateRecord(model, id, values) {
  return await fetch('/api/odoo', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      action: 'update',
      model,
      id,
      values
    })
  });
}

Delete

export async function deleteRecord(model, id) {
  return await fetch('/api/odoo', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      action: 'delete',
      model,
      id
    })
  });
}

Field Formatting Helpers:

// Many2one: Convert to Odoo format [id, false]
export function formatMany2one(id) {
  return id ? [id, false] : false;
}

// Many2many: Convert to Odoo command [(6, 0, [ids])]
export function formatMany2many(ids) {
  return ids && ids.length > 0 ? [[6, 0, ids]] : [[6, 0, []]];
}

Layer 4: Server Routes (API Proxy) 🔐

Purpose:

Securely handle Odoo authentication and proxy requests.

Why Server-Side?

  1. Security - API keys never exposed to client
  2. CORS - Bypass cross-origin restrictions
  3. Caching - Cache UIDs to reduce auth calls
  4. Error Handling - Centralized error management

UID Caching Pattern:

let cachedUid = null;

async function authenticate() {
  if (cachedUid) {
    return cachedUid;
  }

  // Authenticate with Odoo
  cachedUid = await odooClient.authenticate(
    db, username, apiKey
  );

  return cachedUid;
}

Action Routing:

export async function POST({ request }) {
  const { action, model, ...params } = await request.json();
  const uid = await authenticate();

  switch (action) {
    case 'create':
      return odooClient.create(model, params.fields);
    case 'search':
      return odooClient.searchRead(model, params.domain, params.fields);
    case 'update':
      return odooClient.write(model, params.id, params.values);
    case 'delete':
      return odooClient.unlink(model, params.id);
    default:
      throw new Error(`Unknown action: ${action}`);
  }
}

Data Flow Examples 🔄

Example 1: Loading Data on Page Load

1. User navigates to page
   ↓
2. Component calls expenseCache.load()
   ↓
3. Cache store loads from localStorage/IndexedDB
   ↓
4. UI updates with cached data (instant)
   ↓
5. Cache checks if data is stale (> 5 min)
   ↓
6. If stale, triggers background sync
   ↓
7. API client calls /api/odoo
   ↓
8. Server authenticates and calls Odoo
   ↓
9. New records returned
   ↓
10. Cache updated (localStorage + IndexedDB)
    ↓
11. UI reactively updates with new data

Example 2: Creating a Record

1. User submits form
   ↓
2. Component calls expenseCache.create(data)
   ↓
3. Cache creates temp record with temp-ID
   ↓
4. UI updates immediately (optimistic)
   ↓
5. API client calls /api/odoo (background)
   ↓
6. Server creates record in Odoo
   ↓
7. Real ID returned
   ↓
8. Cache replaces temp-ID with real ID
   ↓
9. localStorage and IndexedDB updated
   ↓
10. UI shows success message

Example 3: Offline Create → Online Sync

1. User creates record (offline)
   ↓
2. Record saved to cache with temp-ID
   ↓
3. API call fails (no network)
   ↓
4. Record marked as "pending sync"
   ↓
5. UI shows "Will sync when online"
   ↓
   [User goes online]
   ↓
6. Background sync detects pending records
   ↓
7. Retries API call
   ↓
8. Success! Real ID received
   ↓
9. Cache updated
   ↓
10. UI shows "Synced" status

Key Design Patterns 🎯

1. Offline-First

  • Always load from cache first
  • Sync in background
  • Queue operations when offline
  • Retry on reconnection

2. Optimistic UI

  • Update UI immediately
  • Sync with server in background
  • Rollback on error
  • Show pending states

3. Incremental Sync

  • Don't re-fetch all data
  • Only fetch new records (id > lastRecordId)
  • Reduces bandwidth
  • Faster sync times

4. Dual Storage

  • localStorage for metadata (fast)
  • IndexedDB for data (large)
  • Best of both worlds

5. Partner Resolution

  • Batch fetch related records
  • Cache partner names
  • Avoid N+1 queries
  • Display human-readable names

6. Reactive State

  • Framework-native reactivity
  • UI updates automatically
  • No manual DOM manipulation
  • Cleaner code

Performance Considerations

Initial Load:

  • Cache-first: Instant data display
  • Background sync: Fetch updates without blocking
  • IndexedDB: Fast access to large datasets

Network Usage:

  • Incremental sync: Only new data
  • Batch operations: Combine requests
  • UID caching: Reduce auth calls

Memory Usage:

  • Store large data in IndexedDB, not memory
  • Clean up old data periodically
  • Lazy load related data

Bundle Size:

  • Framework-specific optimizations
  • Tree shaking
  • Code splitting
  • Lazy load routes

Security Patterns 🔒

Credential Management:

  • API keys in environment variables
  • Server-side authentication
  • Never expose keys to client
  • Rotate keys periodically

Data Validation:

  • Validate on client (UX)
  • Validate on server (security)
  • Sanitize inputs
  • Check permissions

Error Handling:

  • Don't expose internal errors to user
  • Log errors securely
  • Graceful degradation
  • User-friendly messages

Example prompts to use this command:

  • /architecture - Show architecture details
  • User: "How does the caching work?"
  • User: "Explain the data flow"
  • User: "What design patterns are used?"

Next Steps:

After understanding the architecture:

  1. Review generated code in your project
  2. Read CLAUDE.md in your project for specific details
  3. Customize patterns for your use case
  4. Optimize for your specific needs

For more information, see /help or /examples!