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:
@@ -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 {
|
||||
|
||||
+18
@@ -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{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user