Files
pantry/docs/ARCHITECTURE.md
Claw 41537fa84c docs: Initial project documentation
- README: Project overview, quick start, tech stack
- PROJECT_PLAN: Vision, roadmap, 6-week MVP timeline
- ARCHITECTURE: System design, data model, tech decisions

Establishes foundation for Pantry - self-hosted kitchen
inventory PWA with barcode scanning.
2026-02-08 18:47:48 +00:00

16 KiB

Pantry - Architecture

Version: 1.0
Last Updated: 2026-02-08


🏗️ System Overview

┌─────────────────────────────────────────────┐
│             User Devices                     │
│  (Phone, Tablet, Desktop - PWA)             │
└──────────────────┬──────────────────────────┘
                   │ HTTPS
                   ▼
┌─────────────────────────────────────────────┐
│          Nuxt 4 Frontend (SSR)              │
│  - Vue 3 Components                          │
│  - Tailwind CSS + Nuxt UI                   │
│  - PWA (offline-first)                       │
│  - Barcode Scanner                           │
└──────────────────┬──────────────────────────┘
                   │ WebSocket + REST
                   ▼
┌─────────────────────────────────────────────┐
│            Supabase Platform                 │
│  ┌─────────────────────────────────────┐   │
│  │  PostgreSQL (Database)              │   │
│  │  - Items, Products, Tags, Units     │   │
│  │  - Row Level Security (RLS)         │   │
│  └─────────────────────────────────────┘   │
│  ┌─────────────────────────────────────┐   │
│  │  Auth (GoTrue)                      │   │
│  │  - Email/Password                    │   │
│  │  - OIDC (optional)                   │   │
│  └─────────────────────────────────────┘   │
│  ┌─────────────────────────────────────┐   │
│  │  Realtime (WebSocket)               │   │
│  │  - Live updates across devices       │   │
│  └─────────────────────────────────────┘   │
│  ┌─────────────────────────────────────┐   │
│  │  Edge Functions (Deno)              │   │
│  │  - Product lookup                    │   │
│  │  - Barcode cache                     │   │
│  └─────────────────────────────────────┘   │
└──────────────────┬──────────────────────────┘
                   │ HTTP
                   ▼
┌─────────────────────────────────────────────┐
│       Open Food Facts API                    │
│  (External - product data)                   │
└─────────────────────────────────────────────┘

📦 Tech Stack

Frontend

Nuxt 4 (Vue 3)

  • Why: Meta-framework, SSR, excellent DX
  • Alternatives considered: Next.js (React), SvelteKit
  • Decision: Vue ecosystem maturity + Nuxt UI

Tailwind CSS

  • Why: Utility-first, fast iteration
  • Alternatives: CSS Modules, UnoCSS
  • Decision: Industry standard, Nuxt UI built on it

Nuxt UI

  • Why: Pre-built components, accessible
  • Alternatives: Headless UI, Shadcn
  • Decision: First-party Nuxt support

html5-qrcode

  • Why: PWA camera support, multi-format
  • Alternatives: ZXing, QuaggaJS
  • Decision: Best mobile performance in tests

Backend

Supabase

  • Why: Postgres + Auth + Realtime in one
  • Alternatives: Firebase, Appwrite, custom backend
  • Decision: Self-hosted, SQL flexibility, mature

PostgreSQL 15+

  • Why: Robust, supports JSONB, full-text search
  • Alternatives: MySQL, MongoDB
  • Decision: Comes with Supabase, best for relational data

Deno Edge Functions

  • Why: Serverless, TypeScript-native
  • Alternatives: Node.js functions, custom API
  • Decision: Part of Supabase, fast cold starts

Deployment

Docker Compose

  • Why: Simple multi-container orchestration
  • Alternatives: Kubernetes, bare metal
  • Decision: Right complexity level for self-hosted

Bun (runtime)

  • Why: Fast, npm-compatible, built-in TypeScript
  • Alternatives: Node.js, pnpm
  • Decision: Modern, faster than Node

🗄️ Data Model

Core Entities

erDiagram
    INVENTORY_ITEMS ||--o| PRODUCTS : references
    INVENTORY_ITEMS }o--o{ TAGS : tagged_with
    INVENTORY_ITEMS ||--|| UNITS : uses
    PRODUCTS ||--|| UNITS : default_unit
    UNITS ||--o| UNITS : converts_to
    ITEM_TAGS }|--|| INVENTORY_ITEMS : item
    ITEM_TAGS }|--|| TAGS : tag
    
    INVENTORY_ITEMS {
        uuid id PK
        uuid product_id FK
        string name
        decimal quantity
        uuid unit_id FK
        date expiry_date
        uuid added_by FK
        timestamp created_at
        timestamp updated_at
    }
    
    PRODUCTS {
        uuid id PK
        string barcode UK
        string name
        string brand
        string image_url
        uuid default_unit_id FK
        timestamp cached_at
    }
    
    TAGS {
        uuid id PK
        string name
        enum category
        string icon
        string color
        uuid created_by FK
    }
    
    UNITS {
        uuid id PK
        string name
        string abbreviation
        enum unit_type
        uuid base_unit_id FK
        decimal conversion_factor
        boolean is_default
        uuid created_by FK
    }
    
    ITEM_TAGS {
        uuid item_id FK
        uuid tag_id FK
    }

