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 @@
How it works
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 @@

{{ .Title }}

A simple todo app with real-time updates

+ {{ if .Username }}

Logged in as: {{ .Username }}

{{ end }}
{{/* 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...))