// Package todos stores todo/issue notes filed from the browser by the // otel-todo.js widget. package todos import ( "bufio" "context" "encoding/json" "fmt" "os" "sort" "sync" "time" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" ) // Todo is one note filed from a page. Project is the RUM service name. type Todo struct { ID string `json:"id"` Project string `json:"project"` Text string `json:"text"` Component string `json:"component,omitempty"` Path string `json:"path,omitempty"` Hash string `json:"hash,omitempty"` URL string `json:"url,omitempty"` UserAgent string `json:"userAgent,omitempty"` Session string `json:"session,omitempty"` CreatedAt time.Time `json:"createdAt"` Done bool `json:"done"` DoneAt time.Time `json:"doneAt,omitzero"` } // Store persists todos. List returns newest first; an empty project means all. type Store interface { Add(ctx context.Context, t Todo) error List(ctx context.Context, project string, limit int) ([]Todo, error) // SetDone marks a todo done or open; ok is false for an unknown id. SetDone(ctx context.Context, id string, done bool) (t Todo, ok bool, err error) } // FileStore keeps todos in memory and appends them to a JSONL file. type FileStore struct { mu sync.Mutex path string items []Todo } func NewFileStore(path string) (*FileStore, error) { fs := &FileStore{path: path} f, err := os.Open(path) if os.IsNotExist(err) { return fs, nil } if err != nil { return nil, err } defer f.Close() sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 64<<10), 1<<20) for sc.Scan() { var t Todo if json.Unmarshal(sc.Bytes(), &t) == nil { fs.items = append(fs.items, t) } } return fs, sc.Err() } func (s *FileStore) Add(_ context.Context, t Todo) error { line, err := json.Marshal(t) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() if _, err := f.Write(append(line, '\n')); err != nil { return err } s.items = append(s.items, t) return nil } func (s *FileStore) SetDone(_ context.Context, id string, done bool) (Todo, bool, error) { s.mu.Lock() defer s.mu.Unlock() for i := range s.items { if s.items[i].ID != id { continue } s.items[i].Done = done s.items[i].DoneAt = time.Time{} if done { s.items[i].DoneAt = time.Now().UTC() } var buf []byte for _, t := range s.items { line, err := json.Marshal(t) if err != nil { return Todo{}, false, err } buf = append(append(buf, line...), '\n') } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, buf, 0o644); err != nil { return Todo{}, false, err } return s.items[i], true, os.Rename(tmp, s.path) } return Todo{}, false, nil } func (s *FileStore) List(_ context.Context, project string, limit int) ([]Todo, error) { s.mu.Lock() defer s.mu.Unlock() var out []Todo for _, t := range s.items { if project == "" || t.Project == project { out = append(out, t) } } sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) if limit > 0 && len(out) > limit { out = out[:limit] } return out, nil } // ClickHouseStore persists todos in a plain MergeTree table (no TTL). type ClickHouseStore struct { conn driver.Conn db string } func NewClickHouseStore(ctx context.Context, conn driver.Conn, db string) (*ClickHouseStore, error) { err := conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.todos ( id String, project LowCardinality(String), text String, component String, path String, hash String, url String, user_agent String, session String, created_at DateTime64(3, 'UTC') ) ENGINE = MergeTree ORDER BY (project, created_at)`, db)) if err != nil { return nil, err } for _, col := range []string{"done UInt8", "done_at DateTime64(3, 'UTC')"} { if err := conn.Exec(ctx, fmt.Sprintf(`ALTER TABLE %s.todos ADD COLUMN IF NOT EXISTS %s`, db, col)); err != nil { return nil, err } } return &ClickHouseStore{conn: conn, db: db}, nil } func (s *ClickHouseStore) Add(ctx context.Context, t Todo) error { return s.conn.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.todos (id, project, text, component, path, hash, url, user_agent, session, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, s.db), t.ID, t.Project, t.Text, t.Component, t.Path, t.Hash, t.URL, t.UserAgent, t.Session, t.CreatedAt.UTC()) } func (s *ClickHouseStore) List(ctx context.Context, project string, limit int) ([]Todo, error) { q := fmt.Sprintf(`SELECT id, project, text, component, path, hash, url, user_agent, session, created_at, done, done_at FROM %s.todos`, s.db) var args []any if project != "" { q += ` WHERE project = ?` args = append(args, project) } q += ` ORDER BY created_at DESC LIMIT ?` args = append(args, limit) rows, err := s.conn.Query(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var out []Todo for rows.Next() { var t Todo var done uint8 if err := rows.Scan(&t.ID, &t.Project, &t.Text, &t.Component, &t.Path, &t.Hash, &t.URL, &t.UserAgent, &t.Session, &t.CreatedAt, &done, &t.DoneAt); err != nil { return nil, err } t.Done = done != 0 out = append(out, t) } return out, rows.Err() } func (s *ClickHouseStore) SetDone(ctx context.Context, id string, done bool) (Todo, bool, error) { var d uint8 doneAt := time.Unix(0, 0).UTC() if done { d, doneAt = 1, time.Now().UTC() } if err := s.conn.Exec(ctx, fmt.Sprintf(`ALTER TABLE %s.todos UPDATE done = ?, done_at = ? WHERE id = ? SETTINGS mutations_sync = 1`, s.db), d, doneAt, id); err != nil { return Todo{}, false, err } rows, err := s.conn.Query(ctx, fmt.Sprintf(`SELECT id, project, text, component, path, hash, url, user_agent, session, created_at, done, done_at FROM %s.todos WHERE id = ?`, s.db), id) if err != nil { return Todo{}, false, err } defer rows.Close() if !rows.Next() { return Todo{}, false, nil } var t Todo var dn uint8 if err := rows.Scan(&t.ID, &t.Project, &t.Text, &t.Component, &t.Path, &t.Hash, &t.URL, &t.UserAgent, &t.Session, &t.CreatedAt, &dn, &t.DoneAt); err != nil { return Todo{}, false, err } t.Done = dn != 0 if !t.Done { t.DoneAt = time.Time{} } return t, true, nil }