🌙 Crescent Framework
Framework web moderno e performático construído em Lua, Luvit e MySQL.
🚀 Comece Agora!
Baixe o Crescent Starter e comece a desenvolver em minutos:
📦 Download crescent-starter.zipO que é Crescent?
Crescent é um framework web completo que combina a performance do Lua/LuaJIT com uma arquitetura moderna e modular inspirada em frameworks como NestJS e Laravel. Foi projetado para criar APIs REST e aplicações web de alto desempenho com código limpo e organizado.
🌟 Principais Características
- ⚡ Performance: Built on LuaJIT + libuv (Luvit) for blazing fast execution
- 🎯 Modular: Organize código em módulos independentes e reutilizáveis
- 🗄️ ORM ActiveRecord: Interaja com banco de dados MySQL de forma elegante
- 🔄 Migrations: Sistema completo de versionamento de schema
- 🛠️ CLI Poderoso: Geração automática de código (como Artisan do Laravel)
- 🔐 Segurança: Middleware de segurança, validações e hash de senhas PBKDF2
- ✅ Testes: Biblioteca completa de assertions para testes automatizados
- 📦 Pronto para Produção: Configuração NGINX e systemd incluídas
📦 Instalação Rápida
Método 1: Via Lit Package Manager
# Instale o framework via Lit
lit install daniel-m-tfs/crescent-framework
# Clone o starter template
git clone https://github.com/daniel-m-tfs/crescent-starter.git meu-projeto
cd meu-projeto
# Configure e inicie
cp .env.example .env
nano .env
luvit app.lua
Método 2: CLI (Criar Novo Projeto)
# Rodando de dentro do diretório do Crescent Framework
# (a instalação global do CLI ainda não funciona de qualquer diretório —
# ver Troubleshooting)
luvit crescent-cli.lua new meu-projeto
cd meu-projeto
cp .env.example .env
nano .env
luvit app.lua
📚 Dependências
Requisitos do Sistema
- Luvit 2.18+: Runtime Lua assíncrono
- Lit 3.8+: Gerenciador de pacotes
- MySQL 5.7+ ou MariaDB 10+: Banco de dados
- Linux/macOS: Sistema operacional (Windows via WSL)
Instalando Luvit e Lit
# macOS via Homebrew
brew install luvit
# Linux - Download manual
curl -L https://github.com/luvit/lit/raw/master/get-lit.sh | sh
Dependências do Framework
O Crescent Framework automaticamente inclui:
luvit/luvit@2.18.1- Runtime baseopenssl(vialua-openssl, já embutido no binário do Luvit — confirme
com luvit -v) - usado por crescent.utils.hash (PBKDF2) e, quando disponível, por crescent.utils.jwt para HMAC-SHA256
O driver MySQL não é um pacote Lit — é um módulo LuaRocks, instale separadamente:
luarocks install luasql-mysql
Sem ele, o framework roda em modo mock (avisa no console e não conecta a um MySQL real), então dá pra seguir o resto do tutorial sem instalar nada ainda.
📁 Estrutura do Projeto
crescent-starter/
├── app.lua # 🚀 Arquivo principal da aplicação
├── bootstrap.lua # 🔧 Bootstrap do framework
├── crescent-cli.lua # 🛠️ CLI para geração de código
├── .env # 🔐 Variáveis de ambiente (não versionar!)
├── .env.example # 📝 Template de variáveis
├── config/
│ ├── development.lua # ⚙️ Config de desenvolvimento
│ ├── production.lua # ⚙️ Config de produção
│ ├── nginx.conf # 🌐 Configuração NGINX
│ └── crescent.service # 🔄 Systemd service
├── crescent/ # 📦 Core do framework
│ ├── init.lua
│ ├── server.lua
│ ├── core/
│ │ ├── context.lua # Contexto HTTP (req/res)
│ │ ├── request.lua # Request object
│ │ ├── response.lua # Response object
│ │ └── router.lua # Sistema de rotas
│ ├── database/
│ │ ├── model.lua # ORM ActiveRecord
│ │ ├── query_builder.lua # Query Builder
│ │ ├── mysql.lua # Driver MySQL
│ │ └── migrate.lua # Sistema de migrations
│ ├── middleware/
│ │ ├── auth.lua # Autenticação
│ │ ├── cors.lua # CORS
│ │ ├── logger.lua # Logging
│ │ └── security.lua # Segurança
│ └── utils/
│ ├── env.lua # Variáveis de ambiente
│ ├── hash.lua # 🔐 Hash de senhas PBKDF2
│ ├── tests.lua # ✅ Biblioteca de testes
│ ├── headers.lua # HTTP headers
│ ├── path.lua # Path utilities
│ └── string.lua # String utilities
├── src/ # 📝 Seu código (módulos)
│ └── users/ # Exemplo de módulo
│ ├── init.lua # Registrador do módulo
│ ├── controllers/
│ │ └── users.lua
│ ├── services/
│ │ └── users.lua
│ ├── models/
│ │ └── users.lua
│ └── routes/
│ └── users.lua
├── migrations/ # 🔄 Database migrations
│ └── 20260108230701_create_users_table.lua
└── tests/ # ✅ Testes automatizados
└── test-*.lua
🎯 Convenções de Diretórios
src/: Todos os seus módulos de negóciocrescent/: Core do framework (não modificar)config/: Arquivos de configuraçãomigrations/: Versionamento do banco de dadostests/: Testes automatizados
🔧 Configuração Inicial
1. Variáveis de Ambiente (.env)
# Ambiente
APP_ENV=development
# Servidor
APP_HOST=0.0.0.0
APP_PORT=8080
# Banco de Dados
DB_HOST=localhost
DB_PORT=3306
DB_NAME=meu_banco
DB_USER=root
DB_PASSWORD=senha_segura
2. Testar Conexão MySQL
-- teste-conexao.lua
local MySQL = require('crescent.database.mysql')
MySQL.test()
luvit teste-conexao.lua
3. Criar Primeira Migration
luvit crescent-cli make:migration create_products_table
4. Executar Migrations
luvit crescent-cli migrate
🎮 Primeiro Módulo
Crie um módulo CRUD completo com um único comando:
luvit crescent-cli make:module Product
Isso cria:
- ✅ Controller (
src/product/controllers/product.lua) - ✅ Service (
src/product/services/product.lua) - ✅ Model (
src/product/models/product.lua) - ✅ Routes (
src/product/routes/product.lua) - ✅ Module Init (
src/product/init.lua)
Registrar Módulo no app.lua
-- app.lua
local Crescent = require('crescent')
local app = Crescent.new()
-- Registra módulo Product
local ProductModule = require("src.product")
ProductModule.register(app)
-- app:listen(port, host) não recebe callback; "listening" já é
-- impresso pelo próprio framework
app:listen(8080)
🚀 Iniciando o Servidor
Modo Desenvolvimento
luvit app.lua
Ou use o CLI:
luvit crescent-cli server
Acessar API
# Listar produtos
curl http://localhost:8080/product
# Criar produto
curl -X POST http://localhost:8080/product \
-H "Content-Type: application/json" \
-d '{"name":"Notebook","price":2500}'
# Buscar por ID
curl http://localhost:8080/product/1
# Atualizar
curl -X PUT http://localhost:8080/product/1 \
-H "Content-Type: application/json" \
-d '{"name":"Notebook Dell","price":2800}'
# Deletar
curl -X DELETE http://localhost:8080/product/1
🧪 Executando Testes
# Rodar todos os testes
luvit crescent-cli test
# Rodar um arquivo de teste específico (substitua pelo nome real do seu teste)
luvit tests/test-product.lua
📖 Próximos Passos
Agora que você tem um projeto rodando, explore:
- CLI - Aprenda todos os comandos disponíveis
- Core Concepts - Rotas, Controllers, Services
- Database & ORM - Modelos, relações, migrations
- Utilities - Testes, hash, helpers
- Deployment - Deploy em produção
💡 Dicas Úteis
Hot Reload (Desenvolvimento)
Use nodemon ou entr para reload automático:
# Com entr
find . -name "*.lua" | entr -r luvit app.lua
Debug
-- Use p() para debug (pretty-print)
p(user) -- Imprime tabela formatada
p(ctx.body)
Performance
-- Use LuaJIT JIT compilation
-- Já habilitado por padrão no Luvit
🆘 Troubleshooting
Erro: "Module not found"
# Instale dependências
lit install
Erro: "MySQL connection failed"
- Verifique se MySQL está rodando:
mysql.server status - Teste credenciais:
mysql -u root -p - Confira
.env:DB_HOST,DB_USER,DB_PASSWORD
Porta já em uso
# Mate processo na porta 8080
lsof -ti:8080 | xargs kill -9
# Ou mude a porta no .env
APP_PORT=3000
CLI global (crescent) não encontrado ou dá erro de módulo
A instalação global do CLI (comando crescent, via install.sh) hoje só funciona corretamente quando rodada de dentro do diretório do Crescent Framework — crescent-cli.lua resolve require("crescent.utils....") relativo ao diretório atual, e não ajusta package.path como o bootstrap.lua de um projeto faz. Rodando de outro diretório, use o caminho completo: luvit /caminho/para/Crescent\ Framework/crescent-cli.lua <comando>.
📚 Recursos Adicionais
🛣️ Rotas
Entenda os principais componentes da arquitetura Crescent.
Rotas definem os endpoints da sua API e conectam URLs aos controllers.
Definindo Rotas Básicas
-- app.lua
local Crescent = require('crescent')
local app = Crescent.new()
-- GET
app:get('/hello', function(ctx)
return ctx.json(200, { message = "Hello World" })
end)
-- POST
app:post('/users', function(ctx)
local body = ctx.body
return ctx.json(201, body)
end)
-- PUT
app:put('/users/{id}', function(ctx)
local id = ctx.params.id
return ctx.json(200, { id = id })
end)
-- DELETE
app:delete('/users/{id}', function(ctx)
local id = ctx.params.id
return ctx.no_content()
end)
app:listen(8080)
Parâmetros de Rota
-- Parâmetro único
app:get('/users/{id}', function(ctx)
local id = ctx.params.id
return ctx.json(200, { userId = id })
end)
-- Múltiplos parâmetros
app:get('/posts/{postId}/comments/{commentId}', function(ctx)
local postId = ctx.params.postId
local commentId = ctx.params.commentId
return ctx.json(200, { postId = postId, commentId = commentId })
end)
Query Parameters
app:get('/search', function(ctx)
local query = ctx.query.q
local page = ctx.query.page or 1
local limit = ctx.query.limit or 10
return ctx.json(200, {
query = query,
page = tonumber(page),
limit = tonumber(limit)
})
end)
-- GET /search?q=lua&page=2&limit=20
Request Body
app:post('/users', function(ctx)
local body = ctx.body
-- Acessar campos
local name = body.name
local email = body.email
return ctx.json(201, {
name = name,
email = email
})
end)
Organização em Arquivos
-- src/users/routes/users.lua
local controller = require("src.users.controllers.users")
return function(app, prefix)
prefix = prefix or "/users"
app:get(prefix, function(ctx)
return controller:index(ctx)
end)
app:get(prefix .. "/{id}", function(ctx)
return controller:show(ctx)
end)
app:post(prefix, function(ctx)
return controller:create(ctx)
end)
app:put(prefix .. "/{id}", function(ctx)
return controller:update(ctx)
end)
app:delete(prefix .. "/{id}", function(ctx)
return controller:delete(ctx)
end)
end
Registrar Rotas no App
-- app.lua
local userRoutes = require("src.users.routes.users")
userRoutes(app, "/api/users")
🎮 Controllers
Controllers recebem requisições HTTP e retornam respostas. Devem ser finos e delegar lógica para services.
Estrutura Básica
-- src/users/controllers/users.lua
local service = require("src.users.services.users")
local UsersController = {}
function UsersController:index(ctx)
local users = service:getAll()
return ctx.json(200, users)
end
function UsersController:show(ctx)
local id = ctx.params.id
local user = service:getById(id)
if user then
return ctx.json(200, user)
end
return ctx.json(404, { error = "User not found" })
end
function UsersController:create(ctx)
local body = ctx.body or {}
-- Validação básica
if not body.name or not body.email then
return ctx.json(400, { error = "Name and email are required" })
end
local user = service:create(body)
return ctx.json(201, user)
end
function UsersController:update(ctx)
local id = ctx.params.id
local body = ctx.body or {}
local user = service:update(id, body)
if user then
return ctx.json(200, user)
end
return ctx.json(404, { error = "User not found" })
end
function UsersController:delete(ctx)
local id = ctx.params.id
local success = service:delete(id)
if success then
return ctx.no_content()
end
return ctx.json(404, { error = "User not found" })
end
return UsersController
Context Object (ctx)
O objeto ctx contém toda informação da requisição:
function Controller:example(ctx)
-- Parâmetros de rota
local id = ctx.params.id
-- Query parameters
local page = ctx.query.page
-- Request body
local body = ctx.body
-- Headers
local auth = ctx.headers['authorization']
-- Method
local method = ctx.method -- GET, POST, etc
-- Path
local path = ctx.path -- /users/123
-- Response helpers
return ctx.json(200, data)
return ctx.text(200, "Hello")
return ctx.html(200, "<h1>Hello</h1>")
return ctx.no_content() -- 204
return ctx.redirect("/new-url", 302) -- (location, status) — nessa ordem
end
Validação no Controller
function UsersController:create(ctx)
local body = ctx.body or {}
-- Validação manual
local errors = {}
if not body.name or body.name == "" then
table.insert(errors, "Name is required")
end
if not body.email or not string.match(body.email, ".+@.+%.%w+") then
table.insert(errors, "Valid email is required")
end
if #errors > 0 then
return ctx.json(422, { errors = errors })
end
-- Criar usuário
local user = service:create(body)
return ctx.json(201, user)
end
Error Handling
function UsersController:create(ctx)
local success, result = pcall(function()
return service:create(ctx.body)
end)
if not success then
-- Log error
print("Error creating user:", result)
return ctx.json(500, {
error = "Internal server error",
message = result
})
end
return ctx.json(201, result)
end
⚙️ Services
Services contêm a lógica de negócio da aplicação. Devem ser independentes de HTTP.
Estrutura Básica
-- src/users/services/users.lua
local UsersService = {}
local User = require("src.users.models.user")
function UsersService:getAll()
return User:all()
end
function UsersService:getById(id)
return User:find(id)
end
function UsersService:create(data)
-- Validação adicional
if not data.name or #data.name < 3 then
error("Name must be at least 3 characters")
end
return User:create(data)
end
function UsersService:update(id, data)
local user = User:find(id)
if not user then
return nil
end
user:update(data)
return user
end
function UsersService:delete(id)
local user = User:find(id)
if not user then
return false
end
user:delete()
return true
end
return UsersService
Lógica de Negócio Complexa
-- src/orders/services/orders.lua
local OrdersService = {}
local Order = require("src.orders.models.order")
local OrderItem = require("src.orders.models.order_item")
local Product = require("src.products.models.product")
local User = require("src.users.models.user")
function OrdersService:createOrder(userId, items)
-- Validar usuário
local user = User:find(userId)
if not user then
error("User not found")
end
-- Validar produtos e calcular total
local total = 0
local validatedItems = {}
for _, item in ipairs(items) do
local product = Product:find(item.productId)
if not product then
error("Product " .. item.productId .. " not found")
end
if product.stock < item.quantity then
error("Insufficient stock for " .. product.name)
end
local subtotal = product.price * item.quantity
total = total + subtotal
table.insert(validatedItems, {
product_id = product.id,
quantity = item.quantity,
price = product.price,
subtotal = subtotal
})
end
-- Criar pedido
local order = Order:create({
user_id = userId,
total = total,
status = "pending"
})
-- Adicionar itens ao pedido (order_items é uma tabela separada — o Model
-- não tem um `addItem`/`items` prontos, então isso é feito na mão aqui;
-- se quiser algo reutilizável, defina um método customizado na classe
-- Order, como `function Order:isLowStock()` é mostrado em database.md)
for _, item in ipairs(validatedItems) do
OrderItem:create({
order_id = order.id,
product_id = item.product_id,
quantity = item.quantity,
price = item.price
})
-- Atualizar estoque
local product = Product:find(item.product_id)
product:update({
stock = product.stock - item.quantity
})
end
return order
end
function OrdersService:cancelOrder(orderId)
local order = Order:find(orderId)
if not order then
error("Order not found")
end
if order.status ~= "pending" then
error("Only pending orders can be cancelled")
end
-- Devolver produtos ao estoque
local items = OrderItem:where("order_id", order.id):get()
for _, item in ipairs(items) do
local product = Product:find(item.product_id)
product:update({
stock = product.stock + item.quantity
})
end
-- Atualizar status
order:update({ status = "cancelled" })
return order
end
return OrdersService
Services com Transações
function OrdersService:processPayment(orderId, paymentData)
local db = require('crescent.database.mysql')
-- Iniciar transação
db:query("START TRANSACTION")
local success, err = pcall(function()
local order = Order:find(orderId)
if not order then
error("Order not found")
end
-- Processar pagamento (API externa)
local paymentResult = PaymentGateway:charge(paymentData)
if not paymentResult.success then
error("Payment failed: " .. paymentResult.error)
end
-- Atualizar pedido
order:update({
status = "paid",
payment_id = paymentResult.id
})
-- Enviar email de confirmação
EmailService:sendOrderConfirmation(order)
end)
if success then
db:query("COMMIT")
return true
else
db:query("ROLLBACK")
error(err)
end
end
⚠️ Isso não é uma transação atômica de verdade hoje. crescent/database/mysql.lua pega uma conexão do pool a cada chamada de query() e devolve ao final dela — não há garantia de que START TRANSACTION, as queries do meio e o COMMIT/ROLLBACK rodem na mesma conexão. Trate como limitação conhecida (ver Database & ORM) até o driver expor uma conexão dedicada por transação.
💾 Models (Básico)
Models representam dados e interagem com o banco via ORM ActiveRecord.
Definição Básica
-- src/users/models/user.lua
local Model = require("crescent.database.model")
local User = Model:extend({
table = "users",
primary_key = "id",
timestamps = true,
fillable = {
"name",
"email",
"password"
},
hidden = {
"password"
},
validates = {
name = { required = true, min_length = 3, max_length = 100 },
email = { required = true, email = true, unique = true },
password = { required = true, min_length = 6 }
}
})
return User
Operações CRUD
-- CREATE
local user = User:create({
name = "John Doe",
email = "john@example.com",
password = "hashed_password"
})
-- READ
local user = User:find(1)
local users = User:all()
local user = User:where("email", "john@example.com"):first()
-- UPDATE
user:update({name = "Jane Doe"})
-- DELETE
user:delete()
Para mais detalhes sobre Models, veja Database & ORM.
🔒 Middleware
Middleware processa requisições antes que cheguem aos controllers. crescent/middleware/{auth,logger,cors,security,static}.lua já trazem implementações prontas — normalmente você só precisa registrá-las com app:use(...), sem reescrever nada.
⚠️ Middleware no Crescent é sempre global. app:use(middleware) registra a função pra rodar em toda requisição — não existe app:get(path, middleware, handler) nem middleware escopado por grupo de rotas (Server:group(prefix, fn) só agrupa prefixo de path, não aplica middleware). Se precisar proteger só algumas rotas, ou você escreve um middleware que confere ctx.path e deixa passar (return next()) pras rotas públicas, ou faz a checagem manualmente dentro do handler daquela rota específica.
Middleware de Autenticação (JWT pronto)
-- app.lua
local auth = require('crescent.middleware.auth')
-- Protege TODAS as rotas registradas depois desta linha
app:use(auth.jwt({ secret = env.get("JWT_SECRET") }))
auth.jwt(options) verifica o header Authorization: Bearer <token>, valida o JWT e guarda o payload em ctx.state.jwt_payload / ctx.state.user (não ctx.user). auth.protected(options) é um atalho pra auth.jwt(options). Ver crescent/middleware/auth.lua pra bearer, basic e api_key, e Utilities para a API completa.
Protegendo só algumas rotas
Como middleware é sempre global, pra proteger só um prefixo de rotas escreva um middleware que decide com base em ctx.path:
local jwt_middleware = auth.jwt({ secret = env.get("JWT_SECRET") })
app:use(function(ctx, next)
if ctx.path:match("^/admin") then
return jwt_middleware(ctx, next)
end
return next()
end)
Middleware de Logging
-- app.lua
local logger = require('crescent.middleware.logger')
app:use(logger.basic()) -- uma linha por requisição
-- ou
app:use(logger.detailed()) -- log completo (headers, query, params)
Middleware CORS
-- app.lua
local cors = require('crescent.middleware.cors')
app:use(cors.create({
origin = "*",
methods = "GET,POST,PUT,PATCH,DELETE,OPTIONS",
headers = "Content-Type, Authorization",
credentials = false
}))
-- ou o preset permissivo de desenvolvimento
app:use(cors.default())
Headers de resposta (CORS ou qualquer outro) sempre passam por ctx.res:setHeader(nome, valor) — ctx.headers é só a tabela de headers da requisição (somente leitura, na prática) e escrever nela não afeta a resposta enviada.
Ordem dos Middlewares
-- app.lua
local cors = require('crescent.middleware.cors')
local logger = require('crescent.middleware.logger')
local auth = require('crescent.middleware.auth')
local env = require('crescent.utils.env')
-- Ordem importa! Cada app:use() roda antes das rotas, na ordem registrada
app:use(cors.default()) -- 1. CORS primeiro
app:use(logger.basic()) -- 2. Logging
app:use(auth.jwt({ secret = env.get("JWT_SECRET") })) -- 3. Auth por último
-- Rotas
app:get('/api/users', function(ctx)
return usersController:index(ctx)
end)
🏗️ Módulos
Módulos agrupam funcionalidades relacionadas (controllers, services, models, routes).
Estrutura de Módulo
src/users/
├── init.lua # Registrador do módulo
├── controllers/
│ └── users.lua
├── services/
│ └── users.lua
├── models/
│ └── user.lua
└── routes/
└── users.lua
Module Init
-- src/users/init.lua
local Module = {}
function Module.register(app)
-- Registrar rotas
local routes = require("src.users.routes.users")
routes(app, "/api/users")
print("✓ Módulo Users carregado")
end
return Module
Registrar no App
-- app.lua
local Crescent = require('crescent')
local app = Crescent.new()
-- Registrar módulos
local UsersModule = require("src.users")
local ProductsModule = require("src.products")
UsersModule.register(app)
ProductsModule.register(app)
app:listen(8080)
Gerar Módulo via CLI
luvit crescent-cli make:module Product
Cria toda a estrutura automaticamente!
📊 Fluxo de Requisição
Cliente HTTP
↓
[Middleware CORS]
↓
[Middleware Logger]
↓
[Middleware Auth]
↓
[Router] → Encontra rota
↓
[Controller] → Recebe requisição
↓
[Service] → Lógica de negócio
↓
[Model/ORM] → Banco de dados
↓
[Service] ← Retorna dados
↓
[Controller] ← Formata resposta
↓
Cliente HTTP ← JSON response
💡 Boas Práticas
Controllers
- ✅ Mantenha finos (thin controllers)
- ✅ Delegue lógica para services
- ✅ Valide entrada básica
- ✅ Trate erros com try/catch (pcall)
- ✅ Retorne status codes apropriados
Services
- ✅ Lógica de negócio aqui
- ✅ Independente de HTTP
- ✅ Reutilizável entre controllers
- ✅ Use transações quando necessário
- ✅ Valide regras de negócio
Rotas
- ✅ Use padrões RESTful
- ✅ Agrupe por prefixo (
/api/v1) - ✅ Organize em arquivos separados
- ✅ Use nomes descritivos
Middleware
- ✅ Ordem importa
- ✅ CORS primeiro
- ✅ Auth/validação depois
- ✅ Use
next()para continuar
🧪 Testando Componentes
-- tests/test-users.lua
local tests = require('crescent.utils.tests')
local UsersService = require('src.users.services.users')
local userTests = {
testCreate = function()
local user = UsersService:create({
name = "Test User",
email = "test@example.com"
})
tests.assertNotNil(user)
tests.assertEquals(user.name, "Test User")
end,
testValidation = function()
tests.assertError(function()
UsersService:create({ name = "AB" }) -- Too short
end, "at least 3 characters")
end
}
tests.runSuite("Users Service Tests", userTests)
📖 Próximas Seções
- Database & ORM - Models em detalhes
- Utilities - Testes e helpers
- CLI - Geradores de código
🚀 Configuração do Banco
Sistema de ORM ActiveRecord, migrations e query builder.
.env
DB_HOST=localhost
DB_PORT=3306
DB_NAME=meu_banco
DB_USER=root
DB_PASSWORD=senha123
Testar Conexão
local MySQL = require('crescent.database.mysql')
MySQL.test()
💾 Models (ORM ActiveRecord)
Definição Completa
-- src/products/models/product.lua
local Model = require("crescent.database.model")
local Product = Model:extend({
-- Nome da tabela
table = "products",
-- Chave primária (padrão: "id")
primary_key = "id",
-- Timestamps automáticos (created_at, updated_at)
timestamps = true,
-- Soft delete: em vez de DELETE, escreve deleted_at e faz UPDATE
soft_deletes = false,
-- Campos que podem ser preenchidos em massa
fillable = {
"name",
"description",
"price",
"stock",
"category_id"
},
-- Campos escondidos em toArray()/toJSON() (não serializar)
hidden = {
"deleted_at"
},
-- Campos protegidos (nunca preenchidos em massa; tem prioridade sobre fillable)
guarded = {
"id",
"created_at",
"updated_at"
},
-- Validações (regras suportadas: required, min_length, max_length, email, unique)
validates = {
name = { required = true, min_length = 3, max_length = 255 },
price = { required = true }
},
-- Relações: cada chave é uma função que recebe a instância e devolve
-- o resultado de hasMany/hasOne/belongsTo (ver seção "Relações" abaixo)
relations = {
category = function(self)
local Category = require("src.categories.models.category")
return self:belongsTo(Category, "category_id")
end
},
-- Hooks ficam direto na raiz da config (não dentro de um bloco "hooks")
before_save = function(self)
if self.name and not self.slug then
self.slug = self.name:lower():gsub("%s+", "-")
end
end
})
-- Métodos personalizados
function Product:isLowStock()
return self.stock < 10
end
function Product:applyDiscount(percentage)
self.price = self.price * (1 - percentage / 100)
self:save()
end
return Product
validates, relations e os hooks (before_create, after_create, before_save, after_save, before_update, after_update, before_delete, after_delete) são todos opcionais.
📝 CRUD Operations
CREATE
local Product = require('src.products.models.product')
-- Método 1: create() — valida, roda hooks, insere e devolve a instância
local product, errors = Product:create({
name = "Notebook Dell",
price = 2500,
stock = 10
})
if not product then
-- validação falhou (ou o insert falhou) — não lança erro, devolve nil + errors
print(errors)
end
-- Método 2: new() + save()
local product = Product:new({
name = "Mouse Logitech",
price = 50
})
product:save()
READ
-- Buscar por ID (devolve instância do Model ou nil)
local product = Product:find(1)
-- Buscar por ID ou lançar erro
local product = Product:findOrFail(1)
-- Todos os registros (array de instâncias do Model)
local products = Product:all()
-- Primeiro resultado (instância do Model)
local product = Product:first()
-- Com condições — where(coluna, operador, valor) ou where(coluna, valor) (operador = "=")
-- IMPORTANTE: Product:where(...) devolve um QueryBuilder, não instâncias do Model —
-- :get()/:first() aqui devolvem tabelas cruas do banco, sem os métodos do Model
local rows = Product:where("category_id", 5):get()
local row = Product:where("name", "Notebook"):first()
-- Ordenar, limitar (métodos do QueryBuilder — encadeiam depois de where()/query())
local products = Product:where("stock", ">", 0):orderBy("price", "DESC"):get()
local products = Product:query():limit(10):get()
-- Paginação manual (paginate só existe no QueryBuilder, não no Model)
local page, per_page = 1, 10
local products = Product:query():paginate(page, per_page):get()
UPDATE
-- Método 1: Buscar e atualizar
local product = Product:find(1)
product:update({
price = 2300,
stock = 15
})
-- Método 2: Modificar e salvar
local product = Product:find(1)
product.price = 2300
product:save()
-- Método 3: Update direto via QueryBuilder (não roda hooks nem timestamps do Model)
Product:where("id", 1):update({price = 2300})
DELETE
-- Se soft_deletes = true, isto grava deleted_at e faz UPDATE em vez de DELETE
local product = Product:find(1)
product:delete()
-- Delete direto por condição via QueryBuilder (ignora soft_deletes e hooks)
Product:where("stock", 0):delete()
🔍 Query Builder
Os métodos abaixo existem no QueryBuilder (crescent/database/query_builder.lua). No Model, só query(), find(), findOrFail(), first(), all(), where() e raw() existem como atalhos estáticos — para qualquer outro método (join, orderBy, limit, whereIn, paginate, count, ...) comece a cadeia com Product:query() ou Product:where(...).
Condições WHERE
-- Igualdade simples
Product:where("category_id", 5):get()
-- Operador explícito
Product:where("price", ">", 1000):get()
Product:where("stock", "<=", 5):get()
-- Múltiplas condições (AND, encadeando where)
Product:where("category_id", 5):where("stock", ">", 10):get()
-- WHERE IN
Product:query():whereIn("category_id", {1, 2, 3}):get()
-- WHERE NULL
Product:query():whereNull("deleted_at"):get()
Product:query():whereNotNull("discount"):get()
Não existe whereBetween. Para isso, use duas condições (:where("price", ">=", 1000):where("price", "<=", 5000)) ou uma raw() query.
OR Conditions
Product:where("category_id", 5)
:orWhere("category_id", 10)
:get()
Ordenação
-- ASC (padrão)
Product:query():orderBy("name"):get()
-- DESC
Product:query():orderBy("price", "DESC"):get()
-- Múltiplas ordenações
Product:query()
:orderBy("category_id")
:orderBy("price", "DESC")
:get()
A direção é sempre normalizada para ASC ou DESC — qualquer outro valor vira ASC.
Limit e Offset
-- LIMIT
Product:query():limit(10):get()
-- OFFSET
Product:query():offset(20):limit(10):get()
-- Paginação (limit/offset prontos; ainda precisa de :get())
Product:query():paginate(2, 20):get() -- página 2, 20 por página
paginate(page, per_page) só ajusta limit/offset — não devolve total de registros nem número de páginas. Se precisar dessa metadata, calcule com uma query :count() separada (veja "Paginação" em Boas Práticas).
Select
-- Selecionar campos específicos
Product:query():select("id", "name", "price"):get()
-- Com alias
Product:query():select("id", "name", "price as valor"):get()
Joins
-- INNER JOIN (operador "=" é o padrão se omitido)
Product:query()
:join("categories", "products.category_id", "categories.id")
:select("products.*", "categories.name as category_name")
:get()
-- LEFT JOIN
Product:query():leftJoin("categories", "products.category_id", "categories.id"):get()
Agregações
-- COUNT
local total = Product:query():count()
local inStock = Product:where("stock", ">", 0):count()
Só count() existe hoje — não há sum/avg/min/max/groupBy/having prontos no QueryBuilder. Para esses casos, use raw().
Raw Queries
-- SELECT raw (sempre use bindings com ? — nunca concatene valor de usuário na string)
local products = Product:raw([[
SELECT * FROM products
WHERE price > ? AND stock > ?
]], {1000, 0})
-- INSERT raw
Product:raw([[
INSERT INTO products (name, price)
VALUES (?, ?)
]], {"Teclado", 150})
-- Com bindings para segurança (evita SQL injection)
local search = ctx.query.search
local products = Product:raw([[
SELECT * FROM products
WHERE name LIKE ?
]], {"%" .. search .. "%"})
✅ Validações
Validações Disponíveis
Só estas 5 regras existem em Model:validate() hoje:
validates = {
-- Obrigatório
name = { required = true },
-- Tamanho mínimo/máximo de string
name = { min_length = 3, max_length = 255 },
-- Email (regex simples)
email = { email = true },
-- Único na tabela (ignora o próprio registro ao atualizar)
email = { unique = true }
}
Não há numeric, range numérico, exists (checagem de FK), pattern (regex customizado) ou in_array embutidos. Para essas validações, valide manualmente no Service (próxima seção) — é o padrão recomendado hoje.
Validação no Service
-- src/products/services/products.lua
function ProductService:create(data)
-- Validação customizada (o que o Model:validate() não cobre)
if not data.price or data.price <= 0 then
error("Invalid price")
end
if data.stock and data.stock < 0 then
error("Stock cannot be negative")
end
-- Product:create() já roda as validações do Model (required/min_length/etc)
local product, errors = Product:create(data)
if not product then
error(table.concat((function()
local msgs = {}
for _, msg in pairs(errors) do table.insert(msgs, msg) end
return msgs
end)(), ", "))
end
return product
end
🔗 Relações
Diferente de outros ORMs, relações no Crescent não são declarativas — cada relação é uma função Lua que você chama explicitamente ou registra em relations para carregar sob demanda via instance:get("nome").
Três helpers de instância fazem o trabalho pesado:
| Método | Uso | Retorno |
|---|---|---|
self:belongsTo(RelatedModel, foreign_key, owner_key?) |
N:1 | instância do RelatedModel (ou nil) |
self:hasMany(RelatedModel, foreign_key, local_key?) |
1:N | QueryBuilder (chame :get()) |
self:hasOne(RelatedModel, foreign_key, local_key?) |
1:1 | linha crua da tabela (não é instância do Model) |
Uso direto (sem configurar relations)
local Category = require("src.categories.models.category")
local Product = require("src.products.models.product")
local product = Product:find(1)
local category = product:belongsTo(Category, "category_id")
print(category.name)
local category2 = Category:find(1)
local products = category2:hasMany(Product, "category_id"):get()
for _, row in ipairs(products) do
print(row.name)
end
Registrando em relations (carregamento preguiçoso e cacheado)
local Product = Model:extend({
table = "products",
relations = {
category = function(self)
local Category = require("src.categories.models.category")
return self:belongsTo(Category, "category_id")
end
}
})
local product = Product:find(1)
local category = product:get("category") -- NÃO product:category() nem product.category
instance:get("category") chama a função uma única vez e guarda o resultado em cache na própria instância; chamadas seguintes reaproveitam o valor.
Não há belongsToMany (N:N com tabela pivot) nem eager loading (:with(...)) hoje. Relações N:N precisam ser resolvidas manualmente com raw() ou um join(). Para evitar N+1 em listagens, veja "N+1 Problem" em Boas Práticas.
🔄 Migrations
Criar Migration
luvit crescent-cli make:migration create_products_table
Isso gera um arquivo em migrations/ com uma tabela mínima (id, name, created_at, updated_at) — edite o SQL gerado para o schema real.
Migration de Criação
-- migrations/20260109123456_create_products_table.lua
local Migration = {}
function Migration:up()
return [[
CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
stock INT DEFAULT 0,
category_id INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_category (category_id),
INDEX idx_price (price),
FOREIGN KEY (category_id)
REFERENCES categories(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
]]
end
function Migration:down()
return [[
DROP TABLE IF EXISTS products;
]]
end
return Migration
Cada migration roda com uma única chamada MySQL:query(sql) por cima do driver — evite depender de múltiplas instruções separadas por ; num só up()/down() funcionando como transação; prefira uma instrução DDL por migration quando possível.
Executar Migrations
# Executar pendentes
luvit crescent-cli migrate
# Desfazer última
luvit crescent-cli migrate:rollback
# Status
luvit crescent-cli migrate:status
🪝 Hooks (Lifecycle Events)
Os hooks ficam direto na raiz da config passada para Model:extend() — não existe um bloco hooks = {...} agrupando eles.
local Product = Model:extend({
table = "products",
before_save = function(self)
-- Antes de salvar (CREATE ou UPDATE)
print("Saving product:", self.name)
end,
after_save = function(self)
-- Depois de salvar
print("Product saved:", self.id)
end,
before_create = function(self)
-- Antes de criar
self.slug = self.name:lower():gsub("%s+", "-")
end,
after_create = function(self)
-- Depois de criar
print("New product created!")
end,
before_update = function(self) end,
after_update = function(self) end,
before_delete = function(self)
-- Antes de deletar (soft ou hard)
local orderCount = OrderItem:where("product_id", self.id):count()
if orderCount > 0 then
error("Cannot delete product with existing orders")
end
end,
after_delete = function(self) end
})
💡 Boas Práticas
Índices
-- Campos frequentemente buscados
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_status ON orders(status);
-- Chaves estrangeiras
CREATE INDEX idx_category_id ON products(category_id);
-- Compostos para queries complexas
CREATE INDEX idx_category_price ON products(category_id, price);
Transações
O mysql.lua atual pega uma conexão do pool por chamada e a devolve ao fim dela — chamadas separadas (db:query("START TRANSACTION"), depois outras queries, depois db:query("COMMIT")) não têm garantia de rodar na mesma conexão, então isso não funciona como uma transação atômica de verdade. Não há suporte a transações no ORM hoje; trate isso como uma limitação conhecida (e evite depender de rollback automático) até que o driver exponha uma conexão dedicada por transação.
N+1 Problem
-- ❌ Ruim (N+1 queries: 1 pra listar produtos + 1 por produto)
local products = Product:all()
for _, product in ipairs(products) do
local category = product:belongsTo(Category, "category_id")
end
-- ✅ Melhor: uma query pros produtos + uma query batelada pras categorias
local products = Product:all()
local category_ids = {}
for _, product in ipairs(products) do
table.insert(category_ids, product.category_id)
end
local categories_by_id = {}
for _, row in ipairs(Category:query():whereIn("id", category_ids):get()) do
categories_by_id[row.id] = row
end
for _, product in ipairs(products) do
local category = categories_by_id[product.category_id]
end
Não há eager loading (:with(...)) embutido — o padrão acima (buscar IDs e fazer um whereIn batelado) é a forma recomendada de evitar N+1 hoje.
Paginação
paginate() só ajusta limit/offset; total de registros e número de páginas precisam ser calculados à parte:
-- No controller
function ProductController:index(ctx)
local page = tonumber(ctx.query.page) or 1
local per_page = tonumber(ctx.query.per_page) or 20
local total = Product:query():count()
local data = Product:query():paginate(page, per_page):get()
return ctx.json(200, {
data = data,
current_page = page,
per_page = per_page,
total = total,
last_page = math.ceil(total / per_page)
})
end
🧪 Testando Database
-- tests/test-product.lua
local tests = require('crescent.utils.tests')
local Product = require('src.products.models.product')
local productTests = {
testCreate = function()
local product = Product:create({
name = "Test Product",
price = 100,
stock = 10
})
tests.assertNotNil(product)
tests.assertNotNil(product.id)
tests.assertEquals(product.name, "Test Product")
end,
testValidation = function()
-- Product:create() não lança erro em falha de validação —
-- devolve nil + uma tabela de erros
local product, errors = Product:create({name = ""})
tests.assertNil(product)
tests.assertNotNil(errors)
end,
testRelations = function()
local Category = require('src.categories.models.category')
local product = Product:find(1)
local category = product:belongsTo(Category, "category_id")
tests.assertNotNil(category)
tests.assertIsTable(category)
end
}
tests.runSuite("Product Model Tests", productTests)
📖 Próximas Seções
- Core Concepts - Controllers e Services
- Utilities - Testes e helpers
- Deployment - Deploy em produção
📋 Comandos Disponíveis
O Crescent CLI é uma ferramenta poderosa para acelerar o desenvolvimento, gerando código automaticamente e gerenciando seu projeto.
luvit crescent-cli <comando> [opções]
Comandos Principais
| Comando | Descrição |
|---|---|
new <nome> |
Cria um novo projeto Crescent |
server |
Inicia o servidor de desenvolvimento |
test |
Executa todos os testes do projeto |
make:controller |
Gera um controller |
make:service |
Gera um service |
make:model |
Gera um model |
make:routes |
Gera arquivo de rotas |
make:module |
Gera módulo completo (CRUD) |
make:migration |
Cria uma migration |
migrate |
Executa migrations pendentes |
migrate:rollback |
Desfaz última migration |
migrate:status |
Mostra status das migrations |
🆕 Criar Novo Projeto
luvit crescent-cli new meu-projeto
O que acontece:
- ✅ Clona o
crescent-starterdo GitHub - ✅ Remove histórico Git do template
- ✅ Inicializa novo repositório Git
- ✅ Configura estrutura completa
Próximos passos após criação:
cd meu-projeto
cp .env.example .env
nano .env # Configure MySQL
luvit app.lua
🚀 Servidor de Desenvolvimento
luvit crescent-cli server
Funcionalidades:
- ✅ Inicia aplicação com
luvit app.lua - ✅ Logs em tempo real
- ✅ Substituição de processo (
exec) para manter saída interativa - ✅ Verifica se
app.luaexiste antes de iniciar
Dica: Para hot-reload automático, use entr:
find . -name "*.lua" | entr -r luvit crescent-cli server
✅ Executar Testes
luvit crescent-cli test
Funcionalidades:
- 🔍 Descobre automaticamente diretórios
tests/outest/ - 🔍 Encontra todos arquivos
test.luaoutests.lua - ▶️ Executa cada teste sequencialmente
- 📊 Mostra saída completa de cada teste
- 📈 Apresenta resumo final com estatísticas
- ✅/❌ Feedback visual colorido com emojis
Exemplo de saída (real, capturada rodando runSuite e o comando test):
🌙 Executando Testes Crescent
ℹ Encontrados 1 arquivo(s) de teste
📄 Executando: tests/test-product.lua
────────────────────────────────────────────────────────────
=== Test Suite: Product Model Tests ===
Running test: testCreate
✅ testCreate passed
Running test: testUpdate
✅ testUpdate passed
Running test: testDelete
✅ testDelete passed
=== Results: 3/3 passed, 0 failed ===
════════════════════════════════════════════════════════════
🌙 Resumo dos Testes
Total de arquivos executados: 1
✅ Todos os testes passaram! (1/1)
Cada suíte (tests.runSuite) sempre imprime === Test Suite: <nome> === e uma linha Running test: <nome> antes de cada resultado — é assim que crescent/utils/tests.lua funciona por baixo, independente de rodar via luvit crescent-cli test ou chamando o arquivo de teste direto.
🏗️ Geradores de Código
Gerar Controller
luvit crescent-cli make:controller Product
# ou especificar módulo
luvit crescent-cli make:controller Product catalog
Cria: src/product/controllers/product.lua
Template gerado:
-- src/product/controllers/product.lua
-- Controller para Product
local service = require("src.product.services.product")
local ProductController = {}
function ProductController:index(ctx)
local result = service:getAll()
return ctx.json(200, result)
end
function ProductController:show(ctx)
local id = ctx.params.id
local result = service:getById(id)
if result then
return ctx.json(200, result)
end
return ctx.json(404, { error = "Not found" })
end
function ProductController:create(ctx)
local body = ctx.body or {}
local result = service:create(body)
return ctx.json(201, result)
end
function ProductController:update(ctx)
local id = ctx.params.id
local body = ctx.body or {}
local result = service:update(id, body)
if result then
return ctx.json(200, result)
else
return ctx.json(404, { error = "Not found" })
end
end
function ProductController:delete(ctx)
local id = ctx.params.id
local success = service:delete(id)
if success then
return ctx.no_content()
else
return ctx.json(404, { error = "Not found" })
end
end
return ProductController
Gerar Service
luvit crescent-cli make:service Product
Cria: src/product/services/product.lua
Template gerado:
-- src/product/services/product.lua
-- Service para lógica de negócio de Product
local ProductService = {}
local Product = require("src.product.models.product")
function ProductService:getAll()
return Product:all()
end
function ProductService:getById(id)
return Product:find(id)
end
function ProductService:create(body)
return Product:create(body)
end
function ProductService:update(id, body)
local product = Product:find(id)
if product then
product:update(body)
return product
end
return nil
end
function ProductService:delete(id)
local product = Product:find(id)
if product then
product:delete()
return true
end
return false
end
return ProductService
Gerar Model
luvit crescent-cli make:model Product
Cria: src/product/models/product.lua
Template gerado:
-- src/product/models/product.lua
-- Model para Product usando Active Record ORM
local Model = require("crescent.database.model")
local Product = Model:extend({
table = "product",
primary_key = "id",
timestamps = true,
soft_deletes = false,
fillable = {
-- Adicione aqui os campos que podem ser preenchidos em massa
"name",
},
hidden = {
-- Campos que não devem aparecer em JSON/serialização
-- "password"
},
guarded = {
-- Campos protegidos contra mass assignment
-- "id", "created_at", "updated_at"
},
validates = {
-- Adicione validações aqui
name = {required = true, min_length = 3, max_length = 255},
},
relations = {
-- Defina relações aqui (cada uma é uma função que recebe a instância)
-- posts = function(self) return self:hasMany(require("src.posts.models.post"), "user_id") end,
-- profile = function(self) return self:belongsTo(require("src.profile.models.profile"), "user_id") end,
}
})
-- Métodos personalizados do model
-- function Product:customMethod()
-- -- Seu código aqui
-- end
return Product
Veja Database & ORM → Relações para mais detalhes de como relations funciona na prática.
Gerar Routes
luvit crescent-cli make:routes Product
Cria: src/product/routes/product.lua
Template gerado:
-- src/product/routes/product.lua
-- prefix definido em product/init.lua
local controller = require("src.product.controllers.product")
return function(app, prefix)
prefix = prefix or "/product"
-- CRUD completo
app:get(prefix, function(ctx)
return controller:index(ctx)
end)
app:get(prefix .. "/{id}", function(ctx)
return controller:show(ctx)
end)
app:post(prefix, function(ctx)
return controller:create(ctx)
end)
app:put(prefix .. "/{id}", function(ctx)
return controller:update(ctx)
end)
app:delete(prefix .. "/{id}", function(ctx)
return controller:delete(ctx)
end)
end
Gerar Módulo Completo
luvit crescent-cli make:module Product
Cria tudo de uma vez:
- ✅ Controller
- ✅ Service
- ✅ Model
- ✅ Routes
- ✅ Module Init
Estrutura gerada:
src/product/
├── init.lua
├── controllers/
│ └── product.lua
├── services/
│ └── product.lua
├── models/
│ └── product.lua
└── routes/
└── product.lua
Module Init (src/product/init.lua):
-- src/product/init.lua
local Module = {}
function Module.register(app)
local routes = require("src.product.routes.product")
routes(app, "/product")
print("✓ Módulo Product carregado")
end
return Module
Registrar no app.lua:
local ProductModule = require("src.product")
ProductModule.register(app)
🔄 Migrations
Criar Migration
luvit crescent-cli make:migration create_products_table
Padrões de nome reconhecidos:
O nome da migration só decide qual nome de tabela é extraído — o SQL gerado é sempre o mesmo (CREATE TABLE IF NOT EXISTS <tabela> (...) no up(), DROP TABLE IF EXISTS <tabela> no down()), não importa qual dos 4 padrões você usa:
create_xxx_table→ tabela extraída: "xxx"add_xxx_to_yyy→ tabela extraída: "yyy"drop_xxx_table→ tabela extraída: "xxx"update_xxx_table→ tabela extraída: "xxx"
Se o nome não bater com nenhum desses padrões, a tabela extraída é "example". O gerador não cria colunas específicas nem gera ALTER TABLE/ADD COLUMN — sempre produz a mesma tabela genérica (id, name, created_at, updated_at); editar up()/down() à mão pra adicionar colunas/índices reais é o fluxo esperado (veja exemplos em Database & ORM → Migrations).
Cria: migrations/20260109123456_create_products_table.lua
Template gerado (sempre igual, independente do nome da migration):
-- migrations/20260109123456_create_products_table.lua
-- Migration: create_products_table
local Migration = {}
-- Executa a migration (criar tabelas, adicionar colunas, etc)
function Migration:up()
return [[
CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
]]
end
-- Desfaz a migration (remover tabelas, colunas, etc)
function Migration:down()
return [[
DROP TABLE IF EXISTS products;
]]
end
return Migration
Executar Migrations
# Executar todas pendentes
luvit crescent-cli migrate
# Desfazer última migration
luvit crescent-cli migrate:rollback
# Ver status
luvit crescent-cli migrate:status
Exemplo de saída (real, formato de crescent/database/migrate.lua):
🌙 Executando Migrations
→ Executando: 20260108230701_create_users_table
Executada com sucesso!
→ Executando: 20260109123456_create_products_table
Executada com sucesso!
Total: 2 migration(s) executada(s)
🎯 Casos de Uso Comuns
1. Criar CRUD Completo
# 1. Criar migration
luvit crescent-cli make:migration create_categories_table
# 2. Editar migration (adicionar campos)
nano migrations/20260109_*.lua
# 3. Executar migration
luvit crescent-cli migrate
# 4. Criar módulo completo
luvit crescent-cli make:module Category
# 5. Registrar no app.lua
# Adicionar: CategoryModule.register(app)
# 6. Iniciar servidor
luvit crescent-cli server
2. Adicionar Funcionalidade a Módulo Existente
# Adicionar novo controller
luvit crescent-cli make:controller Admin users
# Adicionar novo service
luvit crescent-cli make:service Auth auth
3. Workflow de Testes
# Criar teste
touch tests/test-product.lua
# Implementar teste usando crescent/utils/tests
nano tests/test-product.lua
# Rodar testes
luvit crescent-cli test
🔧 Instalação Global do CLI
Para usar crescent sem luvit crescent-cli:
# No diretório do framework
./install.sh
# Agora use diretamente
crescent make:module Product
crescent server
crescent test
💡 Dicas Avançadas
Aliases Bash
# Adicione no ~/.bashrc ou ~/.zshrc
alias cres='luvit crescent-cli'
alias cres-serve='luvit crescent-cli server'
alias cres-test='luvit crescent-cli test'
# Uso
cres make:module Product
cres-serve
cres-test
Watch Mode com Entr
# Auto-restart no servidor
find . -name "*.lua" | entr -r luvit crescent-cli server
# Auto-run tests
find tests -name "*.lua" | entr -c luvit crescent-cli test
Scripts Personalizados
Crie scripts no package.lua ou arquivos shell:
#!/bin/bash
# dev.sh - Script de desenvolvimento
echo "🔧 Instalando dependências..."
lit install
echo "🔄 Executando migrations..."
luvit crescent-cli migrate
echo "✅ Rodando testes..."
luvit crescent-cli test
echo "🚀 Iniciando servidor..."
luvit crescent-cli server
📖 Referência Rápida
# Criar projeto
crescent new app
# Geradores
crescent make:module User
crescent make:controller Product
crescent make:service Auth
crescent make:model Category
crescent make:routes Api
# Migrations
crescent make:migration create_posts_table
crescent migrate
crescent migrate:rollback
crescent migrate:status
# Desenvolvimento
crescent server
crescent test
# Help
crescent --help
🆘 Troubleshooting
Erro: "crescent-cli.lua not found"
Execute o comando no diretório raiz do projeto onde está o arquivo crescent-cli.lua.
Erro: "Permission denied"
chmod +x crescent-cli.lua
CLI não cria arquivos
Verifique permissões de escrita no diretório:
ls -la src/
Migration falha
- Verifique sintaxe SQL no arquivo da migration
- Confirme conexão com MySQL:
luvit crescent/database/mysql.lua - Veja logs de erro completos
📚 Próximas Seções
- Core Concepts - Rotas, Controllers, Services
- Database & ORM - Migrations e Models em detalhes
- Utilities - Ferramentas de teste e helpers
✅ Sistema de Testes
Crescent fornece diversas utilidades prontas para acelerar seu desenvolvimento.
O Crescent inclui uma biblioteca completa de assertions para testes automatizados.
Criar Arquivo de Teste
-- tests/test-users.lua
local tests = require('crescent.utils.tests')
local userTests = {
testCreate = function()
-- Seu código de teste
tests.assertEquals(1 + 1, 2)
end,
testValidation = function()
tests.assertTrue(true)
tests.assertFalse(false)
end
}
tests.runSuite("User Tests", userTests)
Executar Testes
# Todos os testes
luvit crescent-cli test
# Um arquivo específico
luvit tests/test-users.lua
📋 Assertions Disponíveis
Comparações Básicas
-- Igualdade
tests.assertEquals(actual, expected, "mensagem opcional")
tests.assertNotEquals(actual, expected)
-- Booleanos
tests.assertTrue(value)
tests.assertFalse(value)
Comparações Numéricas
tests.assertGreaterThan(5, 3) -- 5 > 3
tests.assertLessThan(3, 5) -- 3 < 5
tests.assertGreaterOrEqual(5, 5) -- 5 >= 5
tests.assertLessOrEqual(3, 5) -- 3 <= 5
tests.assertInRange(5, 1, 10) -- 1 <= 5 <= 10
Validações de Tipo
tests.assertType(value, "string")
tests.assertNil(value)
tests.assertNotNil(value)
tests.assertIsTable(value)
tests.assertIsFunction(value)
tests.assertIsString(value)
tests.assertIsNumber(value)
tests.assertIsBoolean(value)
Comparações de Strings
tests.assertContains("hello world", "world")
tests.assertNotContains("hello", "bye")
tests.assertStartsWith("hello world", "hello")
tests.assertEndsWith("hello world", "world")
tests.assertMatches("test123", "%d+") -- Lua pattern
Comparações de Tables
-- Comparação profunda
tests.assertTableEquals({a=1, b=2}, {a=1, b=2})
-- Contém valor
tests.assertTableContains({1, 2, 3}, 2)
-- Vazio/não vazio
tests.assertEmpty({})
tests.assertNotEmpty({1})
-- Tamanho
tests.assertLength({1, 2, 3}, 3)
tests.assertArrayLength({1, 2, 3}, 3)
Exceptions/Errors
-- Espera erro
tests.assertError(function()
error("Ops!")
end, "Ops!")
-- Não deve dar erro
tests.assertNoError(function()
return 1 + 1
end)
HTTP/API Testing
-- Status code
tests.assertStatusCode(response, 200)
-- Headers
tests.assertHeader(response, "Content-Type", "application/json")
-- JSON response
tests.assertJsonEquals('{"name":"John"}', {name="John"})
tests.assertJsonContains('{"name":"John","age":30}', "name", "John")
Identidade
local obj = {}
tests.assertSame(obj, obj) -- Mesma referência
tests.assertNotSame({}, {}) -- Objetos diferentes
📝 Exemplo Completo de Teste
-- tests/test-product.lua
local tests = require('crescent.utils.tests')
local Product = require('src.products.models.product')
local productTests = {
testCreate = function()
local product = Product:create({
name = "Notebook",
price = 2500
})
tests.assertNotNil(product)
tests.assertIsTable(product)
tests.assertEquals(product.name, "Notebook")
tests.assertType(product.price, "number")
tests.assertGreaterThan(product.price, 0)
end,
testValidation = function()
-- Product:create() não lança erro em validação inválida —
-- devolve nil + uma tabela de erros (retorno múltiplo)
local product, errors = Product:create({name = ""})
tests.assertNil(product)
tests.assertNotNil(errors)
end,
testFind = function()
local product = Product:find(1)
if product then
tests.assertIsTable(product)
tests.assertNotNil(product.id)
tests.assertType(product.name, "string")
end
end,
testUpdate = function()
local product = Product:find(1)
if product then
local oldName = product.name
product:update({name = "Updated Name"})
tests.assertNotEquals(product.name, oldName)
tests.assertEquals(product.name, "Updated Name")
end
end,
testAll = function()
local products = Product:all()
tests.assertIsTable(products)
tests.assertGreaterOrEqual(#products, 0)
end
}
-- Executar suite
tests.runSuite("Product Tests", productTests)
Saída:
=== Test Suite: Product Tests ===
Running test: testCreate
✅ testCreate passed
Running test: testValidation
✅ testValidation passed
Running test: testFind
✅ testFind passed
Running test: testUpdate
✅ testUpdate passed
Running test: testAll
✅ testAll passed
=== Results: 5/5 passed, 0 failed ===
🔐 Hash de Senhas (PBKDF2)
Utilitário seguro para hash de senhas com salt aleatório.
Características
- ✅ PBKDF2 com SHA-256
- ✅ Salt aleatório único (16 bytes)
- ✅ 10.000 iterações por padrão
- ✅ Timing-safe comparison
- ✅ Senhas iguais geram hashes diferentes
Criar Hash de Senha
local hash = require('crescent.utils.hash')
-- Registrar usuário
local senha = "minhaSenhaSegura123"
local senhaHash = hash.encrypt(senha)
-- Resultado: "10000$a1b2c3d4e5f6...$9c8d7e6f5a4b..."
-- Armazenar senhaHash no banco de dados
user:update({password = senhaHash})
Verificar Senha (Login)
local hash = require('crescent.utils.hash')
-- No login
local senhaDigitada = ctx.body.password
local senhaArmazenada = user.password -- Hash do banco
if hash.verify(senhaDigitada, senhaArmazenada) then
-- Login bem-sucedido
return ctx.json(200, {token = gerarToken(user)})
else
-- Senha incorreta
return ctx.json(401, {error = "Credenciais inválidas"})
end
Exemplo Completo (Registro + Login)
-- src/auth/services/auth.lua
local hash = require('crescent.utils.hash')
local User = require('src.users.models.user')
local AuthService = {}
function AuthService:register(data)
-- Valida dados
if not data.email or not data.password then
error("Email e senha são obrigatórios")
end
-- Cria hash da senha
local passwordHash = hash.encrypt(data.password)
-- Cria usuário
local user = User:create({
name = data.name,
email = data.email,
password = passwordHash -- Armazena hash, não senha
})
return user
end
function AuthService:login(email, password)
-- Busca usuário (where() é posicional: coluna, [operador,] valor)
local user = User:where("email", email):first()
if not user then
return nil, "Usuário não encontrado"
end
-- Verifica senha
if not hash.verify(password, user.password) then
return nil, "Senha incorreta"
end
-- Remove senha do retorno
user.password = nil
return user
end
return AuthService
Configurar Iterações
-- Mais seguro (mais lento)
local hash50k = hash.encrypt(senha, 50000)
-- Padrão (balanceado)
local hashPadrao = hash.encrypt(senha) -- 10.000 iterações
Hashes Simples (Checksums)
-- SHA-256 (para checksums, não senhas!)
local checksum = hash.sha256("conteúdo do arquivo")
-- MD5 (legado, não usar para senhas)
local md5sum = hash.md5("dados")
⚠️ Importante: Nunca use SHA-256 ou MD5 direto para senhas! Sempre use hash.encrypt() que implementa PBKDF2 com salt.
hash.encrypt()/hash.verify() são os únicos nomes válidos hoje — os aliases antigos (encript, decrypt, decript) foram removidos.
🌐 APIs Externas
crescent.utils.http é um cliente HTTP estilo axios para consumir APIs externas, construído sobre socket.http/ssl.https/ltn12/cjson (dependências LuaRocks: luasocket, luasec, lua-cjson).
Uso Básico (instância padrão)
local http = require('crescent.utils.http')
-- GET
local result, err = http.get("https://api.exemplo.com/users/1")
if result then
print(result.status) -- 200
print(result.data) -- corpo já decodificado como tabela se for JSON
else
print(err.message) -- "Request failed with status 404"
end
-- POST com corpo JSON (Content-Type: application/json é setado automaticamente)
local result, err = http.post("https://api.exemplo.com/users", {
name = "João",
email = "joao@example.com"
})
-- PUT / PATCH / DELETE / HEAD / OPTIONS
http.put(url, data)
http.patch(url, data)
http.delete(url)
http.head(url)
http.options(url)
-- Requisição genérica
local result, err = http.request({
url = "https://api.exemplo.com/search",
method = "GET",
params = { q = "crescent" }, -- vira ?q=crescent na URL
headers = { ["x-api-key"] = "..." }
})
Retorno: em sucesso, (result, nil) — result tem data (corpo, já decodificado se for JSON válido), status, statusText, headers, config, request. Em falha, (nil, result) — o mesmo formato, mais result.error = true e result.message.
Instância customizada (baseURL, headers e timeout fixos)
local http = require('crescent.utils.http')
local api = http.create({
baseURL = "https://api.exemplo.com",
timeout = 10,
headers = { ["Authorization"] = "Bearer " .. token }
})
local result, err = api:get("/users/1") -- vira https://api.exemplo.com/users/1
local result, err = api:post("/users", { name = "João" })
🔐 JWT (JSON Web Tokens)
O Crescent fornece suporte a autenticação JWT no runtime Luvit (não OpenResty/nginx — são runtimes Lua diferentes). crescent.utils.jwt tenta usar openssl.hmac.digest para o HMAC-SHA256 quando disponível (o módulo openssl já vem embutido no binário do Luvit — confirme com luvit -v) e cai para uma implementação SHA-256 pure-Lua automaticamente caso contrário, então funciona sem dependências extras de qualquer forma.
Configurar JWT Secret
Adicione no seu .env:
JWT_SECRET=sua_chave_secreta_super_segura_com_64_ou_mais_caracteres
Gerar Token
local jwt = require('crescent.utils.jwt')
-- Payload do token
local payload = {
user_id = 1,
username = "joao",
email = "joao@example.com",
roles = {"user", "admin"}
}
-- Gerar token (expira em 15 minutos por padrão)
local token = jwt.sign(payload, os.getenv('JWT_SECRET'), {
expiresIn = 900 -- 15 minutos em segundos
})
-- Retornar para o cliente
return ctx.json(200, {
token = token,
type = "Bearer"
})
Verificar Token
local jwt = require('crescent.utils.jwt')
-- Obter token do header Authorization
local token = ctx.getBearer() -- Remove "Bearer " automaticamente
-- Verificar e decodificar
local ok, payload_or_error = jwt.verify(token, os.getenv('JWT_SECRET'))
if ok then
-- Token válido
local user_id = payload_or_error.user_id
local username = payload_or_error.username
-- ... usar dados
else
-- Token inválido
return ctx.error(401, payload_or_error)
end
Opções Avançadas
local jwt = require('crescent.utils.jwt')
-- Token com claims adicionais
local token = jwt.sign(payload, secret, {
expiresIn = 3600, -- Expira em 1 hora
notBefore = 0, -- Válido imediatamente
issuer = "crescent-app", -- Quem emitiu
audience = "api-users" -- Para quem é destinado
})
-- Verificar com validação de claims
local ok, payload = jwt.verify(token, secret, {
issuer = "crescent-app", -- Valida issuer
audience = "api-users" -- Valida audience
})
Access Token e Refresh Token
local jwt = require('crescent.utils.jwt')
local payload = {
user_id = 1,
username = "joao"
}
-- Access token (curta duração - 15 min)
local access_token = jwt.create_access_token(
payload,
os.getenv('JWT_SECRET'),
900 -- 15 minutos (opcional, padrão já é 15min)
)
-- Refresh token (longa duração - 30 dias)
local refresh_token = jwt.create_refresh_token(
payload,
os.getenv('JWT_SECRET'),
2592000 -- 30 dias (opcional, padrão já é 30 dias)
)
return ctx.json(200, {
access_token = access_token,
refresh_token = refresh_token,
token_type = "Bearer",
expires_in = 900
})
Decodificar Sem Verificar
local jwt = require('crescent.utils.jwt')
-- Apenas para debug/inspeção - NÃO use para autenticação!
local header, payload = jwt.decode(token)
print("Algorithm:", header.alg) -- "HS256"
print("User ID:", payload.user_id)
-- ⚠️ Assinatura NÃO foi verificada!
Middleware de Autenticação JWT
Middlewares no Crescent são sempre globais (app:use(middleware), sem segundo argumento de path) — não existe middleware escopado por rota. Para proteger só parte das rotas, registre o middleware depois das rotas públicas e antes das rotas protegidas, ou monte um sub-app/roteador separado por módulo.
local auth = require('crescent.middleware.auth')
-- Middleware JWT básico (protege tudo que for registrado depois dele)
app:use(auth.jwt())
-- Com opções customizadas
app:use(auth.jwt({
secret = os.getenv('JWT_SECRET'),
issuer = "crescent-app",
audience = "admin-panel",
getUserFromPayload = function(payload, ctx)
-- Buscar usuário completo do banco
return User:find(payload.user_id)
end
}))
-- Usar dados do usuário na rota
app:get('/api/profile', function(ctx)
-- ctx.state.user foi populado pelo middleware
local user = ctx.state.user
return ctx.json(200, {
id = user.id,
name = user.name,
email = user.email
})
end)
Helpers do Middleware Auth
local auth = require('crescent.middleware.auth')
-- Gerar token manualmente
local token = auth.generate_token({
user_id = 1,
username = "joao"
}, {
secret = os.getenv('JWT_SECRET'),
expiresIn = 3600
})
-- Gerar par de tokens (access + refresh)
local tokens = auth.generate_token_pair({
user_id = 1,
username = "joao"
}, {
secret = os.getenv('JWT_SECRET'),
access_expires_in = 900, -- 15 min
refresh_expires_in = 2592000 -- 30 dias
})
-- tokens = {
-- access_token = "eyJ...",
-- refresh_token = "eyJ...",
-- token_type = "Bearer",
-- expires_in = 900
-- }
-- Verificar token fora do middleware
local ok, payload = auth.verify_token(token, {
secret = os.getenv('JWT_SECRET')
})
-- Decodificar sem verificar (debug)
local header, payload = auth.decode_token(token)
Exemplo Completo: Sistema de Auth
-- src/auth/services/auth.lua
local jwt = require('crescent.utils.jwt')
local hash = require('crescent.utils.hash')
local User = require('src.users.models.user')
local AuthService = {}
-- Registro de usuário
function AuthService.register(data)
-- Validar dados
if not data.email or not data.password then
error("Email e senha são obrigatórios")
end
-- Hash da senha
local passwordHash = hash.encrypt(data.password)
-- Criar usuário
local user = User:create({
name = data.name,
email = data.email,
password = passwordHash
})
-- Gerar tokens
local tokens = AuthService.generateTokens(user)
return {
user = {
id = user.id,
name = user.name,
email = user.email
},
tokens = tokens
}
end
-- Login de usuário
function AuthService.login(email, password)
-- Buscar usuário (where() é posicional)
local user = User:where("email", email):first()
if not user then
error("Credenciais inválidas")
end
-- Verificar senha
if not hash.verify(password, user.password) then
error("Credenciais inválidas")
end
-- Gerar tokens
local tokens = AuthService.generateTokens(user)
return {
user = {
id = user.id,
name = user.name,
email = user.email
},
tokens = tokens
}
end
-- Refresh token
function AuthService.refresh(refresh_token)
local secret = os.getenv('JWT_SECRET')
-- Verificar refresh token
local ok, payload = jwt.verify(refresh_token, secret)
if not ok then
error("Token inválido ou expirado")
end
-- Buscar usuário
local user = User:find(payload.user_id)
if not user then
error("Usuário não encontrado")
end
-- Gerar novo access token
local access_token = jwt.create_access_token({
user_id = user.id,
username = user.name,
email = user.email
}, secret)
return {
access_token = access_token,
token_type = "Bearer",
expires_in = 900
}
end
-- Helper para gerar tokens
function AuthService.generateTokens(user)
local secret = os.getenv('JWT_SECRET')
local payload = {
user_id = user.id,
username = user.name,
email = user.email
}
local access_token = jwt.create_access_token(payload, secret)
local refresh_token = jwt.create_refresh_token(payload, secret)
return {
access_token = access_token,
refresh_token = refresh_token,
token_type = "Bearer",
expires_in = 900
}
end
return AuthService
Rotas de Autenticação
-- src/auth/routes/auth.lua
local AuthService = require('src.auth.services.auth')
local auth = require('crescent.middleware.auth')
return function(app)
-- Registro
app:post('/auth/register', function(ctx)
local data = ctx.body
local ok, result = pcall(function()
return AuthService.register(data)
end)
if not ok then
return ctx.json(400, {error = result})
end
return ctx.json(201, result)
end)
-- Login
app:post('/auth/login', function(ctx)
local data = ctx.body
local ok, result = pcall(function()
return AuthService.login(data.email, data.password)
end)
if not ok then
return ctx.json(401, {error = result})
end
return ctx.json(200, result)
end)
-- Refresh token
app:post('/auth/refresh', function(ctx)
local refresh_token = ctx.body.refresh_token
if not refresh_token then
return ctx.json(400, {error = "Refresh token é obrigatório"})
end
local ok, result = pcall(function()
return AuthService.refresh(refresh_token)
end)
if not ok then
return ctx.json(401, {error = result})
end
return ctx.json(200, result)
end)
end
-- Rotas protegidas (registre o middleware antes de montar essas rotas
-- no app.lua, já que app:use() é sempre global):
--
-- app:use(auth.jwt())
-- app:get('/auth/profile', function(ctx) ... end)
Claims Padrão JWT
| Claim | Descrição | Exemplo |
|---|---|---|
iat |
Issued At - quando foi criado | 1705484400 |
exp |
Expiration - quando expira | 1705488000 |
nbf |
Not Before - válido a partir de | 1705484400 |
iss |
Issuer - quem emitiu | "crescent-app" |
aud |
Audience - para quem é destinado | "api-users" |
Boas Práticas JWT
✅ Recomendado:
- Use secrets longos e aleatórios (64+ caracteres)
- Access tokens curtos (15 min)
- Refresh tokens longos (30 dias)
- Armazene tokens com segurança no cliente (HttpOnly cookies)
- Valide claims como
issuereaudience - Implemente refresh token rotation
❌ Evite:
- Armazenar dados sensíveis no payload (é decodificável!)
- Tokens muito longos (> 7 dias para access)
- Reutilizar JWT secret entre ambientes
- Esquecer de validar expiração
- Confiar apenas no token sem validar usuário no banco
Testar JWT
-- tests/test-jwt.lua
local tests = require('crescent.utils.tests')
local jwt = require('crescent.utils.jwt')
local secret = "test_secret_key"
local jwtTests = {
testSignAndVerify = function()
local payload = {user_id = 1}
local token = jwt.sign(payload, secret)
local ok, decoded = jwt.verify(token, secret)
tests.assertTrue(ok)
tests.assertEquals(decoded.user_id, 1)
end,
testInvalidSignature = function()
local payload = {user_id = 1}
local token = jwt.sign(payload, secret)
local ok, error = jwt.verify(token, "wrong_secret")
tests.assertFalse(ok)
tests.assertNotNil(error)
end,
testExpiration = function()
local payload = {user_id = 1}
local token = jwt.sign(payload, secret, {expiresIn = 1})
local ok = jwt.verify(token, secret)
tests.assertTrue(ok)
-- Token expira após 1 segundo
end
}
tests.runSuite("JWT Tests", jwtTests)
crescent.utils.mail envia email via SMTP, construído sobre socket.smtp/ltn12/mime (dependência LuaRocks: luasocket). As credenciais padrão vêm do .env (SMTP_SERVER, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_FROM_NAME).
# .env
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=seu_email@gmail.com
SMTP_PASSWORD=sua_senha_de_app
SMTP_FROM=seu_email@gmail.com
SMTP_FROM_NAME="Minha Aplicação"
Enviar Email
local mail = require('crescent.utils.mail')
-- Texto simples
local result, err = mail.send_text(
"destinatario@example.com",
"Bem-vindo!",
"Obrigado por se cadastrar."
)
-- HTML (com fallback em texto opcional)
local result, err = mail.send_html(
"destinatario@example.com",
"Bem-vindo!",
"<h1>Bem-vindo!</h1><p>Seu cadastro foi confirmado.</p>",
"Bem-vindo! Seu cadastro foi confirmado."
)
-- Opções completas (to/cc/bcc aceitam string, array de strings, ou
-- array de {email=..., name=...})
local result, err = mail.send({
to = { { email = "user@example.com", name = "Usuário" } },
cc = "outro@example.com",
subject = "Relatório mensal",
html = "<h1>Relatório</h1>",
reply_to = "suporte@example.com"
})
if not result then
print("Falha ao enviar:", err)
end
Template (etlua)
local mail = require('crescent.utils.mail')
-- Renderiza o .etlua com etlua.render, envia como HTML e gera
-- automaticamente uma versão em texto puro (strip de tags)
local result, err = mail.send_template(
"destinatario@example.com",
"Recuperação de senha",
"views/emails/reset-password.etlua",
{ reset_link = "https://app.com/reset?token=abc123" }
)
Instância customizada e verificação de conexão
local mail = require('crescent.utils.mail')
local custom_mailer = mail.create({
server = "smtp.outro-provedor.com",
port = 465,
user = "outro@example.com",
password = "..."
})
-- Testa conectividade TCP com o servidor SMTP (não envia email)
local ok, msg = mail.verify()
🌐 Variáveis de Ambiente
local env = require('crescent.utils.env')
-- Carregar .env (usado automaticamente por env.get() na primeira chamada)
env.load('.env')
-- Obter valores, com fallback opcional
local dbHost = env.get('DB_HOST', 'localhost')
local port = env.get('APP_PORT')
local isDev = env.get('APP_ENV') == 'development'
-- Limpar cache (útil em testes que trocam variáveis em runtime)
env.clear_cache()
Não existe env.has(...) — para checar presença, compare com nil: if env.get('API_KEY') then ... end.
📨 Headers HTTP
crescent.utils.headers normaliza headers de requisição (não é um parser de headers de resposta genérico).
local headers = require('crescent.utils.headers')
-- normalize(req) recebe o OBJETO de requisição inteiro (req.rawHeaders /
-- req.headers), não um nome de header — devolve uma tabela com todos os
-- headers em lowercase: { authorization = "...", ["content-type"] = "..." }
local normalized = headers.normalize(req)
-- Extrai o token de um header "Authorization: Bearer <token>" já normalizado
local token = headers.get_bearer(normalized)
⚠️ headers.is_safe_value() está quebrado hoje (mesma causa de stringUtil.is_safe()/sanitize(), ver seção "String Utilities" abaixo): o pattern [\r\n\0] inclui um byte nulo dentro de uma character class, o que lança malformed pattern (missing ']') em qualquer chamada neste Luvit/LuaJIT, independente do conteúdo testado. Bug de código, reportado aqui, não corrigido.
Na prática, você raramente chama headers.normalize() direto — o crescent.core.context já expõe o resultado normalizado em ctx.headers, e ctx.getHeader(name) / ctx.getBearer() fazem a leitura pra você:
local auth_header = ctx.getHeader("authorization")
local token = ctx.getBearer()
🛤️ Path Utilities
crescent.utils.path foi feito para paths de rota HTTP, não para manipulação de paths de arquivo do sistema operacional (não existe dirname/basename/extname).
local pathUtil = require('crescent.utils.path')
-- Junta dois segmentos de path (só 2 argumentos)
local fullPath = pathUtil.join('/api', 'users')
-- "/api/users"
-- Normaliza: colapsa "//" repetidos e garante "/" inicial
-- (NÃO resolve ".."/"." — isso é tratado por is_safe, não normalize)
local normalized = pathUtil.normalize('api//users')
-- "/api/users"
-- Valida se o path é seguro (sem ".." nem null byte) — usado
-- internamente pelo middleware de arquivos estáticos contra path traversal
local safe = pathUtil.is_safe('/../../etc/passwd') -- false
local safe2 = pathUtil.is_safe('/css/app.css') -- true
-- Compila um template de rota "/user/{id}" em pattern Lua + nomes de
-- parâmetros — usado internamente pelo roteador
local pattern, names = pathUtil.compile('/user/{id}')
-- pattern: "^/user/?([^/]*)$"; names: {"id"}
🔤 String Utilities
crescent.utils.string foca em segurança (sanitização/validação), não em manipulação geral de strings — não existem split/startsWith/ endsWith/upper/lower/titleCase/slugify.
local stringUtil = require('crescent.utils.string')
-- Remove espaços do início/fim
local trimmed = stringUtil.trim(' hello ') -- "hello"
-- Escapa metacaracteres de pattern Lua (evita injeção de pattern em
-- gsub/match/find quando o texto vem de input externo)
local safe_pattern = stringUtil.escape_lua_pattern('1.99 (promo)')
-- "1%.99 %(promo%)"
-- Limita o tamanho (proteção contra payloads gigantes / DoS)
local limited = stringUtil.limit(long_string, 8192) -- default 8192
Para split/case/slugify, use as primitivas nativas do Lua (string.gmatch, string.upper/lower, string.gsub) diretamente — não há um wrapper do Crescent pra isso hoje.
⚠️ is_safe() e sanitize() estão quebrados hoje. Ambos usam o pattern [\0-\8\11-\12\14-\31\127] (byte nulo como início de um range de character class) — testado neste Luvit/LuaJIT, isso lança malformed pattern (missing ']') em qualquer chamada, mesmo com entrada sem byte nulo (o erro é no parsing do pattern, não no conteúdo). É um bug no código-fonte (crescent/utils/string.lua), não um erro de documentação — reportado, não corrigido aqui. Enquanto não for corrigido, não use is_safe()/sanitize(); escape_lua_pattern() e limit() continuam funcionando normalmente.
🧪 Testando Utilities
-- tests/test-utilities.lua
local tests = require('crescent.utils.tests')
local hash = require('crescent.utils.hash')
local stringUtil = require('crescent.utils.string')
local utilTests = {
testHashUnique = function()
local senha = "test123"
local hash1 = hash.encrypt(senha)
local hash2 = hash.encrypt(senha)
-- Hashes devem ser diferentes (salt único)
tests.assertNotEquals(hash1, hash2)
-- Mas ambos devem verificar corretamente
tests.assertTrue(hash.verify(senha, hash1))
tests.assertTrue(hash.verify(senha, hash2))
end,
testHashVerification = function()
local senha = "myPassword123"
local hashed = hash.encrypt(senha)
tests.assertTrue(hash.verify(senha, hashed))
tests.assertFalse(hash.verify("wrongPassword", hashed))
end,
testStringTrim = function()
tests.assertEquals(stringUtil.trim(" hello "), "hello")
tests.assertEquals(stringUtil.trim("hello"), "hello")
end,
testStringEscapePattern = function()
tests.assertEquals(stringUtil.escape_lua_pattern("1.99"), "1%.99")
end,
testStringLimit = function()
tests.assertEquals(#stringUtil.limit(string.rep("a", 20), 5), 5)
tests.assertEquals(stringUtil.limit("hello", 8192), "hello")
end
-- stringUtil.is_safe()/sanitize() não entram aqui: estão quebrados
-- hoje (ver seção "String Utilities" acima), passariam pra sempre
-- lançar erro em vez de rodar a asserção
}
tests.runSuite("Utility Tests", utilTests)
🎨 Templates e Views (etlua)
O Crescent inclui suporte a templates usando etlua (Embedded Lua), permitindo criar aplicações MVC.
Sintaxe Básica
<!-- Variáveis -->
<h1>Olá, <%= name %>!</h1>
<!-- Condicionais -->
<% if user.admin then %>
<p>Você é admin</p>
<% end %>
<!-- Loops -->
<ul>
<% for i, item in ipairs(items) do %>
<li><%= item.name %></li>
<% end %>
</ul>
Renderizar Views no Controller
local function show_profile(ctx)
local user = User:find(ctx.params.id)
-- Renderiza view com dados
return ctx.view("views/profile.etlua", {
name = user.name,
email = user.email,
created_at = user.created_at
})
end
Renderizar Template Direto
local etlua = require("crescent.utils.etlua")
-- String
local html = etlua.render("Olá, <%= name %>!", { name = "João" })
-- Arquivo
local html, err = etlua.render_file("views/home.etlua", {
title = "Home",
users = users_list
})
if not html then
print("Erro: " .. err)
end
Tags Disponíveis
<% código %>- Executa código Lua (sem output)<%= variável %>- Exibe valor (com escape HTML automático)<%- variável %>- Exibe valor (SEM escape HTML)<% código -%>- Remove quebra de linha após a tag
Exemplo Completo
Controller (src/users/controllers/users.lua):
local User = require("src.users.models.users")
local function list(ctx)
local users = User:all()
return ctx.view("views/users/list.etlua", {
users = users,
total = #users
})
end
local function show(ctx)
local user = User:find(ctx.params.id)
if not user then
return ctx.error(404, "Usuário não encontrado")
end
return ctx.view("views/users/show.etlua", {
name = user.name,
email = user.email,
role = user.role
})
end
return {
list = list,
show = show
}
View (views/users/list.etlua):
<!DOCTYPE html>
<html>
<head>
<title>Lista de Usuários</title>
</head>
<body>
<h1>Usuários (<%= total %>)</h1>
<% if total > 0 then %>
<table>
<thead>
<tr>
<th>ID</th>
<th>Nome</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<% for i, user in ipairs(users) do %>
<tr>
<td><%= user.id %></td>
<td><%= user.name %></td>
<td><%= user.email %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p>Nenhum usuário encontrado.</p>
<% end %>
</body>
</html>
Passando Funções para Views
return ctx.view("views/dashboard.etlua", {
users = users,
format_date = function(timestamp)
return os.date("%d/%m/%Y", timestamp)
end
})
Usando na view:
<p>Data: <%= format_date(os.time()) %></p>
Tratamento de Erros
local etlua = require("crescent.utils.etlua")
local html, err = etlua.render_file("views/my_view.etlua", data)
if not html then
print("Erro ao renderizar: " .. err)
return ctx.html(500, "<h1>Erro ao carregar página</h1>")
end
return ctx.html(200, html)
💡 Boas Práticas
Testes
- Organize por módulo:
tests/test-{module}.lua - Nomenclatura clara:
testCreate,testValidation - Um assert por conceito: Testes pequenos e focados
- Use mensagens descritivas: Facilita debug
- Rode antes de commit:
git pre-commit hook
Hash de Senhas
- Nunca armazene senhas em texto plano
- Use
hash.encrypt()sempre: PBKDF2 + salt - Não use SHA-256/MD5 para senhas
- Valide força da senha antes: regex, tamanho mínimo
- Considere 2FA para produção
Variáveis de Ambiente
- Nunca commite
.env: Use.env.example - Use fallbacks:
env.get('APP_PORT', 8080) - Valide valores críticos: MySQL, API keys
- Diferentes arquivos por ambiente:
.env.dev,.env.prod
📖 Próximas Seções
- Database & ORM - Models, relações e migrations
- Core Concepts - Rotas, controllers e services
- Deployment - Deploy em produção
📋 Pré-requisitos
Guia completo para colocar sua aplicação Crescent em produção.
Servidor Linux
# Ubuntu/Debian
sudo apt update
sudo apt install -y curl git build-essential libssl-dev
# CentOS/RHEL
sudo yum install -y curl git gcc make openssl-devel
Instalar Luvit
curl -L https://github.com/luvit/lit/raw/master/get-lit.sh | sh
Adicionar ao PATH:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
Instalar NGINX
# Ubuntu/Debian
sudo apt install -y nginx
# CentOS/RHEL
sudo yum install -y nginx
# Iniciar e habilitar
sudo systemctl start nginx
sudo systemctl enable nginx
Instalar MySQL/MariaDB
# Ubuntu/Debian
sudo apt install -y mysql-server
# CentOS/RHEL
sudo yum install -y mariadb-server
# Iniciar
sudo systemctl start mysql
sudo systemctl enable mysql
# Secure installation
sudo mysql_secure_installation
🔧 Configuração do Projeto
1. Clonar Projeto
cd /var/www
sudo git clone https://github.com/seu-usuario/seu-projeto.git meu-app
sudo chown -R $USER:$USER /var/www/meu-app
cd /var/www/meu-app
2. Instalar Dependências
# Crescent Framework
lit install daniel-m-tfs/crescent-framework
# Outras dependências (se houver)
lit install luvit/secure-socket
lit install luvit/json
3. Configurar Ambiente
cp .env.example .env
nano .env
# .env (produção)
APP_ENV=production
APP_PORT=8080
APP_HOST=127.0.0.1
DB_HOST=localhost
DB_PORT=3306
DB_NAME=meu_banco_producao
DB_USER=meu_usuario
DB_PASSWORD=senha_forte_aqui
JWT_SECRET=chave_super_secreta_aleatoria_64_caracteres_ou_mais
4. Criar Banco de Dados
mysql -u root -p
CREATE DATABASE meu_banco_producao CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'meu_usuario'@'localhost' IDENTIFIED BY 'senha_forte_aqui';
GRANT ALL PRIVILEGES ON meu_banco_producao.* TO 'meu_usuario'@'localhost';
FLUSH PRIVILEGES;
EXIT;
5. Executar Migrations
luvit crescent-cli migrate
🌐 NGINX Reverse Proxy
Configuração Básica
# /etc/nginx/sites-available/meu-app
server {
listen 80;
server_name meuapp.com www.meuapp.com;
# Logs
access_log /var/log/nginx/meu-app-access.log;
error_log /var/log/nginx/meu-app-error.log;
# Proxy para Luvit
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Headers importantes
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Cache bypass
proxy_cache_bypass $http_upgrade;
}
# Arquivos estáticos (se houver)
location /static {
alias /var/www/meu-app/public;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
Habilitar Site
# Criar link simbólico
sudo ln -s /etc/nginx/sites-available/meu-app /etc/nginx/sites-enabled/
# Testar configuração
sudo nginx -t
# Recarregar NGINX
sudo systemctl reload nginx
O Crescent Framework já vem com um config/nginx.conf de referência bem mais completo que o exemplo básico acima — com rate limiting por zona (limit_req_zone), cache de assets estáticos, headers de segurança e bloqueio de scanners comuns (/wp-admin, /phpmyadmin, etc). Vale usar ele como ponto de partida em vez do exemplo mínimo acima; a seção SSL abaixo já reflete a versão com SSL desse arquivo.
🔒 SSL/HTTPS com Let's Encrypt
Instalar Certbot
# Ubuntu/Debian
sudo apt install -y certbot python3-certbot-nginx
# CentOS/RHEL
sudo yum install -y certbot python3-certbot-nginx
Obter Certificado
sudo certbot --nginx -d meuapp.com -d www.meuapp.com
Configuração NGINX com SSL
Esta é a versão adaptada do config/nginx.conf que já vem no Crescent Framework — troque yourdomain.com pelo seu domínio real:
# /etc/nginx/sites-available/meu-app
upstream crescent_backend {
server 127.0.0.1:8080;
keepalive 64;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=general_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/s;
server {
listen 80;
server_name meuapp.com www.meuapp.com;
# Redirecionar para HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name meuapp.com www.meuapp.com;
# SSL
ssl_certificate /etc/letsencrypt/live/meuapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/meuapp.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
# Headers de segurança
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/meu-app-access.log;
error_log /var/log/nginx/meu-app-error.log;
client_max_body_size 10M;
# Rotas normais
location / {
limit_req zone=general_limit burst=20 nodelay;
limit_req_status 429;
proxy_pass http://crescent_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Rotas de API — limite mais permissivo
location /api/ {
limit_req zone=api_limit burst=30 nodelay;
limit_req_status 429;
proxy_pass http://crescent_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
# Rotas de auth (login/register) — limite restritivo
location ~ ^/(login|register|auth)/ {
limit_req zone=auth_limit burst=5 nodelay;
limit_req_status 429;
proxy_pass http://crescent_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# /health sem rate limit (usado pelo monitoramento externo, ver seção abaixo)
location /health {
access_log off;
proxy_pass http://crescent_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
}
# Arquivos estáticos
location /static/ {
alias /var/www/meu-app/public;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Bloqueia acesso a arquivos ocultos, backups e scanners comuns
location ~ /\. { deny all; access_log off; log_not_found off; }
location ~ ~$ { deny all; access_log off; log_not_found off; }
location ~ /(wp-admin|wp-login|phpmyadmin|admin) { deny all; access_log off; log_not_found off; }
}
Renovação Automática
# Testar renovação
sudo certbot renew --dry-run
# Crontab para renovação automática
sudo crontab -e
Adicionar:
0 0 * * * certbot renew --quiet --post-hook "systemctl reload nginx"
🔧 Systemd Service
Criar Service Unit
sudo nano /etc/systemd/system/meu-app.service
[Unit]
Description=Crescent Framework Application
After=network.target mysql.service
Wants=mysql.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/meu-app
Environment="PATH=/home/www-data/.local/bin:/usr/local/bin:/usr/bin:/bin"
Environment="APP_ENV=production"
# app.lua é o entrypoint real (faz require("./bootstrap") e chama
# app:listen()); bootstrap.lua sozinho só ajusta package.path e não sobe
# servidor nenhum — apontar o ExecStart pra ele faz o serviço encerrar
# imediatamente e ficar em loop de restart.
ExecStart=/home/www-data/.local/bin/luvit app.lua
Restart=always
RestartSec=10
StartLimitIntervalSec=60
StartLimitBurst=3
StandardOutput=journal
StandardError=journal
SyslogIdentifier=meu-app
# Recursos (opcional)
MemoryLimit=512M
CPUQuota=50%
# Segurança
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/www/meu-app
[Install]
WantedBy=multi-user.target
Isso corresponde ao config/crescent.service que já vem no Crescent Framework, com duas correções: o ExecStart de lá aponta pra um example.lua que não existe no repo (deveria ser app.lua, igual acima), e ele define tanto APP_ENV quanto NODE_ENV (resíduo de outro stack) — o framework só lê APP_ENV.
Gerenciar Service
# Recarregar systemd
sudo systemctl daemon-reload
# Habilitar (iniciar no boot)
sudo systemctl enable meu-app
# Iniciar
sudo systemctl start meu-app
# Status
sudo systemctl status meu-app
# Parar
sudo systemctl stop meu-app
# Reiniciar
sudo systemctl restart meu-app
# Logs em tempo real
sudo journalctl -u meu-app -f
📊 Monitoramento e Logs
Logs Systemd
# Últimas 100 linhas
sudo journalctl -u meu-app -n 100
# Seguir em tempo real
sudo journalctl -u meu-app -f
# Filtrar por data
sudo journalctl -u meu-app --since "2026-01-09"
# Filtrar por prioridade
sudo journalctl -u meu-app -p err
Logs NGINX
# Access log
sudo tail -f /var/log/nginx/meu-app-access.log
# Error log
sudo tail -f /var/log/nginx/meu-app-error.log
# Analisar códigos de status
awk '{print $9}' /var/log/nginx/meu-app-access.log | sort | uniq -c | sort -rn
# Top 10 IPs
awk '{print $1}' /var/log/nginx/meu-app-access.log | sort | uniq -c | sort -rn | head -10
Logs da Aplicação
-- Implementar logger
local Logger = {}
function Logger:log(level, message, data)
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
local logEntry = string.format(
"[%s] [%s] %s",
timestamp,
level,
message
)
if data then
logEntry = logEntry .. " " .. require('json').encode(data)
end
print(logEntry) -- systemd captura isso
end
function Logger:info(message, data)
self:log("INFO", message, data)
end
function Logger:error(message, data)
self:log("ERROR", message, data)
end
return Logger
⚡ Performance
NGINX Optimizations
# /etc/nginx/nginx.conf
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
# Básico
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Buffer
client_body_buffer_size 128k;
client_max_body_size 10M;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Gzip
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss;
gzip_disable "msie6";
# Cache estático
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
}
MySQL Optimization
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
# InnoDB
innodb_buffer_pool_size = 1G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
# Query cache
query_cache_size = 64M
query_cache_type = 1
# Connections
max_connections = 200
# Logs (desabilitar em produção para performance)
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
Aplicar:
sudo systemctl restart mysql
🔐 Segurança
Firewall (UFW)
# Habilitar UFW
sudo ufw enable
# Permitir SSH
sudo ufw allow 22/tcp
# Permitir HTTP/HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Verificar status
sudo ufw status
Fail2Ban
# Instalar
sudo apt install -y fail2ban
# Configurar
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
[sshd]
enabled = true
[nginx-http-auth]
enabled = true
[nginx-limit-req]
enabled = true
# Iniciar
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Status
sudo fail2ban-client status
Hardening MySQL
-- Remover usuários anônimos
DELETE FROM mysql.user WHERE User='';
-- Remover banco de teste
DROP DATABASE IF EXISTS test;
-- Permitir root apenas localmente
DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');
FLUSH PRIVILEGES;
Variáveis de Ambiente Seguras
# Nunca commite .env no Git!
# Usar variáveis do sistema
sudo nano /etc/systemd/system/meu-app.service
[Service]
Environment="DB_PASSWORD=senha_segura_aqui"
Environment="JWT_SECRET=token_secreto_aqui"
🔄 Deploy Automatizado
Script de Deploy
#!/bin/bash
# deploy.sh
set -e # Exit on error
APP_DIR="/var/www/meu-app"
BACKUP_DIR="/var/backups/meu-app"
echo "🚀 Starting deployment..."
# 1. Backup atual
echo "📦 Creating backup..."
mkdir -p $BACKUP_DIR
tar -czf $BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz $APP_DIR
# 2. Pull código
echo "📥 Pulling latest code..."
cd $APP_DIR
git pull origin main
# 3. Instalar dependências
echo "📚 Installing dependencies..."
lit install
# 4. Migrations
echo "🗄️ Running migrations..."
luvit crescent-cli migrate
# 5. Reiniciar serviço
echo "🔄 Restarting service..."
sudo systemctl restart meu-app
# 6. Verificar status
echo "✅ Checking status..."
sleep 2
sudo systemctl status meu-app --no-pager
# 7. Reload NGINX
echo "🌐 Reloading NGINX..."
sudo nginx -t && sudo systemctl reload nginx
echo "✨ Deployment complete!"
Tornar executável:
chmod +x deploy.sh
GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_IP }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/meu-app
./deploy.sh
🩺 Health Checks
Endpoint de Status
Em app.lua, guarde o horário de início antes de app:listen():
-- app.lua
_G.APP_START_TIME = os.time()
O arquivo de rotas segue a mesma convenção usada por make:routes/make:module (function(app, prefix), não um objeto "router" separado):
-- src/health/routes/health.lua
return function(app, prefix)
app:get(prefix or "/health", function(ctx)
-- Verificar banco
local db = require('crescent.database.mysql')
local dbOk = pcall(function()
db:query("SELECT 1")
end)
return ctx.json(200, {
status = "ok",
timestamp = os.date("%Y-%m-%d %H:%M:%S"),
-- os.clock() mede CPU consumida pelo processo, não tempo de
-- parede — para uptime real, compare contra o horário salvo
-- na subida do servidor
uptime_seconds = os.time() - (_G.APP_START_TIME or os.time()),
database = dbOk and "connected" or "disconnected"
})
end)
end
Monitoramento Externo
# Criar script de verificação
nano /usr/local/bin/check-app.sh
#!/bin/bash
URL="https://meuapp.com/health"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $URL)
if [ $RESPONSE -eq 200 ]; then
echo "✅ App is healthy"
exit 0
else
echo "❌ App is down (HTTP $RESPONSE)"
# Reiniciar serviço
sudo systemctl restart meu-app
exit 1
fi
chmod +x /usr/local/bin/check-app.sh
# Cron a cada 5 minutos
crontab -e
*/5 * * * * /usr/local/bin/check-app.sh >> /var/log/app-health.log 2>&1
🐛 Troubleshooting
App não inicia
# Verificar logs
sudo journalctl -u meu-app -n 100
# Verificar sintaxe Lua
luvit -e "require('bootstrap')"
# Verificar permissões
ls -la /var/www/meu-app
sudo chown -R www-data:www-data /var/www/meu-app
Erro 502 Bad Gateway
# App está rodando?
sudo systemctl status meu-app
# Porta correta?
netstat -tulpn | grep 8080
# Testar localmente
curl http://localhost:8080
Banco de dados não conecta
# MySQL está rodando?
sudo systemctl status mysql
# Testar conexão
mysql -h localhost -u meu_usuario -p meu_banco_producao
# Verificar .env
cat .env | grep DB_
Logs grandes
# Limitar tamanho do journal
sudo journalctl --vacuum-time=7d
sudo journalctl --vacuum-size=500M
# Rotação de logs NGINX
sudo nano /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 www-data adm
sharedscripts
postrotate
systemctl reload nginx > /dev/null
endscript
}
📖 Próximas Seções
- Getting Started - Instalação e início
- Core Concepts - Routes, Controllers, Services
- Database - ORM e Migrations
- CLI - Comandos disponíveis