Skip to content

feat: add ephemeral-counter and ephemeral-todos examples#41

Merged
adnaan merged 1 commit intomainfrom
feat/ephemeral-examples
Mar 31, 2026
Merged

feat: add ephemeral-counter and ephemeral-todos examples#41
adnaan merged 1 commit intomainfrom
feat/ephemeral-examples

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Mar 31, 2026

Summary

  • Add ephemeral-counter/ — demonstrates WithEphemeralState() with an in-memory store as the source of truth. No SQLite dependency — just a mutex-protected counter.
  • Add ephemeral-todos/ — demonstrates WithEphemeralState() with SQLite for CRUD. Supports both WebSocket and HTTP-only modes via LVT_WEBSOCKET_DISABLED=true.
  • Update go.mod to livetemplate v0.8.11 (which includes WithEphemeralState())

What is Ephemeral State?

With WithEphemeralState(), LiveTemplate skips session persistence entirely. Every request starts with fresh state rebuilt via Mount(). The database (or any external store) is the canonical source of truth — LiveTemplate state is just a rendering projection.

Test plan

  • Both examples build (go build -o /dev/null ./ephemeral-counter/ ./ephemeral-todos/)
  • Chromedp E2E tests: initial render, actions, page refresh loads from DB
  • Updated README.md examples table
  • Updated test-all.sh WORKING_EXAMPLES

Implements examples for livetemplate/livetemplate#299.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings March 31, 2026 20:12
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with WithEphemeralState().
  • Add ephemeral-todos/ example using SQLite as the canonical store with WithEphemeralState(), plus E2E coverage.
  • Bump github.com/livetemplate/livetemplate to v0.8.11 and 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.

Comment on lines +17 to +21
<form>
<fieldset role="group">
<input type="text" name="title" placeholder="What needs to be done?" required>
<button type="submit">Add</button>
</fieldset>
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +36
<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>
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +71
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)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +104
db, err := sql.Open("sqlite", "file:todos.db?cache=shared&mode=rwc")
if err != nil {
slog.Error("Failed to open database", "error", err)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
return state, err
}
state.Items = append(state.Items, t)
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
}
if err := rows.Err(); err != nil {
return state, err
}

Copilot uses AI. Check for mistakes.
return state, err
}

id, _ := result.LastInsertId()
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
id, _ := result.LastInsertId()
id, err := result.LastInsertId()
if err != nil {
return state, err
}

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +26
func TestEphemeralTodosE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E test in short mode")
}

Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@adnaan adnaan merged commit 133bd41 into main Mar 31, 2026
16 checks passed
@adnaan adnaan deleted the feat/ephemeral-examples branch March 31, 2026 20:27
@adnaan adnaan mentioned this pull request Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants