Files
Maven/internal/store/reminders.go
T
kami 861e418669 worker+voice: per-conn context, store error hygiene, StateDir wiring, pipe leak
worker/server.go: dispatch now receives a per-connection context instead of
context.Background(), so handler cancellation propagates on conn close.

voice/server.go: same — per-conn context fed through safeDispatch into
HandlePushToTalk instead of context.Background().

store/reminders.go: propagate LastInsertId error.
store/nudges.go: propagate LastInsertId and RowsAffected errors.
store/tools.go: propagate RowsAffected error.

config/config.go: applyDefaults now respects StateDir when set, using it as
the base for empty DBPath/SocketPath instead of silently ignoring it.

phraser/llmphraser.go: close stderr pipe fd when cmd.Start() fails.
2026-07-03 11:03:14 +02:00

90 lines
2.8 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Reminder — user-stated future intent. fires once. relative→absolute happens
// at capture ("in 4h" → store now+4h, never the string).
type Reminder struct {
ID int64
CreatedTs time.Time
FireTs time.Time
Payload string // raw json
Status string // pending | fired | cancelled
}
var (
ErrReminderNotFound = errors.New("store: reminder not found")
ErrReminderState = errors.New("store: reminder not in a mutable state")
)
// CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
now := time.Now().UTC()
res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`,
now.UnixMilli(), fire.UnixMilli(), payload)
if err != nil {
return 0, fmt.Errorf("create reminder: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create reminder: last insert id: %w", err)
}
return id, nil
}
// DueReminders returns pending reminders with fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`.
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, payload, status
FROM reminders
WHERE status = 'pending' AND fire_ts <= ?
ORDER BY fire_ts ASC`, now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("due reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
var r Reminder
var created, fire int64
if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil {
return nil, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
out = append(out, r)
}
return out, rows.Err()
}
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
// pending→cancelled. Anything else is a programming error.
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
if status != "fired" && status != "cancelled" {
return fmt.Errorf("%w: %s", ErrReminderState, status)
}
// pending → fired|cancelled only.
var current string
err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(&current)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return err
}
if current != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err
}