635 lines
15 KiB
Markdown
635 lines
15 KiB
Markdown
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
|
|
```javascript
|
|
<script>
|
|
import { expenseCache } from '$lib/stores/expenseCache';
|
|
|
|
// Reactive to cache updates
|
|
$effect(() => {
|
|
expenseCache.load();
|
|
});
|
|
</script>
|
|
|
|
{#each $expenseCache as expense}
|
|
<ExpenseCard {expense} />
|
|
{/each}
|
|
```
|
|
|
|
#### React
|
|
```javascript
|
|
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
|
|
```javascript
|
|
<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:
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
await db.expenses.bulkPut(records);
|
|
```
|
|
|
|
### Stale Detection:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
let syncInterval;
|
|
|
|
export function startAutoSync() {
|
|
syncInterval = setInterval(() => {
|
|
syncInBackground();
|
|
}, 3 * 60 * 1000); // Every 3 minutes
|
|
}
|
|
|
|
export function stopAutoSync() {
|
|
clearInterval(syncInterval);
|
|
}
|
|
```
|
|
|
|
### Partner Resolution Pattern:
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```javascript
|
|
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
|
|
```javascript
|
|
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
|
|
```javascript
|
|
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
|
|
```javascript
|
|
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
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```javascript
|
|
let cachedUid = null;
|
|
|
|
async function authenticate() {
|
|
if (cachedUid) {
|
|
return cachedUid;
|
|
}
|
|
|
|
// Authenticate with Odoo
|
|
cachedUid = await odooClient.authenticate(
|
|
db, username, apiKey
|
|
);
|
|
|
|
return cachedUid;
|
|
}
|
|
```
|
|
|
|
### Action Routing:
|
|
|
|
```javascript
|
|
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`!
|