Compare commits

..

7 Commits

Author SHA1 Message Date
davidtio
905a9112f8 fix: correct Thorium API usage
- Import Mapping.* instead of data.mapping.* (mapping/text live in companion object)
- Use Mapping("field", text) for single-field binding instead of mapping[String]
- Pass request explicitly: bindFromRequest()(using request)
- Replace .as(String) with .as(MediaType.JSON) / .as(MediaType.HTML_UTF_8)
- Remove 'implicit' from Action lambda (Scala 3 uses 'using' instead)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:42:22 +08:00
davidtio
6692d5a053 refactor: migrate from Java HttpServer to Thorium framework
Replace com.sun.net.httpserver routing with Thorium's Server/Action/annotation
pattern. TaskController now owns @Get/@Post routes, form binding via data-mapping,
and the renderPage()/esc() helpers moved from Main. Main reduced to a 9-line
Thorium server entry point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:31:18 +08:00
davidtio
9f0ecf67da fix: revert uid=1000 pin — compose sets user: "0" instead
In rootless Docker, uid=0 in-container = cldrzd on host (not privileged).
Pinning to uid=1000 in-container mapped to host uid=100999 (phantom UID),
which cannot write to the cldrzd-owned data directory.

The Dockerfile USER directive is overridden by compose user: "0" anyway,
so revert to a standard non-root app user without explicit uid/gid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:05:19 +08:00
davidtio
776a6e8973 Pin app user to uid/gid=1000 for Clouderized bind mount compatibility
Clouderized platform convention: all containers run as uid=1000/gid=1000
so data directories (owned by host cldrzd user) are writable without
insecure world-write permissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:47:21 +08:00
davidtio
4760fd22c0 Bump to v2.0.0: blue theme upgrade demo
Changes version to 2.0.0 and header color from teal (#0F766E) to blue (#1D4ED8).
Used to demonstrate a zero-data-loss upgrade on Clouderized.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:59:31 +08:00
davidtio
371f2c949d Add SQLite persistence via Docker named volume
Tasks are now stored in SQLite (DB_PATH env var, defaults to ./tasks.db).
Pre-seeding runs only when the table is empty, so upgrades preserve data.
This is the v1.0.0 baseline for the persistence demo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:59:26 +08:00
davidtio
78a5f98f11 Fix /data permissions: create directory owned by app user
Docker mounts named volumes as root by default. Without pre-creating /data
in the image with correct ownership, the app user cannot write tasks.db,
causing a 502 on any route that touches TaskStore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:59:20 +08:00
7 changed files with 248 additions and 253 deletions

View File

@@ -20,6 +20,7 @@ RUN sbt "set test in assembly := false" assembly
FROM eclipse-temurin:25-jre-alpine
RUN addgroup -S app && adduser -S app -G app
RUN mkdir -p /data && chown app:app /data
WORKDIR /app
COPY --from=build --chown=app:app /app/target/scala-3.7.1/demoapp.jar ./demoapp.jar

121
README.md
View File

@@ -1,97 +1,83 @@
# demoapp — Clouderized Demo App
A task manager built with Scala 3 + Thorium, used to demo the full Clouderized deployment cycle: edit config, push, rebuild, see changes live.
A task manager built with Scala 3 + Thorium, used to demonstrate Clouderized's core value prop: **stateful app upgrades with zero data loss**.
Tasks are persisted in SQLite backed by a Docker named volume (`demouser-data`). Rebuilding or upgrading the container leaves all user data intact.
## Prerequisites
- Access to `git.clouderized.com` (Gitea)
- The app deployed at `demoapp.clouderized.com`
- The app deployed at `demouser.clouderized.com`
- VPS SSH access: `ssh -i ssh/id_rsa -p 9876 cldrzd@194.233.75.123`
## Running the Demo
---
### 1. Show the live app
## v1 → v2 Upgrade Demo (with data persistence)
Open `https://demoapp.clouderized.com` in a browser. Point out:
This is the primary demo sequence. It shows that Clouderized can upgrade a running app without wiping user data.
- The **teal header** with app name and version (v1.0.0)
- CRUD functionality — add, complete, and delete tasks
- The `/health` endpoint returning `{"status":"ok"}`
### 1. Deploy v1.0.0
### 2. Make a visible change
Edit `src/main/resources/site.conf`:
```diff
app {
- name = "DemoCust Tasks"
- version = "1.0.0"
+ name = "Clouderized Tasks"
+ version = "1.1.0"
theme {
- background = "#0F766E"
+ background = "#1E40AF"
font = "DM Sans"
- fontSize = "16px"
+ fontSize = "18px"
}
}
```
This changes the header from **teal to blue**, bumps the version, and increases font size.
### 3. Push the change
On the VPS:
```bash
git add src/main/resources/site.conf
git commit -m "Update theme to blue, bump to v1.1.0"
git push
cd ~/customers/demouser/demoapp && git fetch --tags && git checkout v1.0.0
cd ~/customers/demouser && docker compose up -d --build
```
### 4. Watch the rebuild
Visit `https://demouser.clouderized.com`:
- **Teal header**, version badge shows **v1.0.0**
- Three pre-seeded tasks are present
- `/health` returns `{"status":"ok"}`
Go to Gitea Actions at `git.clouderized.com/cldrzd/demoapp/actions` and watch the pipeline:
### 2. Add user data
1. `sbt assembly` builds the fat JAR
2. Docker builds the container image
3. The old container is replaced with the new one
In the browser, add 23 tasks and delete one of the pre-seeded tasks. This simulates real user activity before an upgrade.
### 5. See the result
### 3. Upgrade to v2.0.0
Refresh the browser. The page now shows:
- **Blue header** instead of teal
- Version **v1.1.0** in the top-right
- Larger font size
### 6. Reset for the next demo
On the VPS:
```bash
git revert HEAD --no-edit
git push
cd ~/customers/demouser/demoapp && git checkout v2.0.0
cd ~/customers/demouser && docker compose up -d --build
```
The app rebuilds and returns to its original teal theme at v1.0.0.
Revisit `https://demouser.clouderized.com`:
- **Blue header**, version badge shows **v2.0.0**
- All tasks added in step 2 are still there
- Pre-seeded tasks are not re-added (seeding is skipped when the table is non-empty)
### 4. Verify persistence
```bash
# On the VPS — confirm the volume exists and is mounted
docker volume inspect demouser-data
```
---
## How Persistence Works
| Component | Detail |
|-----------|--------|
| Storage | SQLite at `/data/tasks.db` inside the container |
| Volume | Docker named volume `demouser-data` mounted at `/data` |
| Env var | `DB_PATH=/data/tasks.db` (set in `docker-compose.yml`) |
| Seeding | Pre-seed runs only when the `tasks` table is empty |
The named volume `demouser-data` survives `docker compose up --build` — data is never lost on rebuild.
---
## Config Reference
All visual changes are driven by `src/main/resources/site.conf`:
| Key | Default | What it controls |
|-----|---------|-----------------|
| `app.name` | `"DemoCust Tasks"` | Header title |
| `app.version` | `"1.0.0"` | Version badge in header |
| `app.theme.background` | `"#0F766E"` (teal) | Header and button color |
| `app.theme.font` | `"DM Sans"` | Body font family |
| `app.theme.fontSize` | `"16px"` | Base font size |
## Suggested color swaps for demos
| Color | Hex | Effect |
|-------|-----|--------|
| Teal (default) | `#0F766E` | Clouderized brand color |
| Blue | `#1E40AF` | Obvious visual change |
| Purple | `#7C3AED` | Another clear contrast |
| Red | `#DC2626` | High-contrast demo |
| Key | v1.0.0 | v2.0.0 | What it controls |
|-----|--------|--------|-----------------|
| `app.version` | `"1.0.0"` | `"2.0.0"` | Version badge in header |
| `app.theme.background` | `"#0F766E"` (teal) | `"#1D4ED8"` (blue) | Header and button color |
## Endpoints
@@ -105,6 +91,5 @@ All visual changes are driven by `src/main/resources/site.conf`:
## Notes
- Task storage is **in-memory** — it resets on every redeploy, which keeps the demo clean.
- The app runs on the **Pro tier** (2GB RAM, 2 vCPU) since JVM needs the headroom.
- Three sample tasks are pre-seeded on startup.
- Local dev uses `./tasks.db` (relative path) when `DB_PATH` is not set.

View File

@@ -13,7 +13,8 @@ lazy val root = project
libraryDependencies ++= Seq(
"com.greenfossil" %% "thorium" % thoriumVersion,
"com.typesafe" % "config" % "1.4.3",
"ch.qos.logback" % "logback-classic" % "1.5.6"
"ch.qos.logback" % "logback-classic" % "1.5.6",
"org.xerial" % "sqlite-jdbc" % "3.47.1.0"
),
assembly / mainClass := Some("Main"),

View File

@@ -1,8 +1,8 @@
app {
name = "DemoCust Tasks"
version = "1.0.0"
version = "2.0.0"
theme {
background = "#0F766E"
background = "#1D4ED8"
font = "DM Sans"
fontSize = "16px"
}

View File

@@ -1,162 +1,10 @@
import com.sun.net.httpserver.{HttpExchange, HttpServer}
import java.net.InetSocketAddress
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import com.greenfossil.thorium.{*, given}
object Main:
def main(args: Array[String]): Unit =
val port = sys.env.getOrElse("PORT", "8080").toInt
val server = HttpServer.create(InetSocketAddress(port), 0)
server.createContext("/health", exchange => {
val response = """{"status":"ok"}"""
exchange.getResponseHeaders.set("Content-Type", "application/json")
exchange.sendResponseHeaders(200, response.getBytes.length)
val os = exchange.getResponseBody
os.write(response.getBytes)
os.close()
})
server.createContext("/tasks", exchange => {
val method = exchange.getRequestMethod
val path = exchange.getRequestURI.getPath
(method, path) match
case ("POST", "/tasks") =>
val body = String(exchange.getRequestBody.readAllBytes(), StandardCharsets.UTF_8)
val title = parseFormField(body, "title")
TaskController.createTask(title)
redirect(exchange, "/")
case ("POST", p) if p.endsWith("/toggle") =>
val id = p.stripPrefix("/tasks/").stripSuffix("/toggle")
TaskController.toggleTask(id)
redirect(exchange, "/")
case ("POST", p) if p.endsWith("/delete") =>
val id = p.stripPrefix("/tasks/").stripSuffix("/delete")
TaskController.deleteTask(id)
redirect(exchange, "/")
case _ =>
exchange.sendResponseHeaders(404, -1)
exchange.close()
})
server.createContext("/", exchange => {
val path = exchange.getRequestURI.getPath
if path == "/" || path.isEmpty then
val html = renderPage()
exchange.getResponseHeaders.set("Content-Type", "text/html; charset=utf-8")
exchange.sendResponseHeaders(200, html.getBytes(StandardCharsets.UTF_8).length)
val os = exchange.getResponseBody
os.write(html.getBytes(StandardCharsets.UTF_8))
os.close()
else
exchange.sendResponseHeaders(404, -1)
exchange.close()
})
server.setExecutor(null)
server.start()
Server(port)
.addServices(TaskController)
.start()
println(s"demoapp started on port $port")
println(s" app: ${SiteConfig.appName} v${SiteConfig.appVersion}")
private def redirect(exchange: HttpExchange, location: String): Unit =
exchange.getResponseHeaders.set("Location", location)
exchange.sendResponseHeaders(303, -1)
exchange.close()
private def parseFormField(body: String, field: String): String =
body.split("&").map(_.split("=", 2)).collectFirst {
case Array(k, v) if k == field =>
URLDecoder.decode(v, StandardCharsets.UTF_8)
}.getOrElse("")
private def esc(s: String): String =
s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;")
private def renderPage(): String =
val cfg = SiteConfig
val tasks = TaskStore.all()
val totalCount = tasks.size
val doneCount = tasks.count(_.completed)
val taskRows = tasks.map { t =>
val checkedClass = if t.completed then "line-through text-gray-400" else ""
val checkIcon = if t.completed then "&#10003;" else ""
s"""
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 group transition-colors">
<form method="POST" action="/tasks/${t.id}/toggle" class="flex-none">
<button type="submit"
class="w-6 h-6 rounded-full border-2 ${if t.completed then "bg-emerald-500 border-emerald-500 text-white" else "border-gray-300 hover:border-teal-500"} flex items-center justify-center text-xs transition-colors">
$checkIcon
</button>
</form>
<span class="flex-1 $checkedClass">${esc(t.title)}</span>
<form method="POST" action="/tasks/${t.id}/delete" class="flex-none opacity-0 group-hover:opacity-100 transition-opacity">
<button type="submit" class="text-red-400 hover:text-red-600 text-sm px-2 py-1 rounded hover:bg-red-50 transition-colors">
Delete
</button>
</form>
</div>"""
}.mkString("\n")
s"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(cfg.appName)}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: '${cfg.font}', sans-serif; font-size: ${cfg.fontSize}; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<header style="background-color: ${cfg.bgColor};" class="shadow-lg">
<div class="max-w-2xl mx-auto px-6 py-5 flex items-center justify-between">
<h1 class="text-white text-2xl font-bold" style="font-family: 'Instrument Serif', serif;">
${esc(cfg.appName)}
</h1>
<span class="text-white/70 text-sm font-mono">v${esc(cfg.appVersion)}</span>
</div>
</header>
<main class="max-w-2xl mx-auto px-6 py-8">
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-800">Tasks</h2>
<span class="text-sm text-gray-500">$doneCount / $totalCount completed</span>
</div>
<div class="space-y-1">
$taskRows
${if tasks.isEmpty then """<p class="text-gray-400 text-center py-8">No tasks yet. Add one below!</p>""" else ""}
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Add Task</h2>
<form method="POST" action="/tasks" class="flex gap-3">
<input type="text" name="title" placeholder="What needs to be done?"
required
class="flex-1 border-2 border-gray-200 rounded-lg px-4 py-2 focus:border-teal-600 focus:ring-2 focus:ring-teal-600/10 outline-none transition-colors"
style="border-color: #E2E8F0;">
<button type="submit"
class="px-6 py-2 rounded-lg text-white font-medium hover:-translate-y-0.5 hover:shadow-lg transition-all duration-300"
style="background-color: ${cfg.bgColor};">
Add
</button>
</form>
</div>
<footer class="text-center text-gray-400 text-xs mt-8">
Powered by <span class="font-semibold">Clouderized</span> &middot; Thorium/Scala
</footer>
</main>
</body>
</html>"""

View File

@@ -1,14 +1,128 @@
import com.greenfossil.thorium.{*, given}
import com.greenfossil.data.mapping.Mapping
import com.greenfossil.data.mapping.Mapping.*
import com.linecorp.armeria.common.MediaType
import com.linecorp.armeria.server.annotation.{Get, Post, Param}
import java.util.UUID
object TaskController:
def createTask(title: String): Unit =
if title.trim.nonEmpty then TaskStore.add(title)
private val titleField = Mapping("title", text)
def toggleTask(id: String): Unit =
@Get("/health")
def health = Action: _ =>
Ok("""{"status":"ok"}""").as(MediaType.JSON)
@Get("/")
def index = Action: _ =>
Ok(renderPage()).as(MediaType.HTML_UTF_8)
@Post("/tasks")
def create = Action: request =>
val bound = titleField.bindFromRequest()(using request)
if bound.hasErrors then Redirect("/")
else
val title = bound.typedValueOpt.get
if title.trim.nonEmpty then TaskStore.add(title)
Redirect("/")
@Post("/tasks/:id/toggle")
def toggle(@Param id: String) = Action: _ =>
try TaskStore.toggle(UUID.fromString(id))
catch case _: IllegalArgumentException => ()
Redirect("/")
def deleteTask(id: String): Unit =
@Post("/tasks/:id/delete")
def delete(@Param id: String) = Action: _ =>
try TaskStore.delete(UUID.fromString(id))
catch case _: IllegalArgumentException => ()
Redirect("/")
// ── rendering helpers (moved from Main) ──────────────────────────────────
private def esc(s: String): String =
s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;")
private def renderPage(): String =
val cfg = SiteConfig
val tasks = TaskStore.all()
val totalCount = tasks.size
val doneCount = tasks.count(_.completed)
val taskRows = tasks.map { t =>
val checkedClass = if t.completed then "line-through text-gray-400" else ""
val checkIcon = if t.completed then "&#10003;" else ""
s"""
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 group transition-colors">
<form method="POST" action="/tasks/${t.id}/toggle" class="flex-none">
<button type="submit"
class="w-6 h-6 rounded-full border-2 ${if t.completed then "bg-emerald-500 border-emerald-500 text-white" else "border-gray-300 hover:border-teal-500"} flex items-center justify-center text-xs transition-colors">
$checkIcon
</button>
</form>
<span class="flex-1 $checkedClass">${esc(t.title)}</span>
<form method="POST" action="/tasks/${t.id}/delete" class="flex-none opacity-0 group-hover:opacity-100 transition-opacity">
<button type="submit" class="text-red-400 hover:text-red-600 text-sm px-2 py-1 rounded hover:bg-red-50 transition-colors">
Delete
</button>
</form>
</div>"""
}.mkString("\n")
s"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(cfg.appName)}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: '${cfg.font}', sans-serif; font-size: ${cfg.fontSize}; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<header style="background-color: ${cfg.bgColor};" class="shadow-lg">
<div class="max-w-2xl mx-auto px-6 py-5 flex items-center justify-between">
<h1 class="text-white text-2xl font-bold" style="font-family: 'Instrument Serif', serif;">
${esc(cfg.appName)}
</h1>
<span class="text-white/70 text-sm font-mono">v${esc(cfg.appVersion)}</span>
</div>
</header>
<main class="max-w-2xl mx-auto px-6 py-8">
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-800">Tasks</h2>
<span class="text-sm text-gray-500">$doneCount / $totalCount completed</span>
</div>
<div class="space-y-1">
$taskRows
${if tasks.isEmpty then """<p class="text-gray-400 text-center py-8">No tasks yet. Add one below!</p>""" else ""}
</div>
</div>
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Add Task</h2>
<form method="POST" action="/tasks" class="flex gap-3">
<input type="text" name="title" placeholder="What needs to be done?"
required
class="flex-1 border-2 border-gray-200 rounded-lg px-4 py-2 focus:border-teal-600 focus:ring-2 focus:ring-teal-600/10 outline-none transition-colors"
style="border-color: #E2E8F0;">
<button type="submit"
class="px-6 py-2 rounded-lg text-white font-medium hover:-translate-y-0.5 hover:shadow-lg transition-all duration-300"
style="background-color: ${cfg.bgColor};">
Add
</button>
</form>
</div>
<footer class="text-center text-gray-400 text-xs mt-8">
Powered by <span class="font-semibold">Clouderized</span> &middot; Thorium/Scala
</footer>
</main>
</body>
</html>"""

View File

@@ -1,41 +1,87 @@
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import scala.jdk.CollectionConverters.*
import java.sql.{Connection, DriverManager}
case class Task(id: UUID, title: String, completed: Boolean)
object TaskStore:
private val tasks = ConcurrentHashMap[UUID, Task]()
private val dbPath = sys.env.getOrElse("DB_PATH", "./tasks.db")
private val url = s"jdbc:sqlite:$dbPath"
// Pre-seed sample tasks
Class.forName("org.sqlite.JDBC")
private def connect(): Connection = DriverManager.getConnection(url)
// Initialize schema and pre-seed if empty
locally {
val samples = List(
"Deploy Scala app to Clouderized",
"Set up CI/CD with Gitea Actions",
"Configure custom domain"
)
samples.foreach { title =>
val id = UUID.randomUUID()
tasks.put(id, Task(id, title, false))
}
val conn = connect()
try
val stmt = conn.createStatement()
stmt.execute(
"CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0)"
)
val rs = stmt.executeQuery("SELECT COUNT(*) FROM tasks")
val count = rs.getInt(1)
if count == 0 then
val samples = List(
"Deploy Scala app to Clouderized",
"Set up CI/CD with Gitea Actions",
"Configure custom domain"
)
val ps = conn.prepareStatement("INSERT INTO tasks (id, title, completed) VALUES (?, ?, 0)")
samples.foreach { title =>
ps.setString(1, UUID.randomUUID().toString)
ps.setString(2, title)
ps.executeUpdate()
}
finally conn.close()
}
def all(): List[Task] =
tasks.values().asScala.toList.sortBy(_.title)
val conn = connect()
try
val rs = conn.createStatement().executeQuery("SELECT id, title, completed FROM tasks ORDER BY title")
val buf = collection.mutable.ListBuffer.empty[Task]
while rs.next() do
buf += Task(UUID.fromString(rs.getString("id")), rs.getString("title"), rs.getInt("completed") == 1)
buf.toList
finally conn.close()
def add(title: String): Task =
val id = UUID.randomUUID()
val task = Task(id, title.trim, false)
tasks.put(id, task)
task
val conn = connect()
try
val ps = conn.prepareStatement("INSERT INTO tasks (id, title, completed) VALUES (?, ?, 0)")
ps.setString(1, id.toString)
ps.setString(2, title.trim)
ps.executeUpdate()
Task(id, title.trim, false)
finally conn.close()
def toggle(id: UUID): Unit =
Option(tasks.get(id)).foreach { task =>
tasks.put(id, task.copy(completed = !task.completed))
}
val conn = connect()
try
val ps = conn.prepareStatement(
"UPDATE tasks SET completed = CASE WHEN completed = 1 THEN 0 ELSE 1 END WHERE id = ?"
)
ps.setString(1, id.toString)
ps.executeUpdate()
finally conn.close()
def delete(id: UUID): Unit =
tasks.remove(id)
val conn = connect()
try
val ps = conn.prepareStatement("DELETE FROM tasks WHERE id = ?")
ps.setString(1, id.toString)
ps.executeUpdate()
finally conn.close()
def get(id: UUID): Option[Task] =
Option(tasks.get(id))
val conn = connect()
try
val ps = conn.prepareStatement("SELECT id, title, completed FROM tasks WHERE id = ?")
ps.setString(1, id.toString)
val rs = ps.executeQuery()
if rs.next() then
Some(Task(UUID.fromString(rs.getString("id")), rs.getString("title"), rs.getInt("completed") == 1))
else None
finally conn.close()