Key Relationships

  1. Items ↔ Products: Many-to-one (multiple items from same product)
  2. Items ↔ Tags: Many-to-many (items can have multiple tags)
  3. Units ↔ Units: Self-referential (g → kg conversion)
  4. Items → Users: Audit trail (who added)

🔐 Security Model

Authentication

Supabase Auth (GoTrue)

Email/Password (Default):

const { user, error } = await supabase.auth.signUp({
  email: 'user@example.com',
  password: 'secure-password'
})

OIDC (Optional - Admin Config):

# Admin sets via environment
SUPABASE_AUTH_GOOGLE_ENABLED=true
SUPABASE_AUTH_GOOGLE_CLIENT_ID=...
SUPABASE_AUTH_GOOGLE_SECRET=...

Authorization

Row Level Security (RLS)

Inventory Items:

-- Everyone can read (shared inventory)
CREATE POLICY "items_select" ON inventory_items
  FOR SELECT USING (true);

-- Authenticated users can insert
CREATE POLICY "items_insert" ON inventory_items
  FOR INSERT WITH CHECK (
    auth.uid() IS NOT NULL
  );

-- Users can update any item (trust model)
CREATE POLICY "items_update" ON inventory_items
  FOR UPDATE USING (
    auth.uid() IS NOT NULL
  );

-- Users can delete any item
CREATE POLICY "items_delete" ON inventory_items
  FOR DELETE USING (
    auth.uid() IS NOT NULL
  );

Products (read-only for users):

-- Everyone reads
CREATE POLICY "products_select" ON products
  FOR SELECT USING (true);

-- Only service role writes (via Edge Functions)
-- (No user-facing policy needed)

Tags (user-managed):

-- Default tags: everyone reads
-- Custom tags: creator manages
CREATE POLICY "tags_select" ON tags
  FOR SELECT USING (true);

CREATE POLICY "tags_insert" ON tags
  FOR INSERT WITH CHECK (
    auth.uid() IS NOT NULL
  );

CREATE POLICY "tags_update" ON tags
  FOR UPDATE USING (
    created_by = auth.uid() OR created_by IS NULL
  );

🔄 Data Flow

1. Barcode Scan Flow

User taps "Scan" button
  ↓
Camera permission requested (if first time)
  ↓
Camera opens (html5-qrcode)
  ↓
Barcode detected (e.g., "8000500310427")
  ↓
Frontend calls Edge Function
  POST /functions/v1/product-lookup
  { barcode: "8000500310427" }
  ↓
Edge Function checks products table
  ├─ Found: return cached product
  └─ Not found:
      ↓
    Fetch from Open Food Facts API
      https://world.openfoodfacts.org/api/v2/product/{barcode}
      ↓
    Parse response (name, brand, image, etc.)
      ↓
    Cache in products table
      ↓
    Return product data
  ↓
Frontend displays product card
  ↓
User sets quantity, tags, expiry (optional)
  ↓
Frontend inserts into inventory_items table
  ↓
Realtime updates other devices (WebSocket)
  ↓
Done!

2. Manual Add Flow

User taps "Add Manually"
  ↓
Form: name, quantity, unit, tags, expiry
  ↓
Frontend validates (required: name, quantity)
  ↓
Insert into inventory_items
  ↓
Realtime broadcast to other devices
  ↓
Done!

3. Unit Conversion Flow

User selects unit (e.g., g)
  ↓
Item has quantity (e.g., 500g)
  ↓
User wants to view in kg
  ↓
Frontend calls conversion logic:
  convert(500, g, kg)
    ↓
  Find g.base_unit = kg
  Find g.conversion_factor = 0.001
    ↓
  result = 500 * 0.001 = 0.5 kg
  ↓
Display: "0.5 kg (500 g)"

🌐 API Design

Supabase Client (Direct Access)

Inventory CRUD:

// List items
const { data } = await supabase
  .from('inventory_items')
  .select(`
    *,
    product:products(*),
    unit:units(*),
    tags:item_tags(tag:tags(*))
  `)
  .order('created_at', { ascending: false })

// Add item
const { data, error } = await supabase
  .from('inventory_items')
  .insert({
    product_id: '...',
    quantity: 1,
    unit_id: '...',
    added_by: user.id
  })

// Update quantity (consume)
const { data } = await supabase
  .from('inventory_items')
  .update({ quantity: item.quantity - 1 })
  .eq('id', itemId)

// Delete item
const { error } = await supabase
  .from('inventory_items')
  .delete()
  .eq('id', itemId)

Edge Functions

Product Lookup:

// POST /functions/v1/product-lookup
// Body: { barcode: string }

interface Request {
  barcode: string
}

interface Response {
  product: Product | null
  cached: boolean
}

// Returns cached product or fetches from Open Food Facts

Batch Lookup (future):

// POST /functions/v1/products/batch
// Body: { barcodes: string[] }

