15 KiB
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 synclastRecordId- Highest ID fetched so farversion- 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?
- Security - API keys never exposed to client
- CORS - Bypass cross-origin restrictions
- Caching - Cache UIDs to reduce auth calls
- 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:
- Review generated code in your project
- Read CLAUDE.md in your project for specific details
- Customize patterns for your use case
- Optimize for your specific needs
For more information, see /help or /examples!