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 FROM eclipse-temurin:25-jre-alpine
RUN addgroup -S app && adduser -S app -G app RUN addgroup -S app && adduser -S app -G app
RUN mkdir -p /data && chown app:app /data
WORKDIR /app WORKDIR /app
COPY --from=build --chown=app:app /app/target/scala-3.7.1/demoapp.jar ./demoapp.jar 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 # 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 ## Prerequisites
- Access to `git.clouderized.com` (Gitea) - 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) ### 1. Deploy v1.0.0
- CRUD functionality — add, complete, and delete tasks
- The `/health` endpoint returning `{"status":"ok"}`
### 2. Make a visible change On the VPS:
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
```bash ```bash
git add src/main/resources/site.conf cd ~/customers/demouser/demoapp && git fetch --tags && git checkout v1.0.0
git commit -m "Update theme to blue, bump to v1.1.0" cd ~/customers/demouser && docker compose up -d --build
git push
``` ```
### 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 In the browser, add 23 tasks and delete one of the pre-seeded tasks. This simulates real user activity before an upgrade.
2. Docker builds the container image
3. The old container is replaced with the new one
### 5. See the result ### 3. Upgrade to v2.0.0
Refresh the browser. The page now shows: On the VPS:
- **Blue header** instead of teal
- Version **v1.1.0** in the top-right
- Larger font size
### 6. Reset for the next demo
```bash ```bash
git revert HEAD --no-edit cd ~/customers/demouser/demoapp && git checkout v2.0.0
git push 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 ## Config Reference
All visual changes are driven by `src/main/resources/site.conf`: All visual changes are driven by `src/main/resources/site.conf`:
| Key | Default | What it controls | | Key | v1.0.0 | v2.0.0 | What it controls |
|-----|---------|-----------------| |-----|--------|--------|-----------------|
| `app.name` | `"DemoCust Tasks"` | Header title | | `app.version` | `"1.0.0"` | `"2.0.0"` | Version badge in header |
| `app.version` | `"1.0.0"` | Version badge in header | | `app.theme.background` | `"#0F766E"` (teal) | `"#1D4ED8"` (blue) | Header and button color |
| `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 |
## Endpoints ## Endpoints
@@ -105,6 +91,5 @@ All visual changes are driven by `src/main/resources/site.conf`:
## Notes ## 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. - 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( libraryDependencies ++= Seq(
"com.greenfossil" %% "thorium" % thoriumVersion, "com.greenfossil" %% "thorium" % thoriumVersion,
"com.typesafe" % "config" % "1.4.3", "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"), assembly / mainClass := Some("Main"),

View File

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

View File

@@ -1,162 +1,10 @@
import com.sun.net.httpserver.{HttpExchange, HttpServer} import com.greenfossil.thorium.{*, given}
import java.net.InetSocketAddress
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
object Main: object Main:
def main(args: Array[String]): Unit = def main(args: Array[String]): Unit =
val port = sys.env.getOrElse("PORT", "8080").toInt val port = sys.env.getOrElse("PORT", "8080").toInt
val server = HttpServer.create(InetSocketAddress(port), 0) Server(port)
.addServices(TaskController)
server.createContext("/health", exchange => { .start()
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()
println(s"demoapp started on port $port") println(s"demoapp started on port $port")
println(s" app: ${SiteConfig.appName} v${SiteConfig.appVersion}") 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 import java.util.UUID
object TaskController: object TaskController:
def createTask(title: String): Unit = private val titleField = Mapping("title", text)
if title.trim.nonEmpty then TaskStore.add(title)
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)) try TaskStore.toggle(UUID.fromString(id))
catch case _: IllegalArgumentException => () 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)) try TaskStore.delete(UUID.fromString(id))
catch case _: IllegalArgumentException => () 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.UUID
import java.util.concurrent.ConcurrentHashMap import java.sql.{Connection, DriverManager}
import scala.jdk.CollectionConverters.*
case class Task(id: UUID, title: String, completed: Boolean) case class Task(id: UUID, title: String, completed: Boolean)
object TaskStore: 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 { locally {
val samples = List( val conn = connect()
"Deploy Scala app to Clouderized", try
"Set up CI/CD with Gitea Actions", val stmt = conn.createStatement()
"Configure custom domain" stmt.execute(
) "CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0)"
samples.foreach { title => )
val id = UUID.randomUUID() val rs = stmt.executeQuery("SELECT COUNT(*) FROM tasks")
tasks.put(id, Task(id, title, false)) 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] = 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 = def add(title: String): Task =
val id = UUID.randomUUID() val id = UUID.randomUUID()
val task = Task(id, title.trim, false) val conn = connect()
tasks.put(id, task) try
task 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 = def toggle(id: UUID): Unit =
Option(tasks.get(id)).foreach { task => val conn = connect()
tasks.put(id, task.copy(completed = !task.completed)) 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 = 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] = 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()