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:
jochen
2026-10-22 19:39:15 +02:00
parent a3626e7920
commit daff20d351
6 changed files with 268 additions and 21 deletions
@@ -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
}
@@ -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)