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>
This commit is contained in:
davidtio
2026-02-28 14:15:17 +08:00
parent 252ed3b4ec
commit a012c69693
3 changed files with 123 additions and 91 deletions

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,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()