// For offline sync (scan multiple, upload batch)

📱 PWA Architecture

Service Worker Strategy

Network-first with cache fallback:

// App shell: cache-first
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/app')) {
    event.respondWith(
      caches.match(event.request)
        .then(cached => cached || fetch(event.request))
    )
  }
})

// API calls: network-first
if (event.request.url.includes('/api')) {
  event.respondWith(
    fetch(event.request)
      .catch(() => caches.match(event.request))
  )
}

Offline Support

Offline Queue:

// Queue scans when offline
if (!navigator.onLine) {
  await db.offlineQueue.add({
    type: 'scan',
    barcode: '123456',
    timestamp: Date.now()
  })
}

// Sync when online
window.addEventListener('online', async () => {
  const queue = await db.offlineQueue.getAll()
  for (const item of queue) {
    await processQueueItem(item)
  }
})

🎨 Frontend Architecture

Component Structure

app/
├── components/
│   ├── inventory/
│   │   ├── ItemList.vue         # Main inventory view
│   │   ├── ItemCard.vue         # Single item card
│   │   ├── QuickActions.vue     # Consume, restock buttons
│   │   └── FilterBar.vue        # Search & filter
│   ├── scan/
│   │   ├── BarcodeScanner.vue   # Camera + detection
│   │   ├── ScanButton.vue       # Floating action button
│   │   └── ProductCard.vue      # After scan confirmation
│   ├── tags/
│   │   ├── TagPicker.vue        # Multi-select tags
│   │   ├── TagBadge.vue         # Display tag
│   │   └── TagManager.vue       # Settings: manage tags
│   ├── units/
│   │   ├── UnitSelector.vue     # Dropdown with conversions
│   │   └── UnitDisplay.vue      # Show quantity + unit
│   └── common/
│       ├── AppHeader.vue
│       ├── AppFooter.vue
│       └── LoadingState.vue
├── composables/
│   ├── useBarcode.ts            # Camera, detection logic
│   ├── useInventory.ts          # CRUD operations
│   ├── useUnits.ts              # Conversions
│   ├── useTags.ts               # Tag filtering
│   └── useOpenFoodFacts.ts      # API wrapper
├── pages/
│   ├── index.vue                # Inventory list
│   ├── scan.vue                 # Full-screen scanner
│   ├── item/[id].vue            # Item detail/edit
│   └── settings.vue             # Tags, units, profile
└── utils/
    ├── conversions.ts           # Unit conversion math
    ├── barcode-formats.ts       # Supported formats
    └── offline-queue.ts         # Offline sync

State Management

No Vuex/Pinia needed (for MVP):

  • Supabase Realtime = reactive state
  • Composables for shared logic
  • Props/emits for component state

If needed later:

  • Pinia for complex UI state (filters, etc.)

🔧 Development Workflow

Local Development

# Terminal 1: Supabase
docker-compose -f docker/docker-compose.dev.yml up

# Terminal 2: Nuxt dev server
cd app
bun run dev

# Access:
# - App: http://localhost:3000
# - Supabase Studio: http://localhost:54323

Database Migrations

# Create migration
supabase migration new add_tags_table

# Apply locally
supabase db reset

# Push to production
supabase db push

Testing

# Unit tests
bun test

# E2E tests (Playwright)
bun run test:e2e

# Type check
bun run typecheck

🚀 Deployment

Docker Compose (Production)

services:
  supabase:
    image: supabase/postgres:15
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
  
  supabase-auth:
    image: supabase/gotrue
    environment:
      SITE_URL: ${SITE_URL}
      # OIDC config (optional)
  
  pantry:
    build: ./app
    environment:
      SUPABASE_URL: http://supabase:8000
      SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
    ports:
      - "3000:3000"

Coolify Deployment

Service Config:

  • Type: Docker Compose
  • Repo: pantry-app/pantry
  • Compose File: docker/docker-compose.yml
  • Domain: pantry.yourdomain.com

📊 Performance Considerations

Image Optimization

  • Open Food Facts images cached locally
  • Nuxt Image module for responsive images
  • WebP format with fallbacks

Database Indexing

-- Fast barcode lookup
CREATE INDEX idx_products_barcode ON products(barcode);

-- Fast tag filtering
CREATE INDEX idx_item_tags_tag ON item_tags(tag_id);

-- Fast user queries
CREATE INDEX idx_items_added_by ON inventory_items(added_by);

Realtime Optimization

  • Only subscribe to user's inventory (RLS filters)
  • Debounce updates (don't broadcast every keystroke)

🔮 Future Considerations

Scalability

  • Database: Postgres scales to millions of rows
  • Storage: Images stored in Supabase Storage (S3-compatible)
  • Caching: Redis for product lookup cache (if Open Food Facts is slow)

Multi-Tenancy (v2.0+)

  • Add household_id to all tables
  • RLS policies filter by household
  • Invite system for family members

Native Mobile App (v2.0+)

  • Capacitor wrapper around Nuxt
  • Native barcode scanner (faster than PWA)
  • Push notifications (expiry alerts)

📚 References


Next: Database Schema