issue 1858: keep the operator's handling and the action log (1858.1)

ADR 0517 §1 to §3, §9 to §11: migration 0005 and schema.sql add item_handling (per item, gone with its
entry) and action_line (what, mail id, item, mode, when; never mail text); the store writes each act and its
line in one transaction.
This commit is contained in:
jochen
2026-10-22 19:50:15 +02:00
parent 75ce0e8a8c
commit fdd56d2259
6 changed files with 391 additions and 1 deletions
@@ -0,0 +1,47 @@
package main
// Issue 1858 (novox/hq ADR 0517 §3, §9 to §11): the handling, the reminders and the action log in the
// reader's own database — never a column for mail text; handling and reminders go with their entry.
import (
"strings"
"testing"
)
// 1858/AC6: the action log's and the handling's tables hold no column for mail text: only what was done,
// the mail id, the item, the mode and when.
func TestTheActionLogHoldsNoMailText(t *testing.T) {
for which, m := range map[string]*schemaModel{"schema.sql": modelOfConsolidated(t), "the migrations": modelOfMigrations(t)} {
for table, want := range map[string][]string{
"action_line": {"id", "what", "mail_entry", "item", "mode", "at"},
"item_handling": {"mail_entry", "item", "handled", "shows_again"},
} {
tb := m.Tables[table]
if tb == nil {
t.Fatalf("%s has no table %s", which, table)
}
var got []string
for col := range tb.Columns {
got = append(got, col)
}
if len(got) != len(want) {
t.Fatalf("%s: %s has the columns %v, want exactly %v", which, table, got, want)
}
for _, col := range want {
if _, ok := tb.Columns[col]; !ok {
t.Fatalf("%s: %s has the columns %v, want exactly %v", which, table, got, want)
}
}
}
}
}
// 1858/AC7 (the store's half): an item's handling and reminder are removed with their entry.
func TestHandlingGoesWithItsEntry(t *testing.T) {
for which, m := range map[string]*schemaModel{"schema.sql": modelOfConsolidated(t), "the migrations": modelOfMigrations(t)} {
tb := m.Tables["item_handling"]
if tb == nil || !strings.Contains(tb.Columns["mail_entry"], "references mail_entry(id)on delete cascade") {
t.Fatalf("%s: item_handling.mail_entry does not go with its entry: %v", which, tb)
}
}
}
@@ -26,6 +26,126 @@ type memStore struct {
nextLesson int64
reread *rereadKept
handlings []Handling
actions []ActionLine
nextLine int64
failAct error // Act, Record and CorrectEntry fail once with this, then clear: a failed act writes nothing
}
func (s *memStore) failing() error {
err := s.failAct
s.failAct = nil
return err
}
func (s *memStore) addLine(line ActionLine) {
s.nextLine++
line.ID = s.nextLine
s.actions = append(s.actions, line)
}
func (s *memStore) Handlings(context.Context) ([]Handling, error) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Handling(nil), s.handlings...), nil
}
func (s *memStore) Act(_ context.Context, h Handling, line ActionLine) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.failing(); err != nil {
return err
}
found := false
for _, e := range s.entries {
found = found || e.ID == h.Entry
}
if !found {
return ErrNotFound // PgStore: the foreign key refuses an item of no entry
}
kept := s.handlings[:0]
for _, held := range s.handlings {
if held.Entry != h.Entry || held.Item != h.Item {
kept = append(kept, held)
}
}
s.handlings = kept
if !h.Handled.IsZero() || !h.ShowsAgain.IsZero() {
s.handlings = append(s.handlings, h)
}
s.addLine(line)
return nil
}
func (s *memStore) Record(_ context.Context, line ActionLine) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.failing(); err != nil {
return err
}
s.addLine(line)
return nil
}
func (s *memStore) CorrectEntry(_ context.Context, e Entry, l Lesson, line ActionLine) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.failing(); err != nil {
return 0, err
}
at := -1
for i, held := range s.entries {
if held.ID == e.ID {
at = i
}
}
if at < 0 {
return 0, ErrNotFound
}
s.entries[at] = cloneEntry(e)
s.nextLesson++
l.ID = s.nextLesson
s.lessons = append(s.lessons, l)
s.addLine(line)
return l.ID, nil
}
func (s *memStore) Actions(_ context.Context, limit int) ([]ActionLine, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []ActionLine
for i := len(s.actions) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, s.actions[i])
}
return out, nil
}
func (s *memStore) LastAction(_ context.Context, what string) (time.Time, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
var last time.Time
found := false
for _, a := range s.actions {
if a.What == what && (!found || a.At.After(last)) {
last, found = a.At, true
}
}
return last, found, nil
}
func (s *memStore) RemoveActionsBefore(_ context.Context, before time.Time) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var kept []ActionLine
for _, a := range s.actions {
if !a.At.Before(before) {
kept = append(kept, a)
}
}
removed := int64(len(s.actions) - len(kept))
s.actions = kept
return removed, nil
}
type rereadKept struct {
@@ -0,0 +1,18 @@
-- Issue 1858 (novox/hq ADR 0517 §1 to §5, §9 to §11): the operator's handling of each item of a mail entry
-- (a task, a promise, or the mail itself), the day a reminded item shows again, and the action log: one line
-- per act on the mail page, holding what was done, the mail id, the item, the mode and when; never mail text.
-- Handling goes with its entry; a line is kept 365 days.
create table item_handling (
mail_entry bigint not null references mail_entry (id) on delete cascade,
item text not null check (item in ('mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')),
handled timestamptz,
shows_again date,
primary key (mail_entry, item));
create table action_line (
id bigserial primary key,
what text not null check (what in ('handled', 'undone', 'reminded', 'opened', 'corrected', 'mode', 'page opened')),
mail_entry bigint,
item text not null default '' check (item in ('', 'mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')),
mode text not null default '' check (mode in ('', 'work', 'personal', 'everything')),
at timestamptz not null default now());
create index action_line_at on action_line (at);
@@ -659,3 +659,139 @@ func (s *PgStore) UndoLesson(ctx context.Context, id int64) error {
}
return nil
}
// ---- handling and the action log (novox/hq ADR 0517, issue 1858) ------------------------------------------------
func (s *PgStore) Handlings(ctx context.Context) ([]Handling, error) {
rows, err := s.pool.Query(ctx, `select mail_entry, item, handled, shows_again from item_handling`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Handling
for rows.Next() {
var h Handling
var handled, showsAgain *time.Time
if err := rows.Scan(&h.Entry, &h.Item, &handled, &showsAgain); err != nil {
return nil, err
}
if handled != nil {
h.Handled = handled.UTC()
}
if showsAgain != nil {
h.ShowsAgain = *showsAgain
}
out = append(out, h)
}
return out, rows.Err()
}
// insertLine writes one action line in the caller's transaction: the one statement that writes the action log
// (ADR 0517 §5; TestOnlyTheActsWriteTheActionLog).
func insertLine(ctx context.Context, tx pgx.Tx, line ActionLine) error {
var entry *int64
if line.Entry != 0 {
entry = &line.Entry
}
_, err := tx.Exec(ctx, `insert into action_line (what, mail_entry, item, mode, at) values ($1, $2, $3, $4, $5)`,
line.What, entry, line.Item, line.Mode, line.At)
return err
}
// inTx runs f in one transaction, committed only when f succeeds.
func (s *PgStore) inTx(ctx context.Context, f func(tx pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if err := f(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *PgStore) Act(ctx context.Context, h Handling, line ActionLine) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
if h.Handled.IsZero() && h.ShowsAgain.IsZero() {
if _, err := tx.Exec(ctx, `delete from item_handling where mail_entry = $1 and item = $2`, h.Entry, h.Item); err != nil {
return err
}
} else if _, err := tx.Exec(ctx, `insert into item_handling (mail_entry, item, handled, shows_again) values ($1, $2, $3, $4)
on conflict (mail_entry, item) do update set handled = excluded.handled, shows_again = excluded.shows_again`,
h.Entry, h.Item, nullDate(h.Handled), nullDate(h.ShowsAgain)); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return ErrNotFound
}
return err
}
return insertLine(ctx, tx, line)
})
}
func (s *PgStore) Record(ctx context.Context, line ActionLine) error {
return s.inTx(ctx, func(tx pgx.Tx) error { return insertLine(ctx, tx, line) })
}
func (s *PgStore) CorrectEntry(ctx context.Context, e Entry, l Lesson, line ActionLine) (int64, error) {
var id int64
err := s.inTx(ctx, func(tx pgx.Tx) error {
if err := updateEntryIn(ctx, tx, e); err != nil {
return err
}
var group *int64
if l.Group != 0 {
group = &l.Group
}
if err := tx.QueryRow(ctx, `insert into reading_lesson
(account, folder, sender_name, sender_address, subject, mail_group, change, made, undone)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id`,
l.Account, l.Folder, l.SenderName, l.SenderAddress, l.Subject, group, l.Change, l.Made, l.Undone).Scan(&id); err != nil {
return err
}
return insertLine(ctx, tx, line)
})
return id, err
}
func (s *PgStore) Actions(ctx context.Context, limit int) ([]ActionLine, error) {
rows, err := s.pool.Query(ctx, `select id, what, mail_entry, item, mode, at from action_line order by at desc, id desc limit $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ActionLine
for rows.Next() {
var a ActionLine
var entry *int64
if err := rows.Scan(&a.ID, &a.What, &entry, &a.Item, &a.Mode, &a.At); err != nil {
return nil, err
}
if entry != nil {
a.Entry = *entry
}
a.At = a.At.UTC()
out = append(out, a)
}
return out, rows.Err()
}
func (s *PgStore) LastAction(ctx context.Context, what string) (time.Time, bool, error) {
var at *time.Time
if err := s.pool.QueryRow(ctx, `select max(at) from action_line where what = $1`, what).Scan(&at); err != nil {
return time.Time{}, false, err
}
if at == nil {
return time.Time{}, false, nil
}
return at.UTC(), true, nil
}
func (s *PgStore) RemoveActionsBefore(ctx context.Context, before time.Time) (int64, error) {
tag, err := s.pool.Exec(ctx, `delete from action_line where at < $1`, before)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
@@ -1,4 +1,4 @@
-- The consolidated schema, through migration 0004: what a fresh database is given at once (the mesh's rule: a
-- The consolidated schema, through migration 0005: what a fresh database is given at once (the mesh's rule: a
-- numbered migration for the running store, and this for a fresh install).
create table mail_group (
id bigserial primary key,
@@ -106,3 +106,24 @@ create table rereading (
ended timestamptz not null,
mails integer not null,
constraint rereading_once check (id = 1));
-- The operator's handling (novox/hq ADR 0517 §1, §2, §10, issue 1858): per item of an entry — a task, a
-- promise, or the mail itself — when it was handled, and the day a reminded item shows again. It goes with
-- its entry.
create table item_handling (
mail_entry bigint not null references mail_entry (id) on delete cascade,
item text not null check (item in ('mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')),
handled timestamptz,
shows_again date,
primary key (mail_entry, item));
-- The action log (ADR 0517 §3, §5, §9): one line per act on the mail page — what was done, the mail id, the
-- item, the mode and when; never mail text. Kept 365 days.
create table action_line (
id bigserial primary key,
what text not null check (what in ('handled', 'undone', 'reminded', 'opened', 'corrected', 'mode', 'page opened')),
mail_entry bigint,
item text not null default '' check (item in ('', 'mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')),
mode text not null default '' check (mode in ('', 'work', 'personal', 'everything')),
at timestamptz not null default now());
create index action_line_at on action_line (at);
@@ -240,8 +240,56 @@ type Store interface {
// that it was, when it started and ended and how many mails it read, so a restart never starts it again.
RereadingDone(ctx context.Context) (bool, error)
KeepRereading(ctx context.Context, started, ended time.Time, mails int) error
// Handlings answers every item's handling and reminder (ADR 0517 §1, §2, issue 1858).
Handlings(ctx context.Context) ([]Handling, error)
// Act replaces one item's handling with h — a zero Handled and a zero ShowsAgain remove it — and writes
// line, in one transaction: an act that fails writes no line (ADR 0517 §5).
Act(ctx context.Context, h Handling, line ActionLine) error
// Record writes one line that changes no entry: opened, a mode, the page opened (ADR 0517 §4, §5).
Record(ctx context.Context, line ActionLine) error
// CorrectEntry is correct's act (ADR 0459 §8, ADR 0517 §5): the entry updated, the reading lesson kept and
// the corrected line written, in one transaction; it answers the lesson's id. ErrNotFound when unknown.
CorrectEntry(ctx context.Context, e Entry, l Lesson, line ActionLine) (int64, error)
// Actions answers the action log, newest first, at most limit lines.
Actions(ctx context.Context, limit int) ([]ActionLine, error)
// LastAction answers when the newest line of what was written, and whether there is one.
LastAction(ctx context.Context, what string) (time.Time, bool, error)
// RemoveActionsBefore removes every line older than before, answering how many (ADR 0517 §9).
RemoveActionsBefore(ctx context.Context, before time.Time) (int64, error)
}
// Handling is one item's handling (ADR 0517 §1, §2): the item ("mail", "task n" or "promise n"), when it was
// handled (zero while it is not), and the day it shows again after "remind me tomorrow" (zero for none).
type Handling struct {
Entry int64
Item string
Handled time.Time
ShowsAgain time.Time // a day: midnight UTC of that date
}
// ActionLine is one line of the action log (ADR 0517 §3): what was done, the mail entry it concerns (0 for
// none), the item ("" for none), the mode and when — never text from a mail.
type ActionLine struct {
ID int64
What string
Entry int64
Item string
Mode string
At time.Time
}
// What an action line says was done (ADR 0517 §3); the table's own check names the same.
const (
ActHandled = "handled"
ActUndone = "undone"
ActReminded = "reminded"
ActOpened = "opened"
ActCorrected = "corrected"
ActMode = "mode"
ActPageOpened = "page opened"
)
// ErrNotFound is answered by a Store method asked for something it does not hold.
var ErrNotFound = notFoundError{}