🎉 initial commit
Some checks failed
CI / Lint (push) Failing after 39s
CI / Build (push) Has been skipped
CI / Test (push) Has been skipped
CI / Helm Lint (push) Successful in 13s

This commit is contained in:
Christian van Dijk
2026-02-23 09:47:16 +01:00
commit 94b4f31102
53 changed files with 3220 additions and 0 deletions

16
devices-api/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM nickblah/lua:5.4-luarocks-alpine
RUN apk add --no-cache gcc musl-dev make libpq git curl wget linux-headers pkgconfig
RUN luarocks install lua-cjson
RUN luarocks install luasocket
RUN luarocks install pgmoon
RUN luarocks install redis-lua
RUN luarocks install luaossl
WORKDIR /app
COPY . /app
EXPOSE 8080
CMD ["lua", "app-standalone.lua"]

39
devices-api/Makefile Normal file
View File

@@ -0,0 +1,39 @@
.PHONY: init-db dev build clean logs down
init-db:
docker-compose exec postgres psql -U devices_user -d handheld_devices -f /docker-entrypoint-initdb.d/001_create_devices.sql
build:
docker-compose build
dev:
docker-compose up -d
logs:
docker-compose logs -f api
logs-worker:
docker-compose logs -f worker
logs-all:
docker-compose logs -f
down:
docker-compose down
down-volumes:
docker-compose down -v
shell-postgres:
docker-compose exec postgres psql -U devices_user -d handheld_devices
shell-api:
docker-compose exec api sh
shell-worker:
docker-compose exec worker sh
clean: down-volumes
status:
docker-compose ps

View File

