issue 1857: keep a mail entry's tasks, promises and read-later (1857.1)
ADR 0516 §1: an entry holds at most 3 tasks and 3 promises in the model's own words, and whether it is read later. Migration 0004 and schema.sql add mail_task, mail_promise and read_later; the store writes and reads them with their entry, in one transaction.
This commit is contained in:
@@ -95,13 +95,13 @@ func (s *memStore) AddEntry(_ context.Context, e Entry) (int64, error) {
|
||||
if held.Account == e.Account && entryKey(held) == entryKey(e) {
|
||||
// As PgStore's on conflict: the judgement replaced, the id, headers and told kept (1849).
|
||||
e.ID, e.Folder, e.UIDValidity, e.UID, e.Told = held.ID, held.Folder, held.UIDValidity, held.UID, held.Told
|
||||
s.entries[i] = e
|
||||
s.entries[i] = cloneEntry(e)
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
s.nextEntry++
|
||||
e.ID = s.nextEntry
|
||||
s.entries = append(s.entries, e)
|
||||
s.entries = append(s.entries, cloneEntry(e))
|
||||
return e.ID, nil
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (s *memStore) UpdateEntry(_ context.Context, e Entry) error {
|
||||
defer s.mu.Unlock()
|
||||
for i, existing := range s.entries {
|
||||
if existing.ID == e.ID {
|
||||
s.entries[i] = e
|
||||
s.entries[i] = cloneEntry(e)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func (s *memStore) Entry(_ context.Context, id int64) (Entry, bool, error) {
|
||||
defer s.mu.Unlock()
|
||||
for _, e := range s.entries {
|
||||
if e.ID == id {
|
||||
return e, true, nil
|
||||
return cloneEntry(e), true, nil
|
||||
}
|
||||
}
|
||||
return Entry{}, false, nil
|
||||
@@ -134,7 +134,9 @@ func (s *memStore) Entries(context.Context) ([]Entry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Entry, len(s.entries))
|
||||
copy(out, s.entries)
|
||||
for i, e := range s.entries {
|
||||
out[i] = cloneEntry(e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
-- Issue 1857 (novox/hq ADR 0516 §1, §2): a mail entry also holds at most 3 tasks and 3 promises, each line
|
||||
-- the model's own words of at most 120 characters, never text taken from the mail, and whether the mail is
|
||||
-- read later. Older entries keep none.
|
||||
alter table mail_entry add column read_later boolean not null default false;
|
||||
create table mail_task (
|
||||
mail_entry bigint not null references mail_entry (id) on delete cascade,
|
||||
n smallint not null check (n between 1 and 3),
|
||||
line text not null check (char_length(line) between 1 and 120),
|
||||
due_date date,
|
||||
primary key (mail_entry, n));
|
||||
create table mail_promise (
|
||||
mail_entry bigint not null references mail_entry (id) on delete cascade,
|
||||
n smallint not null check (n between 1 and 3),
|
||||
kind text not null check (kind in ('you promised', 'waiting on others', 'on its way')),
|
||||
line text not null check (char_length(line) between 1 and 120),
|
||||
who text not null default '' check (char_length(who) <= 120),
|
||||
date date,
|
||||
primary key (mail_entry, n));
|
||||
@@ -362,23 +362,125 @@ func (s *PgStore) AddEntry(ctx context.Context, e Entry) (int64, error) {
|
||||
m := string(e.Mark)
|
||||
mark = &m
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
var id int64
|
||||
// One entry per account and message key (1849): a message judged again (its folder's second copy in a
|
||||
// race, or the re-reading) replaces its judgement and keeps its id, headers and told.
|
||||
err := s.pool.QueryRow(ctx, `insert into mail_entry
|
||||
err = tx.QueryRow(ctx, `insert into mail_entry
|
||||
(account, message_id, folder, date, sender_name, sender_address, subject, mail_group, summary, mark, why, due_date, told, judged,
|
||||
uidvalidity, uid, message_key)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
uidvalidity, uid, message_key, read_later)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
on conflict (account, message_key) do update set mail_group = excluded.mail_group, summary = excluded.summary,
|
||||
mark = excluded.mark, why = excluded.why, due_date = excluded.due_date, judged = excluded.judged
|
||||
mark = excluded.mark, why = excluded.why, due_date = excluded.due_date, judged = excluded.judged,
|
||||
read_later = excluded.read_later
|
||||
returning id`,
|
||||
e.Account, e.MessageID, e.Folder, e.Date, e.SenderName, e.SenderAddress, e.Subject, group, e.Summary, mark, e.Why,
|
||||
nullDate(e.DueDate), e.Told, e.Judged, int64(e.UIDValidity), int64(e.UID),
|
||||
MessageKey(e.MessageID, e.Folder, e.UIDValidity, e.UID)).Scan(&id)
|
||||
return id, err
|
||||
MessageKey(e.MessageID, e.Folder, e.UIDValidity, e.UID), e.ReadLater).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := writeItems(ctx, tx, id, e); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// writeItems replaces an entry's tasks and promises with e's (1857), in the caller's transaction.
|
||||
func writeItems(ctx context.Context, tx pgx.Tx, id int64, e Entry) error {
|
||||
if _, err := tx.Exec(ctx, `delete from mail_task where mail_entry = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `delete from mail_promise where mail_entry = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range e.Tasks {
|
||||
if _, err := tx.Exec(ctx, `insert into mail_task (mail_entry, n, line, due_date) values ($1, $2, $3, $4)`,
|
||||
id, t.N, t.Line, nullDate(t.Due)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, p := range e.Promises {
|
||||
if _, err := tx.Exec(ctx, `insert into mail_promise (mail_entry, n, kind, line, who, date) values ($1, $2, $3, $4, $5, $6)`,
|
||||
id, p.N, string(p.Kind), p.Line, p.Who, nullDate(p.Date)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readItems fills the tasks and promises of the entries given (by id), in their numbers' order.
|
||||
func (s *PgStore) readItems(ctx context.Context, entries []Entry, where string, args ...any) error {
|
||||
at := map[int64]int{}
|
||||
for i, e := range entries {
|
||||
at[e.ID] = i
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `select mail_entry, n, line, due_date from mail_task `+where+` order by mail_entry, n`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var t Task
|
||||
var due *time.Time
|
||||
if err := rows.Scan(&id, &t.N, &t.Line, &due); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if due != nil {
|
||||
t.Due = *due
|
||||
}
|
||||
if i, ok := at[id]; ok {
|
||||
entries[i].Tasks = append(entries[i].Tasks, t)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = s.pool.Query(ctx, `select mail_entry, n, kind, line, who, date from mail_promise `+where+` order by mail_entry, n`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var p Promise
|
||||
var kind string
|
||||
var date *time.Time
|
||||
if err := rows.Scan(&id, &p.N, &kind, &p.Line, &p.Who, &date); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Kind = PromiseKind(kind)
|
||||
if date != nil {
|
||||
p.Date = *date
|
||||
}
|
||||
if i, ok := at[id]; ok {
|
||||
entries[i].Promises = append(entries[i].Promises, p)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) UpdateEntry(ctx context.Context, e Entry) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
if err := updateEntryIn(ctx, tx, e); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// updateEntryIn is UpdateEntry within the caller's transaction (correct writes its lesson and its action
|
||||
// line in the same one, ADR 0517 §5).
|
||||
func updateEntryIn(ctx context.Context, tx pgx.Tx, e Entry) error {
|
||||
var group *int64
|
||||
if e.Group != 0 {
|
||||
group = &e.Group
|
||||
@@ -388,18 +490,18 @@ func (s *PgStore) UpdateEntry(ctx context.Context, e Entry) error {
|
||||
m := string(e.Mark)
|
||||
mark = &m
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `update mail_entry set account = $2, message_id = $3, folder = $4, date = $5,
|
||||
tag, err := tx.Exec(ctx, `update mail_entry set account = $2, message_id = $3, folder = $4, date = $5,
|
||||
sender_name = $6, sender_address = $7, subject = $8, mail_group = $9, summary = $10, mark = $11, why = $12,
|
||||
due_date = $13, told = $14, judged = $15 where id = $1`,
|
||||
due_date = $13, told = $14, judged = $15, read_later = $16 where id = $1`,
|
||||
e.ID, e.Account, e.MessageID, e.Folder, e.Date, e.SenderName, e.SenderAddress, e.Subject, group, e.Summary,
|
||||
mark, e.Why, nullDate(e.DueDate), e.Told, e.Judged)
|
||||
mark, e.Why, nullDate(e.DueDate), e.Told, e.Judged, e.ReadLater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
return writeItems(ctx, tx, e.ID, e)
|
||||
}
|
||||
|
||||
func scanEntry(row pgx.Row) (Entry, error) {
|
||||
@@ -409,7 +511,7 @@ func scanEntry(row pgx.Row) (Entry, error) {
|
||||
var dueDate *time.Time
|
||||
var uidvalidity, uid int64
|
||||
err := row.Scan(&e.ID, &e.Account, &e.MessageID, &e.Folder, &e.Date, &e.SenderName, &e.SenderAddress, &e.Subject,
|
||||
&group, &e.Summary, &mark, &e.Why, &dueDate, &e.Told, &e.Judged, &uidvalidity, &uid)
|
||||
&group, &e.Summary, &mark, &e.Why, &dueDate, &e.Told, &e.Judged, &uidvalidity, &uid, &e.ReadLater)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
@@ -427,14 +529,21 @@ func scanEntry(row pgx.Row) (Entry, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
const entryColumns = `id, account, message_id, folder, date, sender_name, sender_address, subject, mail_group, summary, mark, why, due_date, told, judged, uidvalidity, uid`
|
||||
const entryColumns = `id, account, message_id, folder, date, sender_name, sender_address, subject, mail_group, summary, mark, why, due_date, told, judged, uidvalidity, uid, read_later`
|
||||
|
||||
func (s *PgStore) Entry(ctx context.Context, id int64) (Entry, bool, error) {
|
||||
e, err := scanEntry(s.pool.QueryRow(ctx, `select `+entryColumns+` from mail_entry where id = $1`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Entry{}, false, nil
|
||||
}
|
||||
return e, err == nil, err
|
||||
if err != nil {
|
||||
return Entry{}, false, err
|
||||
}
|
||||
one := []Entry{e}
|
||||
if err := s.readItems(ctx, one, `where mail_entry = $1`, id); err != nil {
|
||||
return Entry{}, false, err
|
||||
}
|
||||
return one[0], true, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Entries(ctx context.Context) ([]Entry, error) {
|
||||
@@ -451,7 +560,11 @@ func (s *PgStore) Entries(ctx context.Context) ([]Entry, error) {
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
return out, s.readItems(ctx, out, ``)
|
||||
}
|
||||
|
||||
func (s *PgStore) FindOrCreateGroup(ctx context.Context, label string) (Group, error) {
|
||||
|
||||
@@ -234,3 +234,38 @@ func TestPgStoreKeepsOneMessageInTwoFoldersOnce(t *testing.T) {
|
||||
t.Fatalf("entries = %+v (%v), want one, its judgement replaced and its folder kept", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1857/AC8 through the real store: an entry's tasks, promises and read-later flag are kept and read back, a
|
||||
// re-judged message replaces them, and a correction's removal of one keeps the others' numbers.
|
||||
func TestPgStoreKeepsTasksPromisesAndReadLater(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := preparedStore(t)
|
||||
date := time.Date(2026, 10, 22, 9, 0, 0, 0, time.UTC)
|
||||
due := time.Date(2026, 10, 24, 0, 0, 0, 0, time.UTC)
|
||||
e := Entry{Account: "work", MessageID: "<t@example.com>", Folder: "INBOX", Date: date, Mark: MarkMatters, Judged: date,
|
||||
ReadLater: true,
|
||||
Tasks: []Task{{N: 1, Line: "Send Sara the signed offer", Due: due}, {N: 2, Line: "Book the Harbor demo room"}},
|
||||
Promises: []Promise{{N: 1, Kind: WaitingOnOthers, Line: "Tim sends the revised quote", Who: "Tim Jacobs", Date: due}},
|
||||
}
|
||||
id, err := s.AddEntry(ctx, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := s.Entry(ctx, id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Entry(%d): %v %v", id, ok, err)
|
||||
}
|
||||
e.ID = id
|
||||
if !reflect.DeepEqual(got.Tasks, e.Tasks) || !reflect.DeepEqual(got.Promises, e.Promises) || !got.ReadLater {
|
||||
t.Fatalf("read back %+v, want the tasks %+v, the promises %+v and read later", got, e.Tasks, e.Promises)
|
||||
}
|
||||
got.Tasks = got.Tasks[1:]
|
||||
got.ReadLater = false
|
||||
if err := s.UpdateEntry(ctx, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := s.Entries(ctx)
|
||||
if err != nil || len(all) != 1 || len(all[0].Tasks) != 1 || all[0].Tasks[0].N != 2 || all[0].ReadLater {
|
||||
t.Fatalf("after removing task 1: %+v (%v), want task 2 alone, keeping its number, and no read later", all, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- The consolidated schema, through migration 0003: what a fresh database is given at once (the mesh's rule: a
|
||||
-- The consolidated schema, through migration 0004: 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,
|
||||
@@ -24,10 +24,29 @@ create table mail_entry (
|
||||
due_date date,
|
||||
told boolean not null default false,
|
||||
judged timestamptz not null default now(),
|
||||
read_later boolean not null default false,
|
||||
constraint mail_entry_one_per_message unique (account, message_key));
|
||||
create index mail_entry_account_date on mail_entry (account, date desc);
|
||||
create index mail_entry_judged on mail_entry (judged);
|
||||
|
||||
-- What a mail asks and promises (novox/hq ADR 0516 §1, §2, issue 1857): at most 3 tasks and 3 promises per
|
||||
-- entry, each line the model's own words of at most 120 characters, never text taken from the mail. An
|
||||
-- item keeps its number n while its entry lives, and goes with it.
|
||||
create table mail_task (
|
||||
mail_entry bigint not null references mail_entry (id) on delete cascade,
|
||||
n smallint not null check (n between 1 and 3),
|
||||
line text not null check (char_length(line) between 1 and 120),
|
||||
due_date date,
|
||||
primary key (mail_entry, n));
|
||||
create table mail_promise (
|
||||
mail_entry bigint not null references mail_entry (id) on delete cascade,
|
||||
n smallint not null check (n between 1 and 3),
|
||||
kind text not null check (kind in ('you promised', 'waiting on others', 'on its way')),
|
||||
line text not null check (char_length(line) between 1 and 120),
|
||||
who text not null default '' check (char_length(who) <= 120),
|
||||
date date,
|
||||
primary key (mail_entry, n));
|
||||
|
||||
-- A reading lesson (novox/hq ADR 0459 §8, §9, issue 1428): the corrected mail's own headers (never a body),
|
||||
-- the change made, when, and whether it was undone. sender_address and mail_group are its own matching keys
|
||||
-- for the next batch's question (1427): a lesson matches a mail of the same sender, or already known to be
|
||||
|
||||
@@ -48,6 +48,65 @@ type Entry struct {
|
||||
// Judged is when a batch judged this mail (issue 1520) — the week the weekly measure (1521/AC3) counts it
|
||||
// in, which is never the mail's own Date: a mail that arrived long before it was judged is read this week.
|
||||
Judged time.Time
|
||||
// Tasks, Promises and ReadLater are what the mail asks of the operator, what is promised in it and whether
|
||||
// it is worth reading later (novox/hq ADR 0516 §1, issue 1857): at most MaxTasks and MaxPromises, each line
|
||||
// the model's own words (§2). An item keeps its number for the entry's life: a task removed by correct
|
||||
// leaves a gap, never renumbers the rest (handling names items by number, ADR 0517 §4).
|
||||
Tasks []Task
|
||||
Promises []Promise
|
||||
ReadLater bool
|
||||
}
|
||||
|
||||
// The bounds of what an entry's judgement holds (ADR 0516 §1, §2).
|
||||
const (
|
||||
MaxTasks = 3
|
||||
MaxPromises = 3
|
||||
MaxLine = 120 // characters, for a task's or a promise's line and a promise's who
|
||||
)
|
||||
|
||||
// Task is one thing the mail asks of the operator: the model's own line and a due date, zero for none.
|
||||
type Task struct {
|
||||
N int
|
||||
Line string
|
||||
Due time.Time
|
||||
}
|
||||
|
||||
// PromiseKind is one of the three kinds of promise (ADR 0516 §1).
|
||||
type PromiseKind string
|
||||
|
||||
const (
|
||||
YouPromised PromiseKind = "you promised"
|
||||
WaitingOnOthers PromiseKind = "waiting on others"
|
||||
OnItsWay PromiseKind = "on its way"
|
||||
)
|
||||
|
||||
// PromiseKinds are the three, in the order the list shows them.
|
||||
var PromiseKinds = []PromiseKind{YouPromised, WaitingOnOthers, OnItsWay}
|
||||
|
||||
func validPromiseKind(k string) bool {
|
||||
for _, known := range PromiseKinds {
|
||||
if string(known) == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Promise is one promise in the mail: its kind, the model's own line, who (where the mail names one) and a
|
||||
// date, zero where the mail gives none.
|
||||
type Promise struct {
|
||||
N int
|
||||
Kind PromiseKind
|
||||
Line string
|
||||
Who string
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// cloneEntry copies an entry with its own tasks and promises, so a store double never shares them.
|
||||
func cloneEntry(e Entry) Entry {
|
||||
e.Tasks = append([]Task(nil), e.Tasks...)
|
||||
e.Promises = append([]Promise(nil), e.Promises...)
|
||||
return e
|
||||
}
|
||||
|
||||
// Group is one mail group (ADR 0459 §4). Count, how many entries hold it, is answered by Store.Groups
|
||||
@@ -154,7 +213,8 @@ type Store interface {
|
||||
// AddEntry records a mail entry (1520/AC3) and answers its own id: one per account and MessageKey (1849) —
|
||||
// for a message that already has one, its judgement is replaced and its id kept.
|
||||
AddEntry(ctx context.Context, e Entry) (int64, error)
|
||||
// UpdateEntry replaces an entry's fields by its id (1428/AC1, correct). ErrNotFound when unknown.
|
||||
// UpdateEntry replaces an entry's fields by its id (1428/AC1, correct), its tasks, promises and read-later
|
||||
// too (1857). ErrNotFound when unknown.
|
||||
UpdateEntry(ctx context.Context, e Entry) error
|
||||
// Entry answers one entry by id, and whether it was found (1428/AC1, AC5: an unknown id is refused).
|
||||
Entry(ctx context.Context, id int64) (Entry, bool, error)
|
||||
|
||||
Reference in New Issue
Block a user