Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Organized docs into logical subdirectories:
**New Structure:**
- docs/
- README.md (index with quick links)
- PROJECT_PLAN.md (root level - main roadmap)
- development/
- getting-started.md (5-min quickstart)
- local-setup.md (detailed Docker Compose guide)
- workflow.md (daily development)
- git-workflow.md (branching strategy)
- architecture/
- overview.md (tech stack, design)
- database.md (schema, RLS, migrations)
- api.md (endpoints, functions)
- deployment/
- production.md (Docker, Coolify)
- ci-cd.md (automated pipelines)
**Cleaned Up:**
- Moved DEV_SETUP.md → docs/development/local-setup.md
- Removed outdated SETUP.md (referenced old Coolify setup)
- Replaced with getting-started.md (current Docker Compose flow)
- Updated README.md links to new structure
All paths tested, no broken links.
652 lines
16 KiB
Markdown
652 lines
16 KiB
Markdown
# Pantry - Database Schema
|
|
|
|
**Version:** 1.0
|
|
**Last Updated:** 2026-02-08
|
|
**PostgreSQL:** 15+
|
|
|
|
---
|
|
|
|
## 📊 Schema Overview
|
|
|
|
### Tables
|
|
|
|
| Table | Purpose | Rows (Est.) |
|
|
|-------|---------|-------------|
|
|
| `inventory_items` | Current inventory (in your kitchen) | 100-500 |
|
|
| `products` | Master data cache (from Open Food Facts) | 500-2000 |
|
|
| `tags` | Organization labels (position, type, custom) | 20-50 |
|
|
| `item_tags` | Many-to-many item ↔ tag | 200-1000 |
|
|
| `units` | Measurement units + conversions | 30-50 |
|
|
| `users` | User accounts (Supabase Auth manages) | 2-10 |
|
|
|
|
---
|
|
|
|
## 🗃️ Table Definitions
|
|
|
|
### `inventory_items`
|
|
|
|
**Purpose:** Actual items in your kitchen right now
|
|
|
|
```sql
|
|
CREATE TABLE inventory_items (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Product reference (nullable for custom items)
|
|
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
|
|
|
|
-- Core data
|
|
name TEXT NOT NULL, -- Product name or custom name
|
|
quantity DECIMAL(10,2) NOT NULL CHECK (quantity >= 0),
|
|
unit_id UUID NOT NULL REFERENCES units(id),
|
|
|
|
-- Optional metadata
|
|
expiry_date DATE,
|
|
location TEXT, -- Free text: "top shelf", "door", etc.
|
|
notes TEXT,
|
|
|
|
-- Audit trail
|
|
added_by UUID NOT NULL REFERENCES auth.users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_items_product ON inventory_items(product_id);
|
|
CREATE INDEX idx_items_added_by ON inventory_items(added_by);
|
|
CREATE INDEX idx_items_expiry ON inventory_items(expiry_date) WHERE expiry_date IS NOT NULL;
|
|
|
|
-- Auto-update timestamp
|
|
CREATE TRIGGER update_items_updated_at
|
|
BEFORE UPDATE ON inventory_items
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at();
|
|
```
|
|
|
|
**Sample Data:**
|
|
```sql
|
|
INSERT INTO inventory_items (product_id, name, quantity, unit_id, expiry_date, added_by)
|
|
VALUES
|
|
('abc-123', 'Whole Milk', 1.5, 'unit-liter', '2026-02-15', 'user-123'),
|
|
(NULL, 'Homemade Jam', 300, 'unit-gram', '2026-06-01', 'user-123');
|
|
```
|
|
|
|
---
|
|
|
|
### `products`
|
|
|
|
**Purpose:** Cached product data from Open Food Facts
|
|
|
|
```sql
|
|
CREATE TABLE products (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Open Food Facts data
|
|
barcode TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
brand TEXT,
|
|
image_url TEXT,
|
|
image_small_url TEXT, -- Thumbnail
|
|
|
|
-- Categories from Open Food Facts
|
|
categories TEXT[], -- Array: ['dairy', 'milk']
|
|
|
|
-- Nutrition (optional, for future features)
|
|
nutrition JSONB, -- Full nutrition data
|
|
|
|
-- Defaults
|
|
default_unit_id UUID REFERENCES units(id),
|
|
default_quantity DECIMAL(10,2), -- E.g., 1L bottle
|
|
|
|
-- Metadata
|
|
source TEXT DEFAULT 'openfoodfacts',
|
|
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_fetched TIMESTAMPTZ,
|
|
|
|
-- Quality score (from Open Food Facts)
|
|
completeness_score INTEGER CHECK (completeness_score BETWEEN 0 AND 100)
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE UNIQUE INDEX idx_products_barcode ON products(barcode);
|
|
CREATE INDEX idx_products_name ON products USING GIN (to_tsvector('english', name));
|
|
|
|
-- Full-text search
|
|
CREATE INDEX idx_products_search ON products
|
|
USING GIN (to_tsvector('english', name || ' ' || COALESCE(brand, '')));
|
|
```
|
|
|
|
**Sample Data:**
|
|
```sql
|
|
INSERT INTO products (barcode, name, brand, image_url, default_unit_id, cached_at)
|
|
VALUES
|
|
('8000500310427', 'Nutella', 'Ferrero', 'https://...', 'unit-gram', NOW()),
|
|
('5449000000996', 'Coca-Cola', 'Coca-Cola', 'https://...', 'unit-liter', NOW());
|
|
```
|
|
|
|
---
|
|
|
|
### `tags`
|
|
|
|
**Purpose:** Flexible labeling system
|
|
|
|
```sql
|
|
CREATE TYPE tag_category AS ENUM (
|
|
'position', -- Location: fridge, freezer, pantry
|
|
'type', -- Food type: dairy, meat, vegan
|
|
'dietary', -- Diet: gluten-free, vegan, organic
|
|
'custom' -- User-defined
|
|
);
|
|
|
|
CREATE TABLE tags (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Core data
|
|
name TEXT NOT NULL,
|
|
category tag_category NOT NULL DEFAULT 'custom',
|
|
|
|
-- Visual
|
|
icon TEXT, -- Emoji or icon name: "🧊", "cheese"
|
|
color TEXT, -- Hex color: "#3b82f6"
|
|
|
|
-- Ownership
|
|
created_by UUID REFERENCES auth.users(id), -- NULL = system tag
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_tag_name UNIQUE (name, created_by)
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_tags_category ON tags(category);
|
|
CREATE INDEX idx_tags_created_by ON tags(created_by);
|
|
```
|
|
|
|
**Sample Data (Seed):**
|
|
```sql
|
|
-- System tags (created_by = NULL)
|
|
INSERT INTO tags (name, category, icon, color, created_by) VALUES
|
|
-- Position
|
|
('Fridge', 'position', '🧊', '#3b82f6', NULL),
|
|
('Freezer', 'position', '❄️', '#0ea5e9', NULL),
|
|
('Pantry', 'position', '📦', '#f59e0b', NULL),
|
|
('Spices', 'position', '🌶️', '#ef4444', NULL),
|
|
|
|
-- Type
|
|
('Dairy', 'type', '🥛', '#fbbf24', NULL),
|
|
('Cheese', 'type', '🧀', '#fcd34d', NULL),
|
|
('Meat', 'type', '🥩', '#dc2626', NULL),
|
|
('Fish', 'type', '🐟', '#06b6d4', NULL),
|
|
('Vegetables', 'type', '🥬', '#10b981', NULL),
|
|
('Fruits', 'type', '🍎', '#f87171', NULL),
|
|
('Bakery', 'type', '🍞', '#d97706', NULL),
|
|
('Snacks', 'type', '🍫', '#7c3aed', NULL),
|
|
|
|
-- Dietary
|
|
('Vegan', 'dietary', '🌱', '#22c55e', NULL),
|
|
('Gluten-Free', 'dietary', '🌾', '#eab308', NULL),
|
|
('Organic', 'dietary', '🍃', '#84cc16', NULL);
|
|
```
|
|
|
|
---
|
|
|
|
### `item_tags`
|
|
|
|
**Purpose:** Many-to-many relationship between items and tags
|
|
|
|
```sql
|
|
CREATE TABLE item_tags (
|
|
item_id UUID NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE,
|
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
PRIMARY KEY (item_id, tag_id)
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_item_tags_tag ON item_tags(tag_id);
|
|
CREATE INDEX idx_item_tags_item ON item_tags(item_id);
|
|
```
|
|
|
|
**Sample Data:**
|
|
```sql
|
|
-- Milk in fridge + dairy tag
|
|
INSERT INTO item_tags (item_id, tag_id) VALUES
|
|
('item-milk', 'tag-fridge'),
|
|
('item-milk', 'tag-dairy');
|
|
```
|
|
|
|
---
|
|
|
|
### `units`
|
|
|
|
**Purpose:** Measurement units with conversion support
|
|
|
|
```sql
|
|
CREATE TYPE unit_type AS ENUM (
|
|
'weight', -- kg, g, lb, oz
|
|
'volume', -- L, mL, cup, tbsp
|
|
'count', -- pcs, items (no conversion)
|
|
'custom' -- can, jar, bottle (user-defined)
|
|
);
|
|
|
|
CREATE TABLE units (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Core data
|
|
name TEXT NOT NULL, -- "kilogram", "liter", "piece"
|
|
abbreviation TEXT NOT NULL, -- "kg", "L", "pcs"
|
|
unit_type unit_type NOT NULL,
|
|
|
|
-- Conversion system
|
|
base_unit_id UUID REFERENCES units(id), -- NULL = this is a base unit
|
|
conversion_factor DECIMAL(20,10), -- Factor to convert to base unit
|
|
|
|
-- E.g., for grams: base_unit = kg, factor = 0.001
|
|
-- To convert: value_in_g * 0.001 = value_in_kg
|
|
|
|
-- Metadata
|
|
is_default BOOLEAN DEFAULT false, -- Shipped with app
|
|
created_by UUID REFERENCES auth.users(id), -- NULL = system unit
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_unit_abbr UNIQUE (abbreviation, created_by)
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_units_type ON units(unit_type);
|
|
CREATE INDEX idx_units_base ON units(base_unit_id);
|
|
```
|
|
|
|
**Sample Data (Seed):**
|
|
```sql
|
|
-- Weight (metric)
|
|
INSERT INTO units (name, abbreviation, unit_type, base_unit_id, conversion_factor, is_default, created_by) VALUES
|
|
('kilogram', 'kg', 'weight', NULL, 1.0, true, NULL), -- Base unit
|
|
('gram', 'g', 'weight', (SELECT id FROM units WHERE abbreviation = 'kg'), 0.001, true, NULL),
|
|
('milligram', 'mg', 'weight', (SELECT id FROM units WHERE abbreviation = 'kg'), 0.000001, true, NULL),
|
|
|
|
-- Volume (metric)
|
|
('liter', 'L', 'volume', NULL, 1.0, true, NULL), -- Base unit
|
|
('milliliter', 'mL', 'volume', (SELECT id FROM units WHERE abbreviation = 'L'), 0.001, true, NULL),
|
|
|
|
-- Count
|
|
('piece', 'pcs', 'count', NULL, 1.0, true, NULL), -- No conversion
|
|
('item', 'item', 'count', NULL, 1.0, true, NULL),
|
|
|
|
-- Custom (common containers)
|
|
('can', 'can', 'custom', NULL, NULL, true, NULL), -- User defines conversion
|
|
('jar', 'jar', 'custom', NULL, NULL, true, NULL),
|
|
('bottle', 'bottle', 'custom', NULL, NULL, true, NULL),
|
|
('package', 'pkg', 'custom', NULL, NULL, true, NULL);
|
|
```
|
|
|
|
---
|
|
|
|
### `users` (Supabase Auth)
|
|
|
|
**Purpose:** User accounts (managed by Supabase Auth)
|
|
|
|
```sql
|
|
-- This table is managed by Supabase Auth (auth.users)
|
|
-- We only reference it via foreign keys
|
|
|
|
-- Additional user metadata (if needed)
|
|
CREATE TABLE user_profiles (
|
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
display_name TEXT,
|
|
avatar_url TEXT,
|
|
|
|
-- Preferences
|
|
default_unit_system TEXT DEFAULT 'metric', -- 'metric' or 'imperial'
|
|
theme TEXT DEFAULT 'auto', -- 'light', 'dark', 'auto'
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 🔐 Row Level Security (RLS)
|
|
|
|
### Enable RLS
|
|
|
|
```sql
|
|
ALTER TABLE inventory_items ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE item_tags ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE units ENABLE ROW LEVEL SECURITY;
|
|
```
|
|
|
|
### Policies
|
|
|
|
**inventory_items:**
|
|
```sql
|
|
-- Everyone can read (shared inventory)
|
|
CREATE POLICY "items_select_all" ON inventory_items
|
|
FOR SELECT USING (true);
|
|
|
|
-- Authenticated users can insert
|
|
CREATE POLICY "items_insert_auth" ON inventory_items
|
|
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
|
|
|
-- Authenticated users can update
|
|
CREATE POLICY "items_update_auth" ON inventory_items
|
|
FOR UPDATE USING (auth.uid() IS NOT NULL);
|
|
|
|
-- Authenticated users can delete
|
|
CREATE POLICY "items_delete_auth" ON inventory_items
|
|
FOR DELETE USING (auth.uid() IS NOT NULL);
|
|
```
|
|
|
|
**products:**
|
|
```sql
|
|
-- Everyone can read cached products
|
|
CREATE POLICY "products_select_all" ON products
|
|
FOR SELECT USING (true);
|
|
|
|
-- Only service role can write (via Edge Functions)
|
|
-- (No user-level INSERT/UPDATE policy)
|
|
```
|
|
|
|
**tags:**
|
|
```sql
|
|
-- Everyone can read all tags
|
|
CREATE POLICY "tags_select_all" ON tags
|
|
FOR SELECT USING (true);
|
|
|
|
-- Authenticated users can create tags
|
|
CREATE POLICY "tags_insert_auth" ON tags
|
|
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
|
|
|
-- Users can only update their own tags (or system tags if admin)
|
|
CREATE POLICY "tags_update_own" ON tags
|
|
FOR UPDATE USING (
|
|
created_by = auth.uid() OR created_by IS NULL
|
|
);
|
|
|
|
-- Users can only delete their own tags
|
|
CREATE POLICY "tags_delete_own" ON tags
|
|
FOR DELETE USING (created_by = auth.uid());
|
|
```
|
|
|
|
**item_tags:**
|
|
```sql
|
|
-- Everyone can read
|
|
CREATE POLICY "item_tags_select_all" ON item_tags
|
|
FOR SELECT USING (true);
|
|
|
|
-- Authenticated users can add tags to items
|
|
CREATE POLICY "item_tags_insert_auth" ON item_tags
|
|
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
|
|
|
-- Authenticated users can remove tags
|
|
CREATE POLICY "item_tags_delete_auth" ON item_tags
|
|
FOR DELETE USING (auth.uid() IS NOT NULL);
|
|
```
|
|
|
|
**units:**
|
|
```sql
|
|
-- Everyone can read
|
|
CREATE POLICY "units_select_all" ON units
|
|
FOR SELECT USING (true);
|
|
|
|
-- Authenticated users can create custom units
|
|
CREATE POLICY "units_insert_auth" ON units
|
|
FOR INSERT WITH CHECK (
|
|
auth.uid() IS NOT NULL AND is_default = false
|
|
);
|
|
|
|
-- Users can update their own custom units
|
|
CREATE POLICY "units_update_own" ON units
|
|
FOR UPDATE USING (
|
|
created_by = auth.uid() AND is_default = false
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 Functions & Triggers
|
|
|
|
### Update Timestamp Trigger
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Apply to tables with updated_at
|
|
CREATE TRIGGER update_items_updated_at
|
|
BEFORE UPDATE ON inventory_items
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at();
|
|
|
|
CREATE TRIGGER update_profiles_updated_at
|
|
BEFORE UPDATE ON user_profiles
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at();
|
|
```
|
|
|
|
### Unit Conversion Function
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION convert_unit(
|
|
quantity DECIMAL,
|
|
from_unit_id UUID,
|
|
to_unit_id UUID
|
|
)
|
|
RETURNS DECIMAL AS $$
|
|
DECLARE
|
|
from_factor DECIMAL;
|
|
to_factor DECIMAL;
|
|
from_type unit_type;
|
|
to_type unit_type;
|
|
base_quantity DECIMAL;
|
|
BEGIN
|
|
-- Get unit types and conversion factors
|
|
SELECT unit_type,
|
|
COALESCE(conversion_factor, 1.0) INTO from_type, from_factor
|
|
FROM units WHERE id = from_unit_id;
|
|
|
|
SELECT unit_type,
|
|
COALESCE(conversion_factor, 1.0) INTO to_type, to_factor
|
|
FROM units WHERE id = to_unit_id;
|
|
|
|
-- Check if units are compatible
|
|
IF from_type != to_type THEN
|
|
RAISE EXCEPTION 'Cannot convert between % and %', from_type, to_type;
|
|
END IF;
|
|
|
|
-- Convert to base unit, then to target unit
|
|
base_quantity := quantity * from_factor;
|
|
RETURN base_quantity / to_factor;
|
|
END;
|
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
|
|
-- Usage:
|
|
-- SELECT convert_unit(500, 'gram-id', 'kg-id'); -> 0.5
|
|
```
|
|
|
|
### Full-Text Search Function
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION search_products(search_query TEXT)
|
|
RETURNS TABLE (
|
|
id UUID,
|
|
barcode TEXT,
|
|
name TEXT,
|
|
brand TEXT,
|
|
rank REAL
|
|
) AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
SELECT
|
|
p.id,
|
|
p.barcode,
|
|
p.name,
|
|
p.brand,
|
|
ts_rank(to_tsvector('english', p.name || ' ' || COALESCE(p.brand, '')),
|
|
plainto_tsquery('english', search_query)) AS rank
|
|
FROM products p
|
|
WHERE to_tsvector('english', p.name || ' ' || COALESCE(p.brand, ''))
|
|
@@ plainto_tsquery('english', search_query)
|
|
ORDER BY rank DESC
|
|
LIMIT 20;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Usage:
|
|
-- SELECT * FROM search_products('chocolate');
|
|
```
|
|
|
|
---
|
|
|
|
## 📈 Example Queries
|
|
|
|
### List all inventory items with tags and units
|
|
|
|
```sql
|
|
SELECT
|
|
i.id,
|
|
i.name,
|
|
i.quantity,
|
|
u.abbreviation AS unit,
|
|
i.expiry_date,
|
|
ARRAY_AGG(t.name) AS tags,
|
|
p.brand,
|
|
p.image_url
|
|
FROM inventory_items i
|
|
LEFT JOIN units u ON i.unit_id = u.id
|
|
LEFT JOIN products p ON i.product_id = p.id
|
|
LEFT JOIN item_tags it ON i.id = it.item_id
|
|
LEFT JOIN tags t ON it.tag_id = t.id
|
|
GROUP BY i.id, u.abbreviation, p.brand, p.image_url
|
|
ORDER BY i.created_at DESC;
|
|
```
|
|
|
|
### Find items in fridge expiring soon
|
|
|
|
```sql
|
|
SELECT
|
|
i.name,
|
|
i.quantity,
|
|
u.abbreviation,
|
|
i.expiry_date,
|
|
i.expiry_date - CURRENT_DATE AS days_left
|
|
FROM inventory_items i
|
|
JOIN units u ON i.unit_id = u.id
|
|
JOIN item_tags it ON i.id = it.item_id
|
|
JOIN tags t ON it.tag_id = t.id
|
|
WHERE
|
|
t.name = 'Fridge'
|
|
AND i.expiry_date IS NOT NULL
|
|
AND i.expiry_date <= CURRENT_DATE + INTERVAL '7 days'
|
|
ORDER BY i.expiry_date ASC;
|
|
```
|
|
|
|
### Convert all items to base units
|
|
|
|
```sql
|
|
SELECT
|
|
i.name,
|
|
i.quantity,
|
|
u.abbreviation AS original_unit,
|
|
convert_unit(i.quantity, i.unit_id, bu.id) AS base_quantity,
|
|
bu.abbreviation AS base_unit
|
|
FROM inventory_items i
|
|
JOIN units u ON i.unit_id = u.id
|
|
LEFT JOIN units bu ON u.base_unit_id = bu.id OR (u.base_unit_id IS NULL AND u.id = bu.id);
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 Maintenance
|
|
|
|
### Vacuum & Analyze
|
|
|
|
```sql
|
|
-- Regular maintenance (run weekly)
|
|
VACUUM ANALYZE inventory_items;
|
|
VACUUM ANALYZE products;
|
|
VACUUM ANALYZE tags;
|
|
```
|
|
|
|
### Clean old cached products
|
|
|
|
```sql
|
|
-- Delete products not referenced by any items and older than 30 days
|
|
DELETE FROM products
|
|
WHERE id NOT IN (SELECT DISTINCT product_id FROM inventory_items WHERE product_id IS NOT NULL)
|
|
AND cached_at < NOW() - INTERVAL '30 days';
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Monitoring
|
|
|
|
### Table sizes
|
|
|
|
```sql
|
|
SELECT
|
|
schemaname,
|
|
tablename,
|
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
|
|
FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
|
|
```
|
|
|
|
### Index usage
|
|
|
|
```sql
|
|
SELECT
|
|
schemaname,
|
|
tablename,
|
|
indexname,
|
|
idx_scan AS scans,
|
|
pg_size_pretty(pg_relation_size(indexrelid)) AS size
|
|
FROM pg_stat_user_indexes
|
|
ORDER BY idx_scan DESC;
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 Migration Strategy
|
|
|
|
### Version 1 (Initial Schema)
|
|
|
|
```sql
|
|
-- migrations/001_initial_schema.sql
|
|
CREATE TABLE inventory_items (...);
|
|
CREATE TABLE products (...);
|
|
CREATE TABLE tags (...);
|
|
CREATE TABLE item_tags (...);
|
|
CREATE TABLE units (...);
|
|
```
|
|
|
|
### Version 2 (Seed Data)
|
|
|
|
```sql
|
|
-- migrations/002_seed_defaults.sql
|
|
INSERT INTO units (...) VALUES (...);
|
|
INSERT INTO tags (...) VALUES (...);
|
|
```
|
|
|
|
### Version 3 (RLS Policies)
|
|
|
|
```sql
|
|
-- migrations/003_rls_policies.sql
|
|
ALTER TABLE inventory_items ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY ...;
|
|
```
|
|
|
|
---
|
|
|
|
**Next:** [API Reference](./API.md)
|