feat: add ephemeral-counter and ephemeral-todos examples#41
Conversation
Demonstrate WithEphemeralState() from livetemplate v0.8.11 (#299). - ephemeral-counter: in-memory store as source of truth, state rebuilt from it on every request. No SQLite — just a mutex-protected counter. - ephemeral-todos: SQLite as source of truth, CRUD with ephemeral state. Supports both WebSocket and HTTP-only modes. - Both include chromedp E2E tests following existing patterns. - Updated go.mod to livetemplate v0.8.11. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds two new LiveTemplate examples showcasing the new WithEphemeralState() option, plus supporting repo updates (deps/docs/scripts) so the examples build and run in CI like the rest of the examples suite.
Changes:
- Add
ephemeral-counter/example using an in-memory mutex-protected “DB” as the canonical store withWithEphemeralState(). - Add
ephemeral-todos/example using SQLite as the canonical store withWithEphemeralState(), plus E2E coverage. - Bump
github.com/livetemplate/livetemplatetov0.8.11and register the new examples in README +test-all.sh.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
test-all.sh |
Adds the two new examples to the WORKING_EXAMPLES list so they are built/tested. |
README.md |
Documents the new examples in the examples table. |
go.mod |
Updates LiveTemplate dependency to v0.8.11 (for WithEphemeralState()). |
go.sum |
Records new module sums for the updated LiveTemplate version. |
ephemeral-counter/main.go |
New in-memory ephemeral-state counter server using WithEphemeralState(). |
ephemeral-counter/counter.tmpl |
Template for the ephemeral counter UI and client bootstrapping. |
ephemeral-counter/counter_test.go |
Chromedp E2E test validating refresh behavior in ephemeral mode. |
ephemeral-todos/main.go |
New SQLite-backed ephemeral-state todos server + optional WebSocket disable via env var. |
ephemeral-todos/todos.tmpl |
Template for the todos UI and client bootstrapping. |
ephemeral-todos/todos_test.go |
Chromedp E2E test for initial render, add, and refresh-from-DB behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <form> | ||
| <fieldset role="group"> | ||
| <input type="text" name="title" placeholder="What needs to be done?" required> | ||
| <button type="submit">Add</button> | ||
| </fieldset> |
There was a problem hiding this comment.
The Add form defaults to GET because it omits method="POST". For the HTTP-only / progressive-enhancement behavior (and consistency with other examples), set method="POST" so submissions route through LiveTemplate actions without relying on query params.
| <ul> | ||
| {{range .Items}} | ||
| <li> | ||
| <label> | ||
| <input type="checkbox" name="toggle" value="{{.ID}}" {{if .Done}}checked{{end}}> | ||
| {{if .Done}}<s>{{.Title}}</s>{{else}}{{.Title}}{{end}} | ||
| </label> | ||
| <button name="delete" value="{{.ID}}" class="outline secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">Delete</button> | ||
| </li> |
There was a problem hiding this comment.
Toggle/Delete controls are not inside a
and the checkbox doesn’t auto-submit, so Toggle() / Delete() won’t be triggered in non-JS / HTTP-only mode (and may not send a stable id parameter). Wrap each item’s toggle/delete in method="POST" forms (with a hidden id field) and submit the toggle form on checkbox change, as done in other todo examples.| func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) { | ||
| id, _ := strconv.Atoi(ctx.GetString("value")) | ||
| _, err := c.DB.Exec("UPDATE todos SET done = NOT done WHERE id = ?", id) |
There was a problem hiding this comment.
Toggle()/Delete() parse the id from ctx.GetString("value") and ignore Atoi errors. This makes the parameter contract unclear and can silently operate on id=0 when the param is missing/invalid. Prefer an explicit parameter (e.g., "id") and return a validation error when conversion fails.
| db, err := sql.Open("sqlite", "file:todos.db?cache=shared&mode=rwc") | ||
| if err != nil { | ||
| slog.Error("Failed to open database", "error", err) |
There was a problem hiding this comment.
initDB() opens a relative on-disk DB (todos.db) in the repo/example directory. This will leave an untracked file after running the example/tests and can make the E2E test flaky (it expects an empty DB on initial load). Consider defaulting to an in-memory DB or a temp dir path, and/or allow overriding the DB path via env var so tests can isolate state.
| db, err := sql.Open("sqlite", "file:todos.db?cache=shared&mode=rwc") | |
| if err != nil { | |
| slog.Error("Failed to open database", "error", err) | |
| // Allow overriding the DB path/DSN via environment variable so tests and | |
| // deployments can control persistence and isolation. By default, use an | |
| // in-memory SQLite database to avoid creating on-disk files in the repo. | |
| dsn := os.Getenv("EPHEMERAL_TODOS_DB") | |
| if dsn == "" { | |
| dsn = "file:memdb1?mode=memory&cache=shared" | |
| } | |
| db, err := sql.Open("sqlite", dsn) | |
| if err != nil { | |
| slog.Error("Failed to open database", "error", err, "dsn", dsn) |
| return state, err | ||
| } | ||
| state.Items = append(state.Items, t) | ||
| } |
There was a problem hiding this comment.
Mount() iterates rows.Next() but never checks rows.Err() after the loop. Add a rows.Err() check to surface any scan/iteration errors from the driver.
| } | |
| } | |
| if err := rows.Err(); err != nil { | |
| return state, err | |
| } |
| return state, err | ||
| } | ||
|
|
||
| id, _ := result.LastInsertId() |
There was a problem hiding this comment.
Submit() ignores the error return from result.LastInsertId(). If the driver can’t provide it, id may be 0 and the returned state will diverge from the DB. Handle the error (or query the inserted row id) before appending to state.Items.
| id, _ := result.LastInsertId() | |
| id, err := result.LastInsertId() | |
| if err != nil { | |
| return state, err | |
| } |
| func TestEphemeralTodosE2E(t *testing.T) { | ||
| if testing.Short() { | ||
| t.Skip("Skipping E2E test in short mode") | ||
| } | ||
|
|
There was a problem hiding this comment.
This example claims to support HTTP-only mode via LVT_WEBSOCKET_DISABLED=true, but the E2E test only exercises the WebSocket path (WaitForWebSocketReady). Add a test variant that starts the server with LVT_WEBSOCKET_DISABLED=true and waits for client readiness in HTTP mode (similar to ws-disabled/ws_disabled_test.go) to prevent regressions.
Summary
ephemeral-counter/— demonstratesWithEphemeralState()with an in-memory store as the source of truth. No SQLite dependency — just a mutex-protected counter.ephemeral-todos/— demonstratesWithEphemeralState()with SQLite for CRUD. Supports both WebSocket and HTTP-only modes viaLVT_WEBSOCKET_DISABLED=true.go.modto livetemplate v0.8.11 (which includesWithEphemeralState())What is Ephemeral State?
With
WithEphemeralState(), LiveTemplate skips session persistence entirely. Every request starts with fresh state rebuilt viaMount(). The database (or any external store) is the canonical source of truth — LiveTemplate state is just a rendering projection.Test plan
go build -o /dev/null ./ephemeral-counter/ ./ephemeral-todos/)Implements examples for livetemplate/livetemplate#299.
🤖 Generated with Claude Code