diff --git a/avatar-upload/main.go b/avatar-upload/main.go
index eb8aba8..19a8847 100644
--- a/avatar-upload/main.go
+++ b/avatar-upload/main.go
@@ -128,7 +128,6 @@ func main() {
lt := livetemplate.Must(livetemplate.New("avatar-upload",
livetemplate.WithParseFiles("avatar-upload.tmpl"),
livetemplate.WithDevMode(true),
- livetemplate.WithStatePersistence(),
// Configure upload using WithUpload option
livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
Accept: []string{"image/jpeg", "image/png", "image/gif"},
diff --git a/counter/main.go b/counter/main.go
index ec73f62..6dc1589 100644
--- a/counter/main.go
+++ b/counter/main.go
@@ -86,7 +86,7 @@ func main() {
LastUpdated: formatTime(),
}
- opts := append(envConfig.ToOptions(), livetemplate.WithStatePersistence())
+ opts := envConfig.ToOptions()
tmpl := livetemplate.Must(livetemplate.New("counter", opts...))
liveHandler := tmpl.Handle(controller, livetemplate.AsState(initialState))
diff --git a/flash-messages/main.go b/flash-messages/main.go
index 68568d2..f399231 100644
--- a/flash-messages/main.go
+++ b/flash-messages/main.go
@@ -130,7 +130,7 @@ func main() {
initialState := &FlashState{}
// Create template with environment-based configuration
- opts := append(envConfig.ToOptions(), livetemplate.WithStatePersistence())
+ opts := envConfig.ToOptions()
tmpl := livetemplate.Must(livetemplate.New("flash", opts...))
// Mount handler
diff --git a/go.mod b/go.mod
index db814f7..9a8b90c 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
- github.com/livetemplate/livetemplate v0.8.9
+ github.com/livetemplate/livetemplate v0.8.10
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e
github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e
modernc.org/sqlite v1.43.0
@@ -46,5 +46,3 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
-
-replace github.com/livetemplate/livetemplate => ../livetemplate/.worktrees/per-connection-persist
diff --git a/go.sum b/go.sum
index b76a164..c7103c2 100644
--- a/go.sum
+++ b/go.sum
@@ -92,6 +92,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/livetemplate/livetemplate v0.8.10 h1:Lg62gb297Iq3/EShPnEUyqDFN8aeyTYchbW3wUFU370=
+github.com/livetemplate/livetemplate v0.8.10/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e h1:nAV7BaOatFcbSaP6m9CgLrRsGPeWtbjPDZ8dOI6Zb+c=
github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU=
github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e h1:vuR0pQtEQHZOD2/HvTJfHPKEdoD77XUs1mq1kdjgVig=
diff --git a/live-preview/main.go b/live-preview/main.go
index bcc632d..3b03c98 100644
--- a/live-preview/main.go
+++ b/live-preview/main.go
@@ -53,7 +53,7 @@ func main() {
controller := &PreviewController{}
initialState := &PreviewState{Preview: preview("")}
- opts := append(envConfig.ToOptions(), livetemplate.WithStatePersistence())
+ opts := envConfig.ToOptions()
tmpl := livetemplate.Must(livetemplate.New("preview", opts...))
http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState)))
http.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
diff --git a/login/main.go b/login/main.go
index 2f1b22b..8c1decf 100644
--- a/login/main.go
+++ b/login/main.go
@@ -165,7 +165,7 @@ func main() {
initialState := &AuthState{}
// Create template with environment-based configuration
- opts := append(envConfig.ToOptions(), livetemplate.WithStatePersistence())
+ opts := envConfig.ToOptions()
tmpl := livetemplate.Must(livetemplate.New("auth", opts...))
// Set up handler with Controller+State pattern
diff --git a/profile-progressive/main.go b/profile-progressive/main.go
index c2b7a9c..ea0097a 100644
--- a/profile-progressive/main.go
+++ b/profile-progressive/main.go
@@ -60,7 +60,7 @@ func main() {
log.Fatal(err)
}
- opts := append(envConfig.ToOptions(), livetemplate.WithStatePersistence())
+ opts := envConfig.ToOptions()
tmpl, err := livetemplate.New("profile", opts...)
if err != nil {
log.Fatal(err)
diff --git a/progressive-enhancement/main.go b/progressive-enhancement/main.go
index f741e13..cedab08 100644
--- a/progressive-enhancement/main.go
+++ b/progressive-enhancement/main.go
@@ -160,7 +160,7 @@ func main() {
// Create template with configuration
// Progressive enhancement is enabled by default
- opts := append(envConfig.ToOptions(), livetemplate.WithStatePersistence())
+ opts := envConfig.ToOptions()
tmpl := livetemplate.Must(livetemplate.New("progressive-enhancement", opts...))
// Mount handler
diff --git a/shared-notepad/main.go b/shared-notepad/main.go
index ec433c9..26ab79b 100644
--- a/shared-notepad/main.go
+++ b/shared-notepad/main.go
@@ -4,18 +4,18 @@ import (
"log"
"net/http"
"os"
+ "sync"
"time"
"github.com/livetemplate/livetemplate"
e2etest "github.com/livetemplate/lvt/testing"
)
-// NotepadController is a stateless singleton — all state lives in NotepadState
-// and is shared across tabs via WithSharedState.
-type NotepadController struct{}
+type NotepadController struct {
+ mu sync.RWMutex
+ notes map[string]NotepadState // userID -> latest state
+}
-// NotepadState is shared across all tabs of the same authenticated user.
-// WithSharedState means any change auto-broadcasts to all other tabs.
type NotepadState struct {
Username string `json:"username"`
Content string `json:"content"`
@@ -23,18 +23,38 @@ type NotepadState struct {
CharCount int `json:"char_count"`
}
-// Mount initializes the notepad for a new session.
func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
state.Username = ctx.UserID()
+ c.mu.RLock()
+ if saved, ok := c.notes[ctx.UserID()]; ok {
+ state.Content = saved.Content
+ state.CharCount = saved.CharCount
+ state.SavedAt = saved.SavedAt
+ }
+ c.mu.RUnlock()
return state, nil
}
-// Save persists the note (triggered by the Save button).
-// WithSharedState auto-broadcasts to all other tabs of this user.
func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
state.Content = ctx.GetString("content")
state.CharCount = len([]rune(state.Content))
state.SavedAt = time.Now().Format("15:04:05")
+
+ c.mu.Lock()
+ c.notes[ctx.UserID()] = state
+ c.mu.Unlock()
+
+ return state, nil
+}
+
+func (c *NotepadController) Sync(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
+ c.mu.RLock()
+ if saved, ok := c.notes[ctx.UserID()]; ok {
+ state.Content = saved.Content
+ state.CharCount = saved.CharCount
+ state.SavedAt = saved.SavedAt
+ }
+ c.mu.RUnlock()
return state, nil
}
@@ -49,21 +69,16 @@ func main() {
log.Fatalf("Invalid configuration: %v", err)
}
- // BasicAuth: each user gets their own isolated session group.
- // The library's ChallengeAuthenticator sends WWW-Authenticate header
- // automatically, triggering the browser's login dialog.
auth := livetemplate.NewBasicAuthenticator(func(username, password string) (bool, error) {
- // Demo: accept any username with password "demo"
return password == "demo", nil
})
opts := envConfig.ToOptions()
- opts = append(opts,
- livetemplate.WithAuthenticator(auth),
- livetemplate.WithSharedState(), // All tabs of the same user share state
- )
+ opts = append(opts, livetemplate.WithAuthenticator(auth))
- controller := &NotepadController{}
+ controller := &NotepadController{
+ notes: make(map[string]NotepadState),
+ }
initialState := &NotepadState{}
tmpl := livetemplate.Must(livetemplate.New("notepad", opts...))
diff --git a/shared-notepad/notepad.tmpl b/shared-notepad/notepad.tmpl
index 91930a3..9f39660 100644
--- a/shared-notepad/notepad.tmpl
+++ b/shared-notepad/notepad.tmpl
@@ -34,7 +34,7 @@
- BasicAuth — each user gets an isolated session (alice's notes are separate from bob's)
- - WithSharedState — all tabs of the same user share state (Save in one tab updates all)
+ - Sync() — all tabs of the same user auto-sync (Save in one tab updates all)
- Notes persist across page refreshes within the same server session
Demo credentials: any username, password demo
diff --git a/todos-components/main.go b/todos-components/main.go
index 923646f..22ded49 100644
--- a/todos-components/main.go
+++ b/todos-components/main.go
@@ -246,7 +246,6 @@ func main() {
ltSets[i] = convertTemplateSet(set)
}
opts = append(opts, livetemplate.WithComponentTemplates(ltSets...))
- opts = append(opts, livetemplate.WithStatePersistence())
// Create template with component templates registered
tmpl := livetemplate.Must(livetemplate.New("todos-components", opts...))
diff --git a/todos-progressive/main.go b/todos-progressive/main.go
index bc663da..edb15e1 100644
--- a/todos-progressive/main.go
+++ b/todos-progressive/main.go
@@ -118,7 +118,7 @@ func main() {
log.Fatal(err)
}
- opts := append(envConfig.ToOptions(), livetemplate.WithStatePersistence())
+ opts := envConfig.ToOptions()
tmpl, err := livetemplate.New("todos", opts...)
if err != nil {
log.Fatal(err)
diff --git a/todos/controller.go b/todos/controller.go
index ebfe969..41a9bec 100644
--- a/todos/controller.go
+++ b/todos/controller.go
@@ -6,46 +6,41 @@ import (
"strings"
"time"
- "github.com/livetemplate/livetemplate"
"github.com/livetemplate/examples/todos/db"
+ "github.com/livetemplate/livetemplate"
)
-// TodoController implements the Controller+State pattern for the todo application.
-//
-// The controller is a singleton that holds dependencies (database connection).
-// Action methods receive state as the first parameter and return modified state.
-// This separation allows for easy testing and clear data flow.
-//
-// Actions support both snake_case (next_page) and camelCase (nextPage) naming
-// due to LiveTemplate's automatic action name normalization.
type TodoController struct {
- // Queries provides access to database operations.
- // This is the only dependency held by the controller.
Queries *db.Queries
}
-// Mount is called when a new session is created.
-// It loads the initial set of todos from the database.
func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
- return c.loadTodos(context.Background(), state)
+ state.Username = ctx.UserID()
+ return c.loadTodos(context.Background(), state, ctx.UserID())
+}
+
+func (c *TodoController) OnConnect(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
+ state.Username = ctx.UserID()
+ return c.loadTodos(context.Background(), state, ctx.UserID())
+}
+
+func (c *TodoController) Sync(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
-// Add creates a new todo item with the given text.
-// Returns validation error if text is less than 3 characters.
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input AddInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err
}
- // Generate unique ID using timestamp
now := time.Now()
id := fmt.Sprintf("todo-%d", now.UnixNano())
dbCtx := context.Background()
- // Insert into database
_, err := c.Queries.CreateTodo(dbCtx, db.CreateTodoParams{
ID: id,
+ UserID: ctx.UserID(),
Text: input.Text,
Completed: false,
CreatedAt: now,
@@ -55,10 +50,9 @@ func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoSt
}
state.LastUpdated = formatTime()
- return c.loadTodos(dbCtx, state)
+ return c.loadTodos(dbCtx, state, ctx.UserID())
}
-// Toggle changes a todo's completion status (completed <-> incomplete).
func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input ToggleInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
@@ -67,26 +61,27 @@ func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (Tod
dbCtx := context.Background()
- // Get current todo to determine its current status
- todo, err := c.Queries.GetTodoByID(dbCtx, input.ID)
+ todo, err := c.Queries.GetTodoByID(dbCtx, db.GetTodoByIDParams{
+ ID: input.ID,
+ UserID: ctx.UserID(),
+ })
if err != nil {
return state, fmt.Errorf("failed to get todo: %w", err)
}
- // Toggle the completion status
err = c.Queries.UpdateTodoCompleted(dbCtx, db.UpdateTodoCompletedParams{
Completed: !todo.Completed,
ID: input.ID,
+ UserID: ctx.UserID(),
})
if err != nil {
return state, fmt.Errorf("failed to update todo: %w", err)
}
state.LastUpdated = formatTime()
- return c.loadTodos(dbCtx, state)
+ return c.loadTodos(dbCtx, state, ctx.UserID())
}
-// Delete removes a todo item by its ID.
func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input DeleteInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
@@ -95,34 +90,33 @@ func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (Tod
dbCtx := context.Background()
- err := c.Queries.DeleteTodo(dbCtx, input.ID)
+ err := c.Queries.DeleteTodo(dbCtx, db.DeleteTodoParams{
+ ID: input.ID,
+ UserID: ctx.UserID(),
+ })
if err != nil {
return state, fmt.Errorf("failed to delete todo: %w", err)
}
state.LastUpdated = formatTime()
- return c.loadTodos(dbCtx, state)
+ return c.loadTodos(dbCtx, state, ctx.UserID())
}
-// Change handles live input updates (search-as-you-type, sort-on-change).
-// The framework auto-wires this method to all inputs with 300ms debounce.
func (c *TodoController) Change(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
if ctx.Has("query") {
state.SearchQuery = ctx.GetString("query")
state.CurrentPage = 1
state.LastUpdated = formatTime()
- return c.loadTodos(context.Background(), state)
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
if ctx.Has("sort_by") {
state.SortBy = ctx.GetString("sort_by")
state.LastUpdated = formatTime()
- return c.loadTodos(context.Background(), state)
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
return state, nil
}
-// Search filters todos by the given query string (case-insensitive substring match).
-// An empty query returns all todos.
func (c *TodoController) Search(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input SearchInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
@@ -131,11 +125,9 @@ func (c *TodoController) Search(state TodoState, ctx *livetemplate.Context) (Tod
state.SearchQuery = input.Query
state.LastUpdated = formatTime()
- return c.loadTodos(context.Background(), state)
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
-// Sort changes the order in which todos are displayed.
-// See SortInput for valid sort values.
func (c *TodoController) Sort(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input SortInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
@@ -144,29 +136,25 @@ func (c *TodoController) Sort(state TodoState, ctx *livetemplate.Context) (TodoS
state.SortBy = input.SortBy
state.LastUpdated = formatTime()
- return c.loadTodos(context.Background(), state)
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
-// NextPage navigates to the next page if available.
func (c *TodoController) NextPage(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
if state.CurrentPage < state.TotalPages {
state.CurrentPage++
}
state.LastUpdated = formatTime()
- return c.loadTodos(context.Background(), state)
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
-// PrevPage navigates to the previous page if available.
func (c *TodoController) PrevPage(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
if state.CurrentPage > 1 {
state.CurrentPage--
}
state.LastUpdated = formatTime()
- return c.loadTodos(context.Background(), state)
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
-// GotoPage navigates directly to a specific page number.
-// Invalid page numbers are ignored.
func (c *TodoController) GotoPage(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input PaginationInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
@@ -177,32 +165,27 @@ func (c *TodoController) GotoPage(state TodoState, ctx *livetemplate.Context) (T
state.CurrentPage = input.Page
}
state.LastUpdated = formatTime()
- return c.loadTodos(context.Background(), state)
+ return c.loadTodos(context.Background(), state, ctx.UserID())
}
-// ClearCompleted removes all todos that have been marked as completed.
func (c *TodoController) ClearCompleted(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
dbCtx := context.Background()
- err := c.Queries.DeleteCompletedTodos(dbCtx)
+ err := c.Queries.DeleteCompletedTodos(dbCtx, ctx.UserID())
if err != nil {
return state, fmt.Errorf("failed to delete completed todos: %w", err)
}
state.LastUpdated = formatTime()
- return c.loadTodos(dbCtx, state)
+ return c.loadTodos(dbCtx, state, ctx.UserID())
}
-// loadTodos fetches all todos from the database and updates computed fields.
-// It applies search filtering, sorting, pagination, and updates statistics.
-func (c *TodoController) loadTodos(ctx context.Context, state TodoState) (TodoState, error) {
- // Fetch all todos from database
- todos, err := c.Queries.GetAllTodos(ctx)
+func (c *TodoController) loadTodos(ctx context.Context, state TodoState, userID string) (TodoState, error) {
+ todos, err := c.Queries.GetAllTodos(ctx, userID)
if err != nil {
return state, fmt.Errorf("failed to load todos: %w", err)
}
- // Apply search filter
if state.SearchQuery == "" {
state.FilteredTodos = todos
} else {
@@ -215,7 +198,6 @@ func (c *TodoController) loadTodos(ctx context.Context, state TodoState) (TodoSt
}
}
- // Update statistics (based on all todos, not filtered)
state.TotalCount = len(todos)
state.CompletedCount = 0
for _, todo := range todos {
@@ -225,7 +207,6 @@ func (c *TodoController) loadTodos(ctx context.Context, state TodoState) (TodoSt
}
state.RemainingCount = state.TotalCount - state.CompletedCount
- // Apply sorting and pagination
state = applySorting(state)
state = applyPagination(state)
diff --git a/todos/db/models.go b/todos/db/models.go
index df7216d..1e4b2bc 100644
--- a/todos/db/models.go
+++ b/todos/db/models.go
@@ -10,6 +10,7 @@ import (
type Todo struct {
ID string `json:"id"`
+ UserID string `json:"user_id"`
Text string `json:"text"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
diff --git a/todos/db/queries.sql b/todos/db/queries.sql
index 4b763b8..6492fa3 100644
--- a/todos/db/queries.sql
+++ b/todos/db/queries.sql
@@ -1,33 +1,35 @@
-- name: GetAllTodos :many
SELECT * FROM todos
+WHERE user_id = ?
ORDER BY created_at DESC;
-- name: GetTodoByID :one
SELECT * FROM todos
-WHERE id = ?
+WHERE id = ? AND user_id = ?
LIMIT 1;
-- name: CreateTodo :one
-INSERT INTO todos (id, text, completed, created_at)
-VALUES (?, ?, ?, ?)
+INSERT INTO todos (id, user_id, text, completed, created_at)
+VALUES (?, ?, ?, ?, ?)
RETURNING *;
-- name: UpdateTodoCompleted :exec
UPDATE todos
SET completed = ?
-WHERE id = ?;
+WHERE id = ? AND user_id = ?;
-- name: DeleteTodo :exec
DELETE FROM todos
-WHERE id = ?;
+WHERE id = ? AND user_id = ?;
-- name: DeleteCompletedTodos :exec
DELETE FROM todos
-WHERE completed = 1;
+WHERE completed = 1 AND user_id = ?;
-- name: CountTodos :one
-SELECT COUNT(*) FROM todos;
+SELECT COUNT(*) FROM todos
+WHERE user_id = ?;
-- name: CountCompletedTodos :one
SELECT COUNT(*) FROM todos
-WHERE completed = 1;
+WHERE completed = 1 AND user_id = ?;
diff --git a/todos/db/queries.sql.go b/todos/db/queries.sql.go
index 815178e..82db1a4 100644
--- a/todos/db/queries.sql.go
+++ b/todos/db/queries.sql.go
@@ -12,11 +12,11 @@ import (
const countCompletedTodos = `-- name: CountCompletedTodos :one
SELECT COUNT(*) FROM todos
-WHERE completed = 1
+WHERE completed = 1 AND user_id = ?
`
-func (q *Queries) CountCompletedTodos(ctx context.Context) (int64, error) {
- row := q.db.QueryRowContext(ctx, countCompletedTodos)
+func (q *Queries) CountCompletedTodos(ctx context.Context, userID string) (int64, error) {
+ row := q.db.QueryRowContext(ctx, countCompletedTodos, userID)
var count int64
err := row.Scan(&count)
return count, err
@@ -24,23 +24,25 @@ func (q *Queries) CountCompletedTodos(ctx context.Context) (int64, error) {
const countTodos = `-- name: CountTodos :one
SELECT COUNT(*) FROM todos
+WHERE user_id = ?
`
-func (q *Queries) CountTodos(ctx context.Context) (int64, error) {
- row := q.db.QueryRowContext(ctx, countTodos)
+func (q *Queries) CountTodos(ctx context.Context, userID string) (int64, error) {
+ row := q.db.QueryRowContext(ctx, countTodos, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const createTodo = `-- name: CreateTodo :one
-INSERT INTO todos (id, text, completed, created_at)
-VALUES (?, ?, ?, ?)
-RETURNING id, text, completed, created_at
+INSERT INTO todos (id, user_id, text, completed, created_at)
+VALUES (?, ?, ?, ?, ?)
+RETURNING id, user_id, text, completed, created_at
`
type CreateTodoParams struct {
ID string `json:"id"`
+ UserID string `json:"user_id"`
Text string `json:"text"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
@@ -49,6 +51,7 @@ type CreateTodoParams struct {
func (q *Queries) CreateTodo(ctx context.Context, arg CreateTodoParams) (Todo, error) {
row := q.db.QueryRowContext(ctx, createTodo,
arg.ID,
+ arg.UserID,
arg.Text,
arg.Completed,
arg.CreatedAt,
@@ -56,6 +59,7 @@ func (q *Queries) CreateTodo(ctx context.Context, arg CreateTodoParams) (Todo, e
var i Todo
err := row.Scan(
&i.ID,
+ &i.UserID,
&i.Text,
&i.Completed,
&i.CreatedAt,
@@ -65,31 +69,37 @@ func (q *Queries) CreateTodo(ctx context.Context, arg CreateTodoParams) (Todo, e
const deleteCompletedTodos = `-- name: DeleteCompletedTodos :exec
DELETE FROM todos
-WHERE completed = 1
+WHERE completed = 1 AND user_id = ?
`
-func (q *Queries) DeleteCompletedTodos(ctx context.Context) error {
- _, err := q.db.ExecContext(ctx, deleteCompletedTodos)
+func (q *Queries) DeleteCompletedTodos(ctx context.Context, userID string) error {
+ _, err := q.db.ExecContext(ctx, deleteCompletedTodos, userID)
return err
}
const deleteTodo = `-- name: DeleteTodo :exec
DELETE FROM todos
-WHERE id = ?
+WHERE id = ? AND user_id = ?
`
-func (q *Queries) DeleteTodo(ctx context.Context, id string) error {
- _, err := q.db.ExecContext(ctx, deleteTodo, id)
+type DeleteTodoParams struct {
+ ID string `json:"id"`
+ UserID string `json:"user_id"`
+}
+
+func (q *Queries) DeleteTodo(ctx context.Context, arg DeleteTodoParams) error {
+ _, err := q.db.ExecContext(ctx, deleteTodo, arg.ID, arg.UserID)
return err
}
const getAllTodos = `-- name: GetAllTodos :many
-SELECT id, text, completed, created_at FROM todos
+SELECT id, user_id, text, completed, created_at FROM todos
+WHERE user_id = ?
ORDER BY created_at DESC
`
-func (q *Queries) GetAllTodos(ctx context.Context) ([]Todo, error) {
- rows, err := q.db.QueryContext(ctx, getAllTodos)
+func (q *Queries) GetAllTodos(ctx context.Context, userID string) ([]Todo, error) {
+ rows, err := q.db.QueryContext(ctx, getAllTodos, userID)
if err != nil {
return nil, err
}
@@ -99,6 +109,7 @@ func (q *Queries) GetAllTodos(ctx context.Context) ([]Todo, error) {
var i Todo
if err := rows.Scan(
&i.ID,
+ &i.UserID,
&i.Text,
&i.Completed,
&i.CreatedAt,
@@ -117,16 +128,22 @@ func (q *Queries) GetAllTodos(ctx context.Context) ([]Todo, error) {
}
const getTodoByID = `-- name: GetTodoByID :one
-SELECT id, text, completed, created_at FROM todos
-WHERE id = ?
+SELECT id, user_id, text, completed, created_at FROM todos
+WHERE id = ? AND user_id = ?
LIMIT 1
`
-func (q *Queries) GetTodoByID(ctx context.Context, id string) (Todo, error) {
- row := q.db.QueryRowContext(ctx, getTodoByID, id)
+type GetTodoByIDParams struct {
+ ID string `json:"id"`
+ UserID string `json:"user_id"`
+}
+
+func (q *Queries) GetTodoByID(ctx context.Context, arg GetTodoByIDParams) (Todo, error) {
+ row := q.db.QueryRowContext(ctx, getTodoByID, arg.ID, arg.UserID)
var i Todo
err := row.Scan(
&i.ID,
+ &i.UserID,
&i.Text,
&i.Completed,
&i.CreatedAt,
@@ -137,15 +154,16 @@ func (q *Queries) GetTodoByID(ctx context.Context, id string) (Todo, error) {
const updateTodoCompleted = `-- name: UpdateTodoCompleted :exec
UPDATE todos
SET completed = ?
-WHERE id = ?
+WHERE id = ? AND user_id = ?
`
type UpdateTodoCompletedParams struct {
Completed bool `json:"completed"`
ID string `json:"id"`
+ UserID string `json:"user_id"`
}
func (q *Queries) UpdateTodoCompleted(ctx context.Context, arg UpdateTodoCompletedParams) error {
- _, err := q.db.ExecContext(ctx, updateTodoCompleted, arg.Completed, arg.ID)
+ _, err := q.db.ExecContext(ctx, updateTodoCompleted, arg.Completed, arg.ID, arg.UserID)
return err
}
diff --git a/todos/db/schema.sql b/todos/db/schema.sql
index aea85ba..3d5660b 100644
--- a/todos/db/schema.sql
+++ b/todos/db/schema.sql
@@ -1,5 +1,6 @@
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
text TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
@@ -7,3 +8,4 @@ CREATE TABLE IF NOT EXISTS todos (
CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at);
CREATE INDEX IF NOT EXISTS idx_todos_completed ON todos(completed);
+CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);
diff --git a/todos/db_manager.go b/todos/db_manager.go
index 34ca1aa..322ee83 100644
--- a/todos/db_manager.go
+++ b/todos/db_manager.go
@@ -47,6 +47,7 @@ func runMigrations(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
text TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
@@ -54,6 +55,7 @@ CREATE TABLE IF NOT EXISTS todos (
CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at);
CREATE INDEX IF NOT EXISTS idx_todos_completed ON todos(completed);
+CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);
`
_, err := db.Exec(schema)
return err
diff --git a/todos/main.go b/todos/main.go
index 4099939..58ed56d 100644
--- a/todos/main.go
+++ b/todos/main.go
@@ -1,15 +1,3 @@
-// Package main implements a todo application demonstrating the LiveTemplate framework.
-//
-// The application uses the Controller+State pattern:
-// - Controller (TodoController): singleton holding dependencies, implements actions
-// - State (TodoState): pure data cloned per session, passed to templates
-//
-// File organization:
-// - main.go: server setup and entry point
-// - state.go: data structures and constants
-// - controller.go: TodoController and action methods
-// - helpers.go: utility functions for sorting, pagination, formatting
-// - db_manager.go: database initialization and migrations
package main
import (
@@ -22,13 +10,11 @@ import (
e2etest "github.com/livetemplate/lvt/testing"
)
-// validate is the shared validator instance for input validation.
var validate = validator.New()
func main() {
log.Println("LiveTemplate Todo App starting...")
- // Load configuration from environment variables (LVT_* prefix)
envConfig, err := livetemplate.LoadEnvConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
@@ -38,7 +24,6 @@ func main() {
log.Fatalf("Invalid configuration: %v", err)
}
- // Initialize SQLite database
dbPath := GetDBPath()
queries, dbErr := InitDB(dbPath)
if dbErr != nil {
@@ -46,13 +31,19 @@ func main() {
}
defer CloseDB()
- // Create controller (singleton with database dependency)
controller := &TodoController{
Queries: queries,
}
- // Create initial state (pure data, cloned per session)
- // Todos are loaded via Mount() when each session is created
+ auth := livetemplate.NewBasicAuthenticator(func(username, password string) (bool, error) {
+ users := map[string]string{
+ "alice": "password",
+ "bob": "password",
+ }
+ pass, ok := users[username]
+ return ok && pass == password, nil
+ })
+
initialState := &TodoState{
Title: "Todo App",
CurrentPage: DefaultPage,
@@ -60,24 +51,18 @@ func main() {
LastUpdated: formatTime(),
}
- // Create template with environment-based configuration
- tmpl := livetemplate.Must(livetemplate.New("todos", envConfig.ToOptions()...))
+ opts := append(envConfig.ToOptions(), livetemplate.WithAuthenticator(auth))
+ tmpl := livetemplate.Must(livetemplate.New("todos", opts...))
- // Mount handler with Controller+State pattern
- // - Controller: singleton with database dependency
- // - State: wrapped with AsState() for per-session cloning
- // - Mount() is called when each session is created to load initial todos
http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState)))
-
- // Serve client library (development only - use CDN in production)
http.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
- // Start server
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on http://localhost:%s", port)
+ log.Println("Demo users: alice/password, bob/password")
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
diff --git a/todos/state.go b/todos/state.go
index ada9574..1c37c8f 100644
--- a/todos/state.go
+++ b/todos/state.go
@@ -61,6 +61,7 @@ type PaginationInput struct {
type TodoState struct {
// Display metadata
Title string `json:"title"`
+ Username string `json:"username"`
LastUpdated string `json:"last_updated"`
// Filter and sort settings
diff --git a/todos/todos.tmpl b/todos/todos.tmpl
index f1b31f5..9d6ca39 100644
--- a/todos/todos.tmpl
+++ b/todos/todos.tmpl
@@ -167,6 +167,7 @@
{{/* Add Todo Form */}}
diff --git a/todos/todos_test.go b/todos/todos_test.go
index d796dc7..049ea7d 100644
--- a/todos/todos_test.go
+++ b/todos/todos_test.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -74,7 +75,9 @@ func TestTodosE2E(t *testing.T) {
var lastErr error
for i := 0; i < 50; i++ { // 10 seconds max (50 * 200ms)
- resp, err := http.Get(serverURL)
+ req, _ := http.NewRequest("GET", serverURL, nil)
+ req.SetBasicAuth("alice", "password")
+ resp, err := http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
@@ -120,7 +123,7 @@ func TestTodosE2E(t *testing.T) {
var initialHTML string
err := chromedp.Run(ctx,
- chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
+ chromedp.Navigate(fmt.Sprintf("http://alice:password@host.docker.internal:%d/", serverPort)),
e2etest.WaitForWebSocketReady(5*time.Second), // Wait for WebSocket init and first update
chromedp.WaitVisible(`h1`, chromedp.ByQuery),
e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), // Validate no raw template expressions
@@ -986,7 +989,9 @@ func TestWebSocketBasic(t *testing.T) {
// Wait for server
time.Sleep(2 * time.Second)
for i := 0; i < 30; i++ {
- if resp, err := http.Get(serverURL); err == nil {
+ req, _ := http.NewRequest("GET", serverURL, nil)
+ req.SetBasicAuth("alice", "password")
+ if resp, err := http.DefaultClient.Do(req); err == nil {
resp.Body.Close()
break
}
@@ -995,9 +1000,11 @@ func TestWebSocketBasic(t *testing.T) {
t.Log("Server is up, trying to connect WebSocket...")
- // Try to connect
+ // Try to connect with auth
+ authHeader := http.Header{}
+ authHeader.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("alice:password")))
dialer := websocket.Dialer{}
- conn, resp, err := dialer.Dial(wsURL, nil)
+ conn, resp, err := dialer.Dial(wsURL, authHeader)
if err != nil {
t.Fatalf("Failed to connect: %v, response: %v", err, resp)
}
diff --git a/ws-disabled/main.go b/ws-disabled/main.go
index 3117959..9439238 100644
--- a/ws-disabled/main.go
+++ b/ws-disabled/main.go
@@ -94,7 +94,6 @@ func main() {
opts := envConfig.ToOptions()
opts = append(opts, livetemplate.WithWebSocketDisabled())
- opts = append(opts, livetemplate.WithStatePersistence())
tmpl := livetemplate.Must(livetemplate.New("ws-disabled", opts...))