carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1// Package todo keeps every visitor's todo list in one SQLite file.
2//
3// A list is identified by a ListKey: the SHA-256 of the visitor's cookie.
4// Every query that reads or changes a todo names the list, so an id that
5// belongs to someone else behaves exactly like an id that doesn't exist.
6package todo
7
8import (
9 "context"
10 "crypto/sha256"
11 "database/sql"
12 "errors"
13 "fmt"
14 "net/url"
15 "runtime"
16 "strings"
17
18 _ "modernc.org/sqlite" // registers the "sqlite" driver; pure Go, no cgo
19)
20
21// ErrNotFound means the id isn't in the caller's list.
22var ErrNotFound = errors.New("todo: not found")
23
24// ListKey names one visitor's list.
25type ListKey [sha256.Size]byte
26
27// KeyFor derives the list key from the visitor's secret cookie bytes. The
28// database only ever sees the hash, never the cookie.
29func KeyFor(secret []byte) ListKey { return sha256.Sum256(secret) }
30
31// Todo is one item in a list.
32type Todo struct {
33 ID int64
34 Title string
35 Completed bool
36}
37
38// migrations only ever grow. Each entry runs once, in order, recorded in
39// todo_migrations. Never edit or remove one that has shipped; append a
40// new one. Names are prefixed so they can't collide with tables an
41// earlier build may have left in the same file.
42var migrations = []string{
43 `CREATE TABLE todo_items (
44 id INTEGER PRIMARY KEY AUTOINCREMENT,
45 list BLOB NOT NULL,
46 title TEXT NOT NULL,
47 completed INTEGER NOT NULL DEFAULT 0,
48 created INTEGER NOT NULL DEFAULT (unixepoch())
49 );
50 CREATE INDEX todo_items_by_list ON todo_items (list, id);`,
51 // Lists the server has issued. A cookie whose hash isn't here is
52 // replaced, so nobody can pick their own list identity. Lists that
53 // already hold todos were issued by an earlier build, so they count.
54 `CREATE TABLE todo_lists (
55 key BLOB PRIMARY KEY,
56 created INTEGER NOT NULL DEFAULT (unixepoch())
57 ) WITHOUT ROWID;
58 INSERT INTO todo_lists (key) SELECT DISTINCT list FROM todo_items;`,
59}
60
61// Store is the todo database: one connection for writes, a small pool
62// for reads. SQLite allows a single writer at a time, so funnelling
63// writes through one connection turns lock contention into a queue.
64type Store struct {
65 w, r *sql.DB
66}
67
68// Open opens (creating if needed) the database at path in WAL mode and
69// brings its schema up to date.
70func Open(ctx context.Context, path string) (*Store, error) {
71 // Every connection the pools open runs these pragmas first.
72 dsn := func(pragmas ...string) string {
73 q := url.Values{}
74 for _, p := range append([]string{"busy_timeout(5000)", "synchronous(NORMAL)"}, pragmas...) {
75 q.Add("_pragma", p)
76 }
77 return "file:" + path + "?" + q.Encode()
78 }
79 w, err := sql.Open("sqlite", dsn("journal_mode(WAL)")+"&_txlock=immediate")
80 if err != nil {
81 return nil, err
82 }
83 w.SetMaxOpenConns(1)
84 r, err := sql.Open("sqlite", dsn("query_only(1)"))
85 if err != nil {
86 w.Close()
87 return nil, err
88 }
89 r.SetMaxOpenConns(max(4, runtime.NumCPU()))
90 s := &Store{w: w, r: r}
91 if err := s.migrate(ctx); err != nil {
92 s.Close()
93 return nil, err
94 }
95 return s, nil
96}
97
98func (s *Store) migrate(ctx context.Context) error {
99 tx, err := s.w.BeginTx(ctx, nil)
100 if err != nil {
101 return err
102 }
103 defer tx.Rollback()
104 if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS todo_migrations (
105 version INTEGER PRIMARY KEY,
106 applied INTEGER NOT NULL DEFAULT (unixepoch())
107 )`); err != nil {
108 return err
109 }
110 var have int
111 if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM todo_migrations`).Scan(&have); err != nil {
112 return err
113 }
114 if have > len(migrations) {
115 return fmt.Errorf("todo: database is at schema %d, this build knows %d", have, len(migrations))
116 }
117 for i := have; i < len(migrations); i++ {
118 if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
119 return fmt.Errorf("todo: migration %d: %w", i+1, err)
120 }
121 if _, err := tx.ExecContext(ctx, `INSERT INTO todo_migrations (version) VALUES (?)`, i+1); err != nil {
122 return err
123 }
124 }
125 return tx.Commit()
126}
127
128// Close checkpoints and closes the database.
129func (s *Store) Close() error {
130 return errors.Join(s.r.Close(), s.w.Close())
131}
132
133// Ping checks that the database answers.
134func (s *Store) Ping(ctx context.Context) error { return s.r.PingContext(ctx) }
135
136// CreateList records a newly issued list.
137func (s *Store) CreateList(ctx context.Context, k ListKey) error {
138 _, err := s.w.ExecContext(ctx, `INSERT INTO todo_lists (key) VALUES (?)`, k[:])
139 return err
140}
141
142// ListExists reports whether the server issued this list.
143func (s *Store) ListExists(ctx context.Context, k ListKey) (bool, error) {
144 var n int
145 err := s.r.QueryRowContext(ctx, `SELECT count(*) FROM todo_lists WHERE key = ?`, k[:]).Scan(&n)
146 return n > 0, err
147}
148
149// List returns every todo in the list, oldest first.
150func (s *Store) List(ctx context.Context, k ListKey) ([]Todo, error) {
151 rows, err := s.r.QueryContext(ctx,
152 `SELECT id, title, completed FROM todo_items WHERE list = ? ORDER BY id`, k[:])
153 if err != nil {
154 return nil, err
155 }
156 defer rows.Close()
157 var out []Todo
158 for rows.Next() {
159 var t Todo
160 if err := rows.Scan(&t.ID, &t.Title, &t.Completed); err != nil {
161 return nil, err
162 }
163 out = append(out, t)
164 }
165 return out, rows.Err()
166}
167
168// CleanTitle trims a submitted title and replaces any invalid UTF-8.
169// An empty result means "no title".
170func CleanTitle(title string) string {
171 return strings.TrimSpace(strings.ToValidUTF8(title, "�"))
172}
173
174// Add appends a todo. A title that is empty after cleaning adds nothing.
175func (s *Store) Add(ctx context.Context, k ListKey, title string) error {
176 if title = CleanTitle(title); title == "" {
177 return nil
178 }
179 _, err := s.w.ExecContext(ctx, `INSERT INTO todo_items (list, title) VALUES (?, ?)`, k[:], title)
180 return err
181}
182
183// Toggle flips one todo between active and completed.
184func (s *Store) Toggle(ctx context.Context, k ListKey, id int64) error {
185 return one(s.w.ExecContext(ctx,
186 `UPDATE todo_items SET completed = 1 - completed WHERE id = ? AND list = ?`, id, k[:]))
187}
188
189// Rename sets a todo's title. A title that is empty after cleaning
190// deletes the todo instead, as TodoMVC does.
191func (s *Store) Rename(ctx context.Context, k ListKey, id int64, title string) error {
192 if title = CleanTitle(title); title == "" {
193 return s.Delete(ctx, k, id)
194 }
195 return one(s.w.ExecContext(ctx,
196 `UPDATE todo_items SET title = ? WHERE id = ? AND list = ?`, title, id, k[:]))
197}
198
199// Delete removes one todo.
200func (s *Store) Delete(ctx context.Context, k ListKey, id int64) error {
201 return one(s.w.ExecContext(ctx, `DELETE FROM todo_items WHERE id = ? AND list = ?`, id, k[:]))
202}
203
204// ToggleAll marks every todo completed, or, if every one already is,
205// marks them all active.
206func (s *Store) ToggleAll(ctx context.Context, k ListKey) error {
207 _, err := s.w.ExecContext(ctx,
208 `UPDATE todo_items
209 SET completed = EXISTS (SELECT 1 FROM todo_items WHERE list = ?1 AND completed = 0)
210 WHERE list = ?1`, k[:])
211 return err
212}
213
214// ClearCompleted deletes every completed todo.
215func (s *Store) ClearCompleted(ctx context.Context, k ListKey) error {
216 _, err := s.w.ExecContext(ctx, `DELETE FROM todo_items WHERE list = ? AND completed = 1`, k[:])
217 return err
218}
219
220// one turns "no row matched" into ErrNotFound.
221func one(res sql.Result, err error) error {
222 if err != nil {
223 return err
224 }
225 n, err := res.RowsAffected()
226 if err != nil {
227 return err
228 }
229 if n == 0 {
230 return ErrNotFound
231 }
232 return nil
233}
234