@@ -0,0 +1,724 @@
#!/usr/bin/env lua
local socket = require("socket")
local cjson = require("cjson")
local db = require("db")
local log = require("log")
-- Optional dependencies
local redis
pcall(function() redis = require("redis") end)
local digest
pcall(function() digest = require("openssl.digest") end)
local app = {}
app.port = tonumber(os.getenv("API_PORT")) or 8080
app.host = "0.0.0.0"
-- Database configuration (from db.lua)
local DB_HOST = os.getenv("DB_HOST") or "localhost"
local DB_PORT = tonumber(os.getenv("DB_PORT")) or 5432
local DB_NAME = os.getenv("DB_NAME") or "handheld_devices"
-- Redis configuration
local REDIS_HOST = os.getenv("REDIS_HOST") or "127.0.0.1"
local REDIS_PORT = tonumber(os.getenv("REDIS_PORT")) or 6379
-- Redis client with retry
local function get_redis_connection()
if not redis then return nil end
local attempts = 3
for i = 1, attempts do
local ok, red = pcall(redis.connect, REDIS_HOST, REDIS_PORT)
if ok and red then return red end
if i < attempts then
socket.sleep(math.min(2 ^ i * 0.1, 2))
end
end
return nil
end
-- Redis ping for health
local function redis_ping()
local red = get_redis_connection()
if not red then return false end
local ok, res = pcall(red.ping, red)
if ok and res == "PONG" then return true end
return false
end
-- Ensure tables exist
local function init_db()
local ok, err = db.with_retry(function()
return db.with_connection(function(conn)
conn:query([[
CREATE TABLE IF NOT EXISTS devices (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
manufacturer VARCHAR(255) NOT NULL,
release_year INTEGER,
cpu VARCHAR(255),
ram_mb INTEGER,
storage_mb INTEGER,
display_size VARCHAR(50),
battery_hours REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ratings (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
score INTEGER CHECK (score >= 1 AND score <= 5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
]])
return true
end)
end)
if not ok then
error("Failed to init DB: " .. tostring(err))
end
end
-- Seed initial data
local function seed_db()
db.with_connection(function(conn)
local res, err = conn:query("SELECT COUNT(*) as total FROM devices")
local count = 0
if res and res[1] then
count = tonumber(res[1].total) or 0
end
if count == 0 then
log.info("Seeding initial devices", { component = "seed" })
local devices = {
{
name = "Steam Deck",
manufacturer = "Valve",
release_year = 2022,
cpu = "AMD Zen 2",
ram_mb = 16384,
storage_mb = 524288,
display_size = "7-inch",
battery_hours = 4.0
},
{
name = "Nintendo Switch",
manufacturer = "Nintendo",
release_year = 2017,
cpu = "Nvidia Tegra X1",
ram_mb = 4096,
storage_mb = 32768,
display_size = "6.2-inch",
battery_hours = 5.5
},
{
name = "ROG Ally",
manufacturer = "ASUS",
release_year = 2023,
cpu = "AMD Ryzen Z1 Extreme",
ram_mb = 16384,
storage_mb = 524288,
display_size = "7-inch",
battery_hours = 3.5
}
}
for _, device in ipairs(devices) do
conn:query(
"INSERT INTO devices (name, manufacturer, release_year, cpu, ram_mb, storage_mb, display_size, battery_hours) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
device.name,
device.manufacturer,
device.release_year,
device.cpu,
device.ram_mb,
device.storage_mb,
device.display_size,
device.battery_hours
)
end
log.info("Seeding completed", { component = "seed" })
end
end)
end
-- Publish to Redis if available (with retry)
local function publish_event(event_type, data, request_id)
for attempt = 1, 3 do
local red = get_redis_connection()
if red then
local event = {
event_type = event_type,
timestamp = os.time(),
request_id = request_id,
}
for k, v in pairs(data) do
event[k] = v
end
local event_json = cjson.encode(event)
-- Push to queue (worker will consume from this using reliable pattern)
red:lpush("devices:events:queue", event_json)
-- Also publish to Pub/Sub for immediate processing (WebSockets)
local count = red:publish("devices:events", event_json)
log.info("Event published", { event_type = event_type, subscribers = count, request_id = request_id })
red:quit()
return
end
if attempt < 3 then socket.sleep(math.min(2 ^ attempt * 0.1, 2)) end
end
log.warn("Failed to publish event after retries", { event_type = event_type, request_id = request_id })
end
-- Helper to check if a value is JSON null
local function is_json_null(val)
return val == nil or val == cjson.null
end
-- WebSocket Utils
local function sha1(data)
if digest then
return digest.new("sha1"):final(data)
end
-- Fallback: if luaossl is not available, we can't do a proper handshake
-- In a production app, we should ensure it is available.
-- NOTE: This fallback doesn't actually produce a SHA1 hash, it just returns data.
-- The WebSocket handshake will fail if digest is not available.
print("[WS] Warning: openssl.digest not available, SHA1 handshake will fail")
return data
end
local function b64(data)
-- Minimal Base64
local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
return ((data:gsub('.', function(x)
local r,b='',x:byte()
for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end
return r;
end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
if (#x < 6) then return '' end
local c=0
for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end
return b:sub(c+1,c+1)
end)..({ '', '==', '=' })[#data%3+1])
end
local function encode_ws_frame(payload)
local header = string.char(0x81) -- FIN + Opcode 1 (text)
local len = #payload
if len <= 125 then
header = header .. string.char(len)
elseif len <= 65535 then
header = header .. string.char(126) .. string.char(math.floor(len / 256)) .. string.char(len % 256)
else
-- 64-bit length not implemented for simplicity
header = header .. string.char(127) .. string.rep(string.char(0), 4) ..
string.char(math.floor(len / 16777216) % 256) ..
string.char(math.floor(len / 65536) % 256) ..
string.char(math.floor(len / 256) % 256) ..
string.char(len % 256)
end
return header .. payload
end
-- Device Model
local Device = {}
function Device.all(limit, offset)
limit = limit or 10
offset = offset or 0
local res = db.with_connection(function(conn)
return conn:query("SELECT * FROM devices ORDER BY id DESC LIMIT $1 OFFSET $2", limit, offset)
end)
return res or {}
end
function Device.find(id)
local cache_key = "device:" .. id
local red = get_redis_connection()
if red then
local cached = red:get(cache_key)
if cached then
return cjson.decode(cached)
end
end
local res = db.with_connection(function(conn)
return conn:query("SELECT * FROM devices WHERE id = $1", tonumber(id))
end)
local row = res and res[1] or nil
if row and red then
red:setex(cache_key, 300, cjson.encode(row))
end
return row
end
function Device.create(data, request_id)
local res = db.with_connection(function(conn)
return conn:query(
"INSERT INTO devices (name, manufacturer, release_year, cpu, ram_mb, storage_mb, display_size, battery_hours) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id",
data.name,
data.manufacturer,
(not is_json_null(data.release_year)) and tonumber(data.release_year) or nil,
(not is_json_null(data.cpu)) and data.cpu or nil,
(not is_json_null(data.ram_mb)) and tonumber(data.ram_mb) or nil,
(not is_json_null(data.storage_mb)) and tonumber(data.storage_mb) or nil,
(not is_json_null(data.display_size)) and data.display_size or nil,
(not is_json_null(data.battery_hours)) and tonumber(data.battery_hours) or nil
)
end)
local row = res and res[1]
local device_id = row and tonumber(row.id) or nil
if device_id then
publish_event("DevicePublished", { device_id = device_id, device_name = data.name }, request_id)
return Device.find(device_id)
end
return nil
end
function Device.update(id, data, request_id)
local updated = false
db.with_connection(function(conn)
local updates = {}
local pg = conn
if not is_json_null(data.name) then table.insert(updates, "name = " .. pg:escape_literal(data.name)) end
if not is_json_null(data.manufacturer) then table.insert(updates, "manufacturer = " .. pg:escape_literal(data.manufacturer)) end
if not is_json_null(data.release_year) then table.insert(updates, "release_year = " .. tonumber(data.release_year)) end
if not is_json_null(data.cpu) then table.insert(updates, "cpu = " .. pg:escape_literal(data.cpu)) end
if not is_json_null(data.ram_mb) then table.insert(updates, "ram_mb = " .. tonumber(data.ram_mb)) end
if not is_json_null(data.storage_mb) then table.insert(updates, "storage_mb = " .. tonumber(data.storage_mb)) end
if not is_json_null(data.display_size) then table.insert(updates, "display_size = " .. pg:escape_literal(data.display_size)) end
if not is_json_null(data.battery_hours) then table.insert(updates, "battery_hours = " .. tonumber(data.battery_hours)) end
if #updates > 0 then
table.insert(updates, "updated_at = CURRENT_TIMESTAMP")
conn:query("UPDATE devices SET " .. table.concat(updates, ", ") .. " WHERE id = $1", tonumber(id))
updated = true
end
end)
local red = get_redis_connection()
if red then
red:del("device:" .. id)
end
local device = Device.find(id)
if device and updated then
publish_event("DeviceUpdated", { device_id = tonumber(id), device_name = device.name }, request_id)
end
return device
end
function Device.delete(id, request_id)
local device = Device.find(id)
db.with_connection(function(conn)
conn:query("DELETE FROM devices WHERE id = $1", tonumber(id))
end)
local red = get_redis_connection()
if red then
red:del("device:" .. id)
end
if device then
publish_event("DeviceDeleted", { device_id = tonumber(id), device_name = device.name }, request_id)
end
return true
end
function Device.get_count()
local res = db.with_connection(function(conn)
return conn:query("SELECT COUNT(*) as total FROM devices")
end)
if res and res[1] then
return tonumber(res[1].total) or 0
end
return 0
end
-- HTTP Request Parser
local function parse_request(request_line)
local method, path, version = request_line:match("^(%w+)%s+(%S+)%s+(%S+)")
return method, path, version
end
local function parse_headers(client)
local headers = {}
while true do
-- Use a small timeout for individual header lines to handle slow clients
-- or cases where headers are partially sent.
client:settimeout(0.1)
local line, err = client:receive("*l")
if not line or line == "" then break end
local key, value = line:match("^([^:]+):%s*(.*)$")
if key then
headers[key:lower()] = value
end
end
client:settimeout(0) -- Back to non-blocking
return headers
end
local function parse_query_string(query_str)
local params = {}
if query_str and query_str ~= "" then
for key, value in query_str:gmatch("([^=&]+)=([^&]*)") do
params[key] = value
end
end
return params
end
local function parse_path(full_path)
local path, query = full_path:match("^([^?]+)%??(.*)$")
return path or full_path, query or ""
end
local function generate_request_id()
return string.format("%x-%x-%x", math.random(0, 0xffff), math.random(0, 0xffff), os.time())
end
-- HTTP Response Builder
local function build_response(status, body, content_type, request_id)
content_type = content_type or "application/json"
local extra_headers = ""
if request_id then
extra_headers = "X-Request-ID: " .. request_id .. "\r\n"
end
local response = string.format(
"HTTP/1.1 %s\r\n" ..
"Content-Type: %s\r\n" ..
"Content-Length: %d\r\n" ..
"%s" ..
"Access-Control-Allow-Origin: *\r\n" ..
"Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\n" ..
"Access-Control-Allow-Headers: Content-Type, X-Request-ID\r\n" ..
"Connection: close\r\n" ..
"\r\n" ..
"%s",
status, content_type, #body, extra_headers, body
)
return response
end
-- Route Handlers
local function handle_get_devices(query_params)
local page = tonumber(query_params.page) or 1
local per_page = 10
local offset = (page - 1) * per_page
local devices = Device.all(per_page, offset)
local total = Device.get_count()
return "200 OK", cjson.encode({
data = devices,
total = total,
page = page,
per_page = per_page
})
end
local function handle_post_devices(json_data, request_id)
if not json_data.name or not json_data.manufacturer then
return "400 Bad Request", cjson.encode({ error = "Missing required fields: name, manufacturer" })
end
local device = Device.create(json_data, request_id)
if device then
return "201 Created", cjson.encode(device)
else
return "500 Internal Server Error", cjson.encode({ error = "Failed to create device" })
end
end
local function handle_get_device(id)
local device = Device.find(id)
if device then
return "200 OK", cjson.encode(device)
else
return "404 Not Found", cjson.encode({ error = "Device not found" })
end
end
local function handle_put_device(id, json_data, request_id)
local device = Device.update(id, json_data, request_id)
if device then
return "200 OK", cjson.encode(device)
else
return "404 Not Found", cjson.encode({ error = "Device not found" })
end
end
local function handle_delete_device(id, request_id)
Device.delete(id, request_id)
return "200 OK", cjson.encode({ success = true })
end
-- Health: liveness (process alive)
local function handle_health_live()
return "200 OK", cjson.encode({ status = "ok" })
end
-- Health: readiness (DB + Redis OK)
local function handle_health_ready()
local db_ok = db.ping()
local redis_ok = redis_ping()
if db_ok and redis_ok then
return "200 OK", cjson.encode({ status = "ok", db = "ok", redis = "ok" })
end
local details = { db = db_ok and "ok" or "fail", redis = redis_ok and "ok" or "fail" }
return "503 Service Unavailable", cjson.encode({ status = "degraded", details = details })
end
-- Main Request Handler
local function handle_request_with_headers(client, request_line, headers)
local method, full_path = parse_request(request_line)
local path, query_str = parse_path(full_path)
local query_params = parse_query_string(query_str)
local request_id = headers["x-request-id"] or generate_request_id()
-- Read body if POST/PUT
local body = ""
if method == "POST" or method == "PUT" then
local content_length = tonumber(headers["content-length"]) or 0
if content_length > 0 then
body = client:receive(content_length)
end
end
-- Parse JSON
local json_data = {}
if body ~= "" then
local ok, data = pcall(cjson.decode, body)
if ok then json_data = data end
end
-- Handle CORS preflight
if method == "OPTIONS" then
client:send(build_response("200 OK", "", nil, request_id))
client:close()
return
end
-- Route handling
local status, response_body = "404 Not Found", cjson.encode({ error = "Not found" })
if method == "GET" and path == "/devices" then
status, response_body = handle_get_devices(query_params)
elseif method == "POST" and path == "/devices" then
status, response_body = handle_post_devices(json_data, request_id)
elseif method == "GET" and path:match("^/devices/%d+$") then
local id = path:match("/devices/(%d+)")
status, response_body = handle_get_device(id)
elseif method == "PUT" and path:match("^/devices/%d+$") then
local id = path:match("/devices/(%d+)")
status, response_body = handle_put_device(id, json_data, request_id)
elseif method == "DELETE" and path:match("^/devices/%d+$") then
local id = path:match("/devices/(%d+)")
status, response_body = handle_delete_device(id, request_id)
elseif method == "GET" and path == "/health" then
status, response_body = "200 OK", cjson.encode({ status = "ok" })
elseif method == "GET" and path == "/health/live" then
status, response_body = handle_health_live()
elseif method == "GET" and path == "/health/ready" then
status, response_body = handle_health_ready()
end
log.info("Request", {
method = method,
path = path,
status = status:match("^(%d+)") or "?",
request_id = request_id,
})
-- Send response
local response = build_response(status, response_body, nil, request_id)
client:send(response)
client:close()
end
-- Start server
function app.start()
log.info("Handheld Devices API Server starting", {
component = "api",
port = app.port,
db = DB_NAME .. "@" .. DB_HOST .. ":" .. DB_PORT,
redis = redis and "enabled" or "disabled",
})
init_db()
seed_db()
local server = socket.bind(app.host, app.port)
if not server then
error("Failed to bind to " .. app.host .. ":" .. app.port)
end
server:settimeout(0) -- Non-blocking
log.info("Server started successfully", { component = "api" })
local clients = {}
local ws_clients = {}
-- Background Redis subscriber
local function connect_subscriber()
local red = get_redis_connection()
if red then
-- Note: redis-lua's subscribe() sets the connection into a subscription state.
local ok, err = pcall(function() return red:subscribe("devices:events") end)
if ok then
-- Set the underlying socket to a very small timeout for non-blocking feel
pcall(function()
if red.network and red.network.socket then
red.network.socket:settimeout(0.001)
end
end)
log.info("Redis subscriber connected", { component = "redis" })
else
log.error("Redis subscriber failed", { component = "redis", err = tostring(err) })
red = nil
end
end
return red
end
local red_sub = connect_subscriber()
local last_sub_reconnect = os.time()
while true do
local client = server:accept()
if client then
client:settimeout(0)
table.insert(clients, { socket = client })
end
-- Reconnect subscriber if lost
if not red_sub and os.time() - last_sub_reconnect > 5 then
red_sub = connect_subscriber()
last_sub_reconnect = os.time()
end
-- Handle HTTP clients
local to_remove = {}
for i, c in ipairs(clients) do
local line, err = c.socket:receive("*l")
if line then
local method, full_path = parse_request(line)
local headers = parse_headers(c.socket)
if headers["upgrade"] == "websocket" then
-- Handle WebSocket Handshake
local key = headers["sec-websocket-key"]
if key then
local sha1_key = sha1(key .. "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
local accept = b64(sha1_key)
local response = "HTTP/1.1 101 Switching Protocols\r\n" ..
"Upgrade: websocket\r\n" ..
"Connection: Upgrade\r\n" ..
"Sec-WebSocket-Accept: " .. accept .. "\r\n\r\n"
c.socket:send(response)
table.insert(ws_clients, c.socket)
log.info("WebSocket client connected", { component = "ws", total = #ws_clients })
else
c.socket:close()
end
table.insert(to_remove, i)
else
handle_request_with_headers(c.socket, line, headers)
table.insert(to_remove, i)
end
elseif err == "closed" then
table.insert(to_remove, i)
end
end
for i = #to_remove, 1, -1 do table.remove(clients, to_remove[i]) end
-- Handle Redis Messages -> Send to WS
-- redis-lua has no read_reply; pub/sub messages come as multibulk: [kind, channel, payload]
if red_sub then
local ok, err = pcall(function()
local sock = red_sub.network.socket
if not sock then return end
sock:settimeout(0.001)
local line, serr = sock:receive("*l")
if not line then
if serr ~= "timeout" then
error(serr or "socket read failed")
end
return
end
-- Parse RESP: *3\r\n means 3-element array
local prefix = line:sub(1, 1)
if prefix == "*" then
local count = tonumber(line:sub(2))
if count and count >= 3 then
local parts = {}
for i = 1, count do
local bline, berr = sock:receive("*l")
if not bline then error(berr or "incomplete") end
if bline:sub(1, 1) == "$" then
local len = tonumber(bline:sub(2))
local bulk = len > 0 and sock:receive(len + 2) or ""
if bulk then
parts[i] = len > 0 and bulk:sub(1, -3) or ""
end
end
end
-- Pub/sub message: ["message", channel, payload]
if parts[1] == "message" and parts[3] then
local payload = parts[3]
log.info("WebSocket broadcast", { component = "ws", payload_len = #tostring(payload) })
local frame = encode_ws_frame(payload)
local closed_ws = {}
for i, ws in ipairs(ws_clients) do
local _, send_err = ws:send(frame)
if send_err then table.insert(closed_ws, i) end
end
for i = #closed_ws, 1, -1 do table.remove(ws_clients, closed_ws[i]) end
end
end
end
end)
if not ok then
local err_str = tostring(err)
if not string.find(err_str, "timeout") and not string.find(err_str, "closed") then
log.error("Redis subscriber error", { component = "redis", err = err_str })
red_sub = nil
end
end
end
socket.sleep(0.01)
end
end
-- Run if executed directly
if arg[0]:match("app%-standalone") then
app.start()
end
return app

124
devices-api/db.lua Normal file
View File

@@ -0,0 +1,124 @@
-- PostgreSQL connection pool using pgmoon
local pgmoon = require("pgmoon")
local cjson = require("cjson")
local DB_HOST = os.getenv("DB_HOST") or "localhost"
local DB_PORT = tonumber(os.getenv("DB_PORT")) or 5432
local DB_NAME = os.getenv("DB_NAME") or "handheld_devices"
local DB_USER = os.getenv("DB_USER") or "devices_user"
local DB_PASSWORD = os.getenv("DB_PASSWORD") or "devices_password"
local DB_POOL_SIZE = tonumber(os.getenv("DB_POOL_SIZE")) or 10
local DB_CONNECT_TIMEOUT_MS = tonumber(os.getenv("DB_CONNECT_TIMEOUT_MS")) or 5000
local DB_QUERY_TIMEOUT_MS = tonumber(os.getenv("DB_QUERY_TIMEOUT_MS")) or 10000
local config = {
host = DB_HOST,
port = tostring(DB_PORT),
database = DB_NAME,
user = DB_USER,
password = DB_PASSWORD,
socket_type = "luasocket",
}
local pool = {
available = {},
in_use = {},
max_size = DB_POOL_SIZE,
}
local function create_connection()
local pg = pgmoon.new(config)
pg:settimeout(DB_CONNECT_TIMEOUT_MS)
local ok, err = pg:connect()
if not ok then
return nil, err
end
pg:settimeout(DB_QUERY_TIMEOUT_MS)
return pg
end
local function get_connection()
local conn = table.remove(pool.available)
if conn then
table.insert(pool.in_use, conn)
return conn
end
if #pool.in_use >= pool.max_size then
return nil, "connection pool exhausted"
end
local pg, err = create_connection()
if not pg then
return nil, err
end
table.insert(pool.in_use, pg)
return pg
end
local function release_connection(conn)
for i, c in ipairs(pool.in_use) do
if c == conn then
table.remove(pool.in_use, i)
if #pool.available < pool.max_size then
table.insert(pool.available, conn)
else
pcall(function() conn:disconnect() end)
end
return
end
end
end
-- Execute with connection from pool; auto-release on return
local function with_connection(fn)
local conn, err = get_connection()
if not conn then
return nil, err
end
local ok, result, result_err = pcall(function()
return fn(conn)
end)
release_connection(conn)
if not ok then
return nil, result
end
return result, result_err
end
-- Retry with exponential backoff
local function with_retry(fn, max_attempts)
max_attempts = max_attempts or 3
local attempt = 0
local last_err
while attempt < max_attempts do
attempt = attempt + 1
local result, err = fn()
if result ~= nil or (err and not (err:match("connection") or err:match("timeout"))) then
return result, err
end
last_err = err
if attempt < max_attempts then
local delay = math.min(2 ^ attempt * 100, 5000)
require("socket").sleep(delay / 1000)
end
end
return nil, last_err
end
local function ping()
return with_connection(function(conn)
local res, err = conn:query("SELECT 1")
if res and type(res) == "table" and (res[1] or #res >= 1) then
return true
end
return nil, err or "ping failed"
end)
end
return {
config = config,
get_connection = get_connection,
release_connection = release_connection,
with_connection = with_connection,
with_retry = with_retry,
ping = ping,
}

16
devices-api/log.lua Normal file
View File

@@ -0,0 +1,16 @@
-- Structured JSON logging
local cjson = require("cjson")
local function log(level, msg, fields)
fields = fields or {}
fields.level = level
fields.msg = msg
fields.time = os.date("!%Y-%m-%dT%H:%M:%SZ")
print(cjson.encode(fields))
end
return {
info = function(msg, fields) log("info", msg, fields) end,
warn = function(msg, fields) log("warn", msg, fields) end,
error = function(msg, fields) log("error", msg, fields) end,
}

View File

@@ -0,0 +1,69 @@
-- Create extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create devices table
CREATE TABLE IF NOT EXISTS devices (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
manufacturer VARCHAR(255) NOT NULL,
release_year INTEGER,
cpu VARCHAR(255),
ram_mb INTEGER,
storage_mb INTEGER,
display_size VARCHAR(50),
battery_hours REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create ratings table
CREATE TABLE IF NOT EXISTS ratings (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
score INTEGER CHECK (score >= 1 AND score <= 5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create reviews table
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create device_events table for worker logging
CREATE TABLE IF NOT EXISTS device_events (
id SERIAL PRIMARY KEY,
device_id INTEGER,
device_name VARCHAR(255),
event_type VARCHAR(100),
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create rating_events table
CREATE TABLE IF NOT EXISTS rating_events (
id SERIAL PRIMARY KEY,
device_id INTEGER,
user_id VARCHAR(255),
score INTEGER,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create review_events table
CREATE TABLE IF NOT EXISTS review_events (
id SERIAL PRIMARY KEY,
device_id INTEGER,
user_id VARCHAR(255),
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_devices_name ON devices(name);
CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer);
CREATE INDEX IF NOT EXISTS idx_ratings_device_id ON ratings(device_id);
CREATE INDEX IF NOT EXISTS idx_ratings_user_id ON ratings(user_id);
CREATE INDEX IF NOT EXISTS idx_reviews_device_id ON reviews(device_id);
CREATE INDEX IF NOT EXISTS idx_reviews_user_id ON reviews(user_id);