Database Design
Overview
The VerticalFarm OS database is built on PostgreSQL (via Supabase) and designed to support a multi-tenant vertical farming management system with real-time capabilities, comprehensive audit trails, and scalable performance. The schema follows best practices for relational database design while leveraging PostgreSQL's advanced features.
Core Design Principles
1. Multi-Tenant Isolation
- Complete data isolation at the farm level
- Row Level Security (RLS) policies enforce access control
- No data leakage between tenants
2. Hierarchical Data Model
- Natural representation of physical farm structure
- Efficient querying of parent-child relationships
- Cascading operations where appropriate
3. Audit & Traceability
- All tables include
created_at
andupdated_at
timestamps - Soft deletes for critical data
- Comprehensive activity logging
4. Performance Optimization
- Strategic indexing on foreign keys and query patterns
- JSONB for flexible, schema-less data
- Materialized views for complex aggregations
5. Real-time Ready
- Tables designed for Supabase Realtime subscriptions
- Optimized for frequent updates
- Efficient change detection
Entity Relationship Diagram
erDiagram
user_profiles ||--o{ farms : manages
farms ||--o{ rows : contains
rows ||--o{ racks : contains
racks ||--o{ shelves : contains
shelves ||--o{ schedules : hosts
schedules ||--o{ scheduled_actions : triggers
schedules ||--o{ harvests : produces
farms ||--o{ device_assignments : has
rows ||--o{ device_assignments : has
racks ||--o{ device_assignments : has
shelves ||--o{ device_assignments : has
device_assignments ||--o{ sensor_readings : generates
device_assignments ||--o{ scheduled_actions : targets
species ||--o{ grow_recipes : has
grow_recipes ||--o{ schedules : uses
farms ||--o{ automation_rules : applies
device_assignments ||--o{ automation_rules : triggers
device_assignments ||--o{ automation_rules : actions
user_profiles {
uuid id PK
enum role
text name
jsonb preferences
timestamp created_at
timestamp updated_at
}
farms {
uuid id PK
text name UK
text location
uuid manager_id FK
numeric width
numeric depth
jsonb metadata
timestamp created_at
timestamp updated_at
}
device_assignments {
uuid id PK
uuid entity_id FK "polymorphic"
text entity_type
text device_id
text device_type
text friendly_name
jsonb configuration
uuid assigned_by FK
timestamp created_at
timestamp updated_at
}
sensor_readings {
bigserial id PK
uuid device_assignment_id FK
text reading_type
numeric value
text unit
jsonb metadata
timestamp timestamp
}
Schema Details
Core Tables
user_profiles
Links to Supabase Auth and stores additional user information.
CREATE TABLE public.user_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
role user_role NOT NULL DEFAULT 'operator',
name TEXT,
organization TEXT,
phone TEXT,
preferences JSONB DEFAULT '{}',
notification_settings JSONB DEFAULT '{"email": true, "sms": false, "push": true}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_user_profiles_role ON public.user_profiles(role);
CREATE INDEX idx_user_profiles_organization ON public.user_profiles(organization);
-- RLS Policies
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile"
ON public.user_profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.user_profiles FOR UPDATE
USING (auth.uid() = id);
farms
Top-level entity representing a vertical farm facility.
CREATE TABLE public.farms (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
location TEXT,
manager_id UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
organization_id UUID REFERENCES public.organizations(id),
-- Physical dimensions
width NUMERIC,
depth NUMERIC,
height NUMERIC,
unit_system TEXT DEFAULT 'metric', -- 'metric' or 'imperial'
-- Configuration
timezone TEXT DEFAULT 'UTC',
currency TEXT DEFAULT 'USD',
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
-- Status
status TEXT DEFAULT 'active', -- 'active', 'maintenance', 'inactive'
commissioned_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT farms_name_org_unique UNIQUE(name, organization_id)
);
-- Indexes
CREATE INDEX idx_farms_manager_id ON public.farms(manager_id);
CREATE INDEX idx_farms_organization_id ON public.farms(organization_id);
CREATE INDEX idx_farms_status ON public.farms(status);
CREATE INDEX idx_farms_tags ON public.farms USING GIN(tags);
-- RLS Policies
ALTER TABLE public.farms ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see own farms"
ON public.farms FOR SELECT
USING (
manager_id = auth.uid()
OR
id IN (
SELECT farm_id FROM public.farm_members
WHERE user_id = auth.uid()
)
);
Farm Hierarchy Tables
rows
Physical rows within a farm facility.
CREATE TABLE public.rows (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
farm_id UUID NOT NULL REFERENCES public.farms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL,
-- Physical layout
position_x NUMERIC,
position_y NUMERIC,
length NUMERIC,
width NUMERIC,
orientation TEXT, -- 'horizontal', 'vertical', 'diagonal'
-- Visual representation
color TEXT,
icon TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT rows_farm_name_unique UNIQUE(farm_id, name),
CONSTRAINT rows_farm_position_unique UNIQUE(farm_id, position)
);
-- Indexes
CREATE INDEX idx_rows_farm_id ON public.rows(farm_id);
CREATE INDEX idx_rows_position ON public.rows(position);
racks
Vertical growing structures within rows.
CREATE TABLE public.racks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
row_id UUID NOT NULL REFERENCES public.rows(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position_in_row INTEGER NOT NULL,
-- Physical specifications
width NUMERIC,
depth NUMERIC,
height NUMERIC,
max_shelves INTEGER,
shelf_height NUMERIC, -- Default height between shelves
-- Type and configuration
rack_type TEXT, -- 'static', 'mobile', 'rotating'
lighting_type TEXT, -- 'LED', 'fluorescent', 'HPS'
irrigation_type TEXT, -- 'drip', 'ebb-flow', 'NFT'
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT racks_row_name_unique UNIQUE(row_id, name),
CONSTRAINT racks_row_position_unique UNIQUE(row_id, position_in_row)
);
-- Indexes
CREATE INDEX idx_racks_row_id ON public.racks(row_id);
CREATE INDEX idx_racks_rack_type ON public.racks(rack_type);
shelves
Individual growing levels within racks.
CREATE TABLE public.shelves (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rack_id UUID NOT NULL REFERENCES public.racks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position_in_rack INTEGER NOT NULL,
-- Physical specifications
width NUMERIC,
depth NUMERIC,
usable_area NUMERIC, -- Actual growing area
max_weight NUMERIC,
-- Growing configuration
grow_medium TEXT, -- 'soil', 'hydroponic', 'aeroponic'
container_type TEXT, -- 'tray', 'pot', 'channel'
container_count INTEGER,
-- Current status
status TEXT DEFAULT 'available', -- 'available', 'occupied', 'maintenance'
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT shelves_rack_name_unique UNIQUE(rack_id, name),
CONSTRAINT shelves_rack_position_unique UNIQUE(rack_id, position_in_rack)
);
-- Indexes
CREATE INDEX idx_shelves_rack_id ON public.shelves(rack_id);
CREATE INDEX idx_shelves_status ON public.shelves(status);
Device Management
device_assignments
Flexible device assignment to any level of the farm hierarchy.
CREATE TABLE public.device_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Polymorphic association (only one should be set)
shelf_id UUID REFERENCES public.shelves(id) ON DELETE CASCADE,
rack_id UUID REFERENCES public.racks(id) ON DELETE CASCADE,
row_id UUID REFERENCES public.rows(id) ON DELETE CASCADE,
farm_id UUID REFERENCES public.farms(id) ON DELETE CASCADE,
-- Device identification
entity_id TEXT NOT NULL, -- External device ID (e.g., Home Assistant entity)
entity_type TEXT NOT NULL, -- 'sensor', 'switch', 'light', 'pump', 'fan'
friendly_name TEXT,
-- Device details
manufacturer TEXT,
model TEXT,
serial_number TEXT,
-- Configuration
configuration JSONB DEFAULT '{}',
calibration JSONB DEFAULT '{}',
-- Assignment tracking
assigned_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Status
status TEXT DEFAULT 'active', -- 'active', 'inactive', 'maintenance', 'error'
last_seen TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Ensure device is assigned to exactly one level
CONSTRAINT chk_device_assignment_level CHECK (
(shelf_id IS NOT NULL)::int +
(rack_id IS NOT NULL)::int +
(row_id IS NOT NULL)::int +
(farm_id IS NOT NULL)::int = 1
)
);
-- Indexes
CREATE INDEX idx_device_assignments_shelf_id ON public.device_assignments(shelf_id);
CREATE INDEX idx_device_assignments_rack_id ON public.device_assignments(rack_id);
CREATE INDEX idx_device_assignments_row_id ON public.device_assignments(row_id);
CREATE INDEX idx_device_assignments_farm_id ON public.device_assignments(farm_id);
CREATE INDEX idx_device_assignments_entity_id ON public.device_assignments(entity_id);
CREATE INDEX idx_device_assignments_entity_type ON public.device_assignments(entity_type);
CREATE INDEX idx_device_assignments_status ON public.device_assignments(status);
Sensor Data
sensor_readings
High-volume time-series data for sensor readings.
CREATE TABLE public.sensor_readings (
id BIGSERIAL PRIMARY KEY,
device_assignment_id UUID NOT NULL REFERENCES public.device_assignments(id) ON DELETE CASCADE,
-- Reading data
reading_type TEXT NOT NULL, -- 'temperature', 'humidity', 'ph', 'ec', 'co2', 'light'
value NUMERIC NOT NULL,
unit TEXT, -- 'celsius', 'fahrenheit', '%', 'ppm', 'lux'
-- Quality indicators
quality TEXT DEFAULT 'good', -- 'good', 'questionable', 'bad'
raw_value NUMERIC, -- Original value before calibration
-- Metadata
metadata JSONB DEFAULT '{}',
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for efficient querying
CREATE INDEX idx_sensor_readings_device_timestamp
ON public.sensor_readings(device_assignment_id, timestamp DESC);
CREATE INDEX idx_sensor_readings_timestamp
ON public.sensor_readings(timestamp DESC);
CREATE INDEX idx_sensor_readings_type_timestamp
ON public.sensor_readings(reading_type, timestamp DESC);
-- Partition by month for better performance (optional)
-- CREATE TABLE sensor_readings_2024_01 PARTITION OF sensor_readings
-- FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
Growing Operations
species
Plant species catalog.
CREATE TABLE public.species (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
scientific_name TEXT,
category TEXT, -- 'leafy_greens', 'herbs', 'vegetables', 'fruits'
-- Growing characteristics
grow_days_min INTEGER,
grow_days_max INTEGER,
difficulty TEXT, -- 'easy', 'medium', 'hard'
-- Environmental preferences
temp_min NUMERIC,
temp_max NUMERIC,
humidity_min NUMERIC,
humidity_max NUMERIC,
ph_min NUMERIC,
ph_max NUMERIC,
ec_min NUMERIC,
ec_max NUMERIC,
-- Light requirements
light_hours_min NUMERIC,
light_hours_max NUMERIC,
light_intensity_min NUMERIC, -- in PPFD
light_intensity_max NUMERIC,
-- Yield expectations
yield_per_sqm_min NUMERIC,
yield_per_sqm_max NUMERIC,
yield_unit TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_species_category ON public.species(category);
CREATE INDEX idx_species_name_trgm ON public.species USING gin(name gin_trgm_ops);
grow_recipes
Detailed growing protocols for species.
CREATE TABLE public.grow_recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
species_id UUID NOT NULL REFERENCES public.species(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT,
-- Growing timeline
grow_days INTEGER,
germination_days INTEGER,
vegetative_days INTEGER,
flowering_days INTEGER,
-- Environmental parameters
stages JSONB, -- Array of growth stages with specific parameters
-- Standard parameters (for simple recipes)
light_hours_per_day NUMERIC,
light_intensity NUMERIC,
day_temperature NUMERIC,
night_temperature NUMERIC,
humidity NUMERIC,
co2_ppm NUMERIC,
-- Irrigation
watering_frequency_hours NUMERIC,
water_amount_ml NUMERIC,
nutrient_recipe JSONB,
-- Expected results
expected_yield NUMERIC,
expected_quality TEXT,
-- Recipe metadata
author TEXT,
source TEXT,
verified BOOLEAN DEFAULT FALSE,
success_rate NUMERIC,
custom_parameters JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT grow_recipes_species_name_unique UNIQUE(species_id, name)
);
-- Indexes
CREATE INDEX idx_grow_recipes_species_id ON public.grow_recipes(species_id);
CREATE INDEX idx_grow_recipes_verified ON public.grow_recipes(verified);
schedules
Active growing schedules on shelves.
CREATE TABLE public.schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
shelf_id UUID NOT NULL REFERENCES public.shelves(id) ON DELETE CASCADE,
grow_recipe_id UUID NOT NULL REFERENCES public.grow_recipes(id) ON DELETE RESTRICT,
-- Timeline
start_date TIMESTAMP WITH TIME ZONE NOT NULL,
estimated_end_date TIMESTAMP WITH TIME ZONE,
actual_end_date TIMESTAMP WITH TIME ZONE,
-- Status tracking
status schedule_status NOT NULL DEFAULT 'planned',
current_stage TEXT,
days_elapsed INTEGER GENERATED ALWAYS AS (
EXTRACT(EPOCH FROM (COALESCE(actual_end_date, NOW()) - start_date)) / 86400
) STORED,
-- Batch information
batch_code TEXT,
seed_lot TEXT,
plant_count INTEGER,
-- Performance tracking
germination_rate NUMERIC,
survival_rate NUMERIC,
growth_rate_factor NUMERIC DEFAULT 1.0, -- Actual vs expected growth rate
notes TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_schedules_shelf_id ON public.schedules(shelf_id);
CREATE INDEX idx_schedules_grow_recipe_id ON public.schedules(grow_recipe_id);
CREATE INDEX idx_schedules_status ON public.schedules(status);
CREATE INDEX idx_schedules_start_date ON public.schedules(start_date);
CREATE INDEX idx_schedules_batch_code ON public.schedules(batch_code);
Automation
automation_rules
Conditional automation rules for farm operations.
CREATE TABLE public.automation_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
description TEXT,
-- Scope
farm_id UUID REFERENCES public.farms(id) ON DELETE CASCADE,
applies_to TEXT, -- 'farm', 'row', 'rack', 'shelf'
target_ids UUID[], -- Specific IDs if not farm-wide
-- Trigger configuration
trigger_type TEXT NOT NULL, -- 'sensor', 'schedule', 'manual', 'cascade'
trigger_source_device_id UUID REFERENCES public.device_assignments(id),
trigger_conditions JSONB, -- Complex condition logic
-- Simple triggers (for backward compatibility)
trigger_reading_type TEXT,
trigger_operator TEXT, -- '>', '<', '=', '>=', '<=', '!='
trigger_value NUMERIC,
-- Action configuration
action_type TEXT NOT NULL, -- 'device_control', 'notification', 'log', 'cascade'
action_target_device_id UUID REFERENCES public.device_assignments(id),
action_parameters JSONB,
-- Execution control
cooldown_minutes INTEGER DEFAULT 5, -- Minimum time between executions
max_executions_per_day INTEGER,
-- Scheduling
active_hours JSONB, -- Time windows when rule is active
active_days INTEGER[], -- Days of week (1-7)
-- Status
is_active BOOLEAN DEFAULT TRUE,
last_triggered TIMESTAMP WITH TIME ZONE,
trigger_count INTEGER DEFAULT 0,
created_by UUID REFERENCES public.user_profiles(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT automation_rules_farm_name_unique UNIQUE(farm_id, name)
);
-- Indexes
CREATE INDEX idx_automation_rules_farm_id ON public.automation_rules(farm_id);
CREATE INDEX idx_automation_rules_is_active ON public.automation_rules(is_active);
CREATE INDEX idx_automation_rules_trigger_type ON public.automation_rules(trigger_type);
CREATE INDEX idx_automation_rules_trigger_source ON public.automation_rules(trigger_source_device_id);
scheduled_actions
Pre-scheduled or triggered actions for devices.
CREATE TABLE public.scheduled_actions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Source of action
schedule_id UUID REFERENCES public.schedules(id) ON DELETE CASCADE,
automation_rule_id UUID REFERENCES public.automation_rules(id) ON DELETE CASCADE,
manual_trigger_by UUID REFERENCES public.user_profiles(id),
-- Target device
device_assignment_id UUID REFERENCES public.device_assignments(id) ON DELETE CASCADE,
-- Action details
action_type action_type NOT NULL,
parameters JSONB DEFAULT '{}',
-- Scheduling
execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
execution_window_minutes INTEGER DEFAULT 5, -- Acceptable delay
-- Execution tracking
status action_status NOT NULL DEFAULT 'pending',
executed_at TIMESTAMP WITH TIME ZONE,
execution_duration_ms INTEGER,
-- Retry logic
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
next_retry_at TIMESTAMP WITH TIME ZONE,
-- Error handling
error_message TEXT,
error_details JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_scheduled_actions_schedule_id ON public.scheduled_actions(schedule_id);
CREATE INDEX idx_scheduled_actions_device_assignment_id ON public.scheduled_actions(device_assignment_id);
CREATE INDEX idx_scheduled_actions_execution_time ON public.scheduled_actions(execution_time);
CREATE INDEX idx_scheduled_actions_status ON public.scheduled_actions(status);
CREATE INDEX idx_scheduled_actions_next_retry ON public.scheduled_actions(next_retry_at)
WHERE status = 'pending' AND next_retry_at IS NOT NULL;
Analytics & Reporting
harvests
Harvest records for yield tracking.
CREATE TABLE public.harvests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
schedule_id UUID NOT NULL REFERENCES public.schedules(id) ON DELETE RESTRICT,
shelf_id UUID NOT NULL REFERENCES public.shelves(id) ON DELETE RESTRICT,
-- Harvest data
harvest_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
harvest_type TEXT, -- 'final', 'partial', 'thinning'
-- Yield data
fresh_weight NUMERIC NOT NULL,
dry_weight NUMERIC,
marketable_weight NUMERIC,
waste_weight NUMERIC,
weight_unit TEXT NOT NULL DEFAULT 'grams',
-- Quality metrics
quality_grade TEXT, -- 'A', 'B', 'C', 'reject'
brix_level NUMERIC,
color_score NUMERIC,
size_uniformity NUMERIC,
defect_rate NUMERIC,
-- Economic data
market_price_per_unit NUMERIC,
total_value NUMERIC,
currency TEXT DEFAULT 'USD',
-- Labor tracking
harvest_duration_minutes INTEGER,
harvester_count INTEGER,
labor_cost NUMERIC,
notes TEXT,
photos JSONB, -- Array of photo URLs
metadata JSONB DEFAULT '{}',
created_by UUID REFERENCES public.user_profiles(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_harvests_schedule_id ON public.harvests(schedule_id);
CREATE INDEX idx_harvests_shelf_id ON public.harvests(shelf_id);
CREATE INDEX idx_harvests_harvest_date ON public.harvests(harvest_date);
CREATE INDEX idx_harvests_quality_grade ON public.harvests(quality_grade);
Advanced Features
Row Level Security (RLS)
-- Enable RLS on all tables
ALTER TABLE public.farms ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rows ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.racks ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.shelves ENABLE ROW LEVEL SECURITY;
-- Farm-level isolation policy
CREATE POLICY "farm_isolation" ON public.rows
FOR ALL
USING (
farm_id IN (
SELECT id FROM public.farms
WHERE manager_id = auth.uid()
OR id IN (
SELECT farm_id FROM public.farm_members
WHERE user_id = auth.uid()
)
)
);
-- Cascade the policy pattern to child tables
CREATE POLICY "rack_isolation" ON public.racks
FOR ALL
USING (
row_id IN (
SELECT id FROM public.rows
WHERE farm_id IN (
SELECT id FROM public.farms
WHERE manager_id = auth.uid()
OR id IN (
SELECT farm_id FROM public.farm_members
WHERE user_id = auth.uid()
)
)
)
);
Database Functions
-- Function to calculate farm health score
CREATE OR REPLACE FUNCTION calculate_farm_health_score(p_farm_id UUID)
RETURNS NUMERIC AS $$
DECLARE
v_score NUMERIC := 100;
v_active_alerts INTEGER;
v_device_offline INTEGER;
v_schedule_delays INTEGER;
BEGIN
-- Count active alerts
SELECT COUNT(*) INTO v_active_alerts
FROM alerts
WHERE farm_id = p_farm_id
AND status = 'active';
-- Count offline devices
SELECT COUNT(*) INTO v_device_offline
FROM device_assignments
WHERE farm_id = p_farm_id
AND last_seen < NOW() - INTERVAL '1 hour';
-- Count delayed schedules
SELECT COUNT(*) INTO v_schedule_delays
FROM schedules s
JOIN shelves sh ON s.shelf_id = sh.id
JOIN racks r ON sh.rack_id = r.id
JOIN rows ro ON r.row_id = ro.id
WHERE ro.farm_id = p_farm_id
AND s.status = 'active'
AND s.estimated_end_date < NOW();
-- Calculate score
v_score := v_score - (v_active_alerts * 5);
v_score := v_score - (v_device_offline * 3);
v_score := v_score - (v_schedule_delays * 2);
RETURN GREATEST(0, v_score);
END;
$$ LANGUAGE plpgsql;
-- Function to get latest sensor readings for a shelf
CREATE OR REPLACE FUNCTION get_latest_shelf_sensors(p_shelf_id UUID)
RETURNS TABLE (
reading_type TEXT,
value NUMERIC,
unit TEXT,
timestamp TIMESTAMP WITH TIME ZONE
) AS $$
BEGIN
RETURN QUERY
WITH latest_readings AS (
SELECT
sr.reading_type,
sr.value,
sr.unit,
sr.timestamp,
ROW_NUMBER() OVER (PARTITION BY sr.reading_type ORDER BY sr.timestamp DESC) as rn
FROM sensor_readings sr
JOIN device_assignments da ON sr.device_assignment_id = da.id
WHERE da.shelf_id = p_shelf_id
AND sr.timestamp > NOW() - INTERVAL '1 hour'
)
SELECT
lr.reading_type,
lr.value,
lr.unit,
lr.timestamp
FROM latest_readings lr
WHERE lr.rn = 1;
END;
$$ LANGUAGE plpgsql;
Triggers
-- Update timestamp trigger
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to all tables
CREATE TRIGGER update_farms_updated_at BEFORE UPDATE ON public.farms
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_rows_updated_at BEFORE UPDATE ON public.rows
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Add more triggers for other tables...
-- Shelf status update trigger
CREATE OR REPLACE FUNCTION update_shelf_status()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
-- Mark shelf as occupied when schedule starts
UPDATE shelves
SET status = 'occupied'
WHERE id = NEW.shelf_id;
ELSIF TG_OP = 'UPDATE' THEN
-- Mark shelf as available when schedule completes
IF NEW.status = 'completed' AND OLD.status != 'completed' THEN
UPDATE shelves
SET status = 'available'
WHERE id = NEW.shelf_id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_shelf_on_schedule
AFTER INSERT OR UPDATE ON public.schedules
FOR EACH ROW EXECUTE FUNCTION update_shelf_status();
Materialized Views
-- Farm statistics view
CREATE MATERIALIZED VIEW farm_statistics AS
SELECT
f.id as farm_id,
f.name as farm_name,
COUNT(DISTINCT r.id) as row_count,
COUNT(DISTINCT ra.id) as rack_count,
COUNT(DISTINCT s.id) as shelf_count,
COUNT(DISTINCT da.id) as device_count,
COUNT(DISTINCT CASE WHEN sc.status = 'active' THEN sc.id END) as active_schedules,
AVG(h.marketable_weight) as avg_yield,
SUM(h.total_value) as total_revenue
FROM farms f
LEFT JOIN rows r ON f.id = r.farm_id
LEFT JOIN racks ra ON r.id = ra.row_id
LEFT JOIN shelves s ON ra.id = s.rack_id
LEFT JOIN device_assignments da ON f.id = da.farm_id
LEFT JOIN schedules sc ON s.id = sc.shelf_id
LEFT JOIN harvests h ON sc.id = h.schedule_id
GROUP BY f.id, f.name;
-- Refresh periodically
CREATE INDEX idx_farm_statistics_farm_id ON farm_statistics(farm_id);
-- Sensor aggregates view
CREATE MATERIALIZED VIEW hourly_sensor_aggregates AS
SELECT
device_assignment_id,
reading_type,
DATE_TRUNC('hour', timestamp) as hour,
AVG(value) as avg_value,
MIN(value) as min_value,
MAX(value) as max_value,
COUNT(*) as reading_count
FROM sensor_readings
WHERE timestamp > NOW() - INTERVAL '7 days'
GROUP BY device_assignment_id, reading_type, DATE_TRUNC('hour', timestamp);
CREATE INDEX idx_hourly_sensor_device_hour
ON hourly_sensor_aggregates(device_assignment_id, hour);
Performance Optimization
Indexing Strategy
-- Composite indexes for common query patterns
CREATE INDEX idx_sensor_readings_device_type_time
ON sensor_readings(device_assignment_id, reading_type, timestamp DESC);
CREATE INDEX idx_schedules_shelf_status_date
ON schedules(shelf_id, status, start_date);
CREATE INDEX idx_device_assignments_farm_type_status
ON device_assignments(farm_id, entity_type, status);
-- Partial indexes for filtered queries
CREATE INDEX idx_active_schedules
ON schedules(shelf_id, start_date)
WHERE status = 'active';
CREATE INDEX idx_pending_actions
ON scheduled_actions(execution_time)
WHERE status = 'pending';
-- Text search indexes
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_farms_name_search
ON farms USING gin(name gin_trgm_ops);
CREATE INDEX idx_species_name_search
ON species USING gin(name gin_trgm_ops);
Partitioning Strategy
-- Partition sensor_readings by month
CREATE TABLE sensor_readings_2024_01
PARTITION OF sensor_readings
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE sensor_readings_2024_02
PARTITION OF sensor_readings
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- Automated partition creation
CREATE OR REPLACE FUNCTION create_monthly_partition()
RETURNS void AS $$
DECLARE
partition_date DATE;
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
partition_date := DATE_TRUNC('month', NOW() + INTERVAL '1 month');
partition_name := 'sensor_readings_' || TO_CHAR(partition_date, 'YYYY_MM');
start_date := partition_date;
end_date := partition_date + INTERVAL '1 month';
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF sensor_readings FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date);
END;
$$ LANGUAGE plpgsql;
-- Schedule monthly execution
-- Run this function via pg_cron or external scheduler
Migration Strategy
Version Control
-- Migration tracking table
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
applied_by TEXT DEFAULT current_user
);
-- Example migration
BEGIN;
-- Check if migration already applied
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM schema_migrations WHERE version = '001_initial_schema') THEN
-- Run migration
CREATE TABLE public.farms (...);
-- ... more DDL
-- Record migration
INSERT INTO schema_migrations (version) VALUES ('001_initial_schema');
END IF;
END $$;
COMMIT;
Backup Strategy
-- Regular backup via pg_dump
-- pg_dump -h localhost -U postgres -d verticalfarm -f backup_$(date +%Y%m%d).sql
-- Point-in-time recovery setup
-- Enable in postgresql.conf:
-- wal_level = replica
-- archive_mode = on
-- archive_command = 'cp %p /backup/archive/%f'
-- Logical replication for real-time backup
CREATE PUBLICATION verticalfarm_pub FOR ALL TABLES;
Security Considerations
Data Encryption
-- Encrypt sensitive columns
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Example: Encrypt API keys
ALTER TABLE integrations
ADD COLUMN api_key_encrypted BYTEA;
UPDATE integrations
SET api_key_encrypted = pgp_sym_encrypt(api_key, 'encryption_key');
-- Decrypt when needed
SELECT pgp_sym_decrypt(api_key_encrypted, 'encryption_key') as api_key
FROM integrations;
Audit Logging
-- Audit log table
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
operation TEXT NOT NULL,
user_id UUID,
record_id UUID,
old_data JSONB,
new_data JSONB,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Generic audit trigger
CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, operation, user_id, record_id, old_data, new_data)
VALUES (
TG_TABLE_NAME,
TG_OP,
auth.uid(),
COALESCE(NEW.id, OLD.id),
to_jsonb(OLD),
to_jsonb(NEW)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to sensitive tables
CREATE TRIGGER audit_farms
AFTER INSERT OR UPDATE OR DELETE ON farms
FOR EACH ROW EXECUTE FUNCTION audit_trigger();
Summary
The database design provides: - Scalability through partitioning and indexing - Performance via materialized views and optimized queries - Security with RLS and encryption - Flexibility using JSONB for extensible data - Reliability through constraints and triggers - Maintainability with clear structure and documentation
This schema supports all current requirements while allowing for future expansion and optimization.
For database-specific questions or schema changes, consult the database team or submit migration proposals for review.