From a012c69693fa0efe89b19edb95dec72211d9c604 Mon Sep 17 00:00:00 2001 From: davidtio Date: Sat, 28 Feb 2026 14:15:17 +0800 Subject: [PATCH] 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 --- README.md | 121 +++++++++++++++------------------ build.sbt | 3 +- src/main/scala/TaskStore.scala | 90 ++++++++++++++++++------ 3 files changed, 123 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index f14d1b8..cc2184f 100644 --- a/README.md +++ b/README.md @@ -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 2–3 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. diff --git a/build.sbt b/build.sbt index 9901075..35e88ed 100644 --- a/build.sbt +++ b/build.sbt @@ -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"), diff --git a/src/main/scala/TaskStore.scala b/src/main/scala/TaskStore.scala index 0732ee0..c4d4092 100644 --- a/src/main/scala/TaskStore.scala +++ b/src/main/scala/TaskStore.scala @@ -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()