Files
LibreMail-Bug-Report-Ingest/internal/storage/status.go
T
JMR-devandClaude Opus 4.8 cb7d9c67a0 #10 Report lifecycle/status metadata (pending/removed/published)
Add a status layer over the ObjectStore so each stored report has a
lifecycle state, encoded in its object key as reports/<status>/<id>:
pending (new reports), removed (#11), published (#15). Encoding status in
the key prefix means "list pending" is a single prefix listing with no
secondary index to drift, so it returns exactly the pending reports by
construction.

Storage:
- Extend ObjectStore with List(ctx, prefix) and Delete(ctx, key); implement
  in MemoryStore (host) and the js/wasm R2Store. R2Store.List drives the R2
  binding's list() directly to page a prefix (the syumai helper takes no
  options), so a status with >1000 objects is still enumerated exactly.
- The ingest Sink now writes new reports under reports/pending/<id>, so
  accepted reports enter the lifecycle as pending. The <id> is stable across
  transitions.

lifecycle package:
- Manager over an ObjectStore: ListPending, GetPending(id), MarkRemoved(id),
  MarkPublished(id). A transition copies the opaque ciphertext frame to the
  destination status key and deletes the source key — bytes are never
  decrypted or re-encrypted; no key is needed to change status.
- Copy-then-delete is idempotent and retry-safe: Put(dest) before Delete(src)
  never loses a report, a retry converges (re-Put identical bytes, Delete the
  leftover source), and a transition of an id not in the source status returns
  ErrUnknownReport (unless it is already at the destination -> idempotent nil).

Tests (host, MemoryStore, no TinyGo):
- List-pending exactness across a mix of pending/removed/published.
- pending->removed and pending->published leave the pending set, appear under
  the target, and move byte-identical ciphertext that still decrypts.
- Idempotent retry and convergence from an interrupted (both-keys) state.
- Unknown/terminal-state ids error sensibly; new Sink reports list as pending.

Closes #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:25:11 -05:00

51 lines
2.1 KiB
Go

package storage
// Report lifecycle status, encoded in the object-key layout (#10).
//
// Every stored report lives under a status-partitioned key:
//
// reports/<status>/<id>
//
// where <id> is the stable, unguessable report id minted at ingest
// (see defaultObjectKey) and <status> is one of the values below. Encoding the
// status in the key prefix means:
//
// - "list the pending reports" is a single prefix List(StatusPrefix(StatusPending)),
// with no separate index to keep in sync; and
// - a status transition is a copy of the (already-encrypted) object to the new
// status's key followed by a delete of the old key. The stored bytes are pure
// ciphertext and are never decrypted, re-encrypted, or otherwise touched to
// change status (see internal/lifecycle).
//
// The <id> is stable across transitions: only the status segment of the key
// changes, so a report keeps its identity as it moves pending -> published/removed.
type Status string
const (
// StatusPending is a freshly accepted report awaiting the weekly publish job.
// New reports are written here by the ingest Sink.
StatusPending Status = "pending"
// StatusRemoved is a report a maintainer pulled before publication (#11); it
// is excluded from publishing.
StatusRemoved Status = "removed"
// StatusPublished is a report already published as a GitHub issue (#15); it is
// retained but never re-published.
StatusPublished Status = "published"
)
// ReportsPrefix namespaces every report object within the bucket.
const ReportsPrefix = "reports/"
// StatusPrefix is the key prefix shared by all reports in status s, e.g.
// "reports/pending/". Listing this prefix enumerates exactly the reports in that
// status. The trailing slash is load-bearing: it keeps the statuses disjoint so
// (for example) "reports/pending/" never matches a "reports/published/..." key.
func StatusPrefix(s Status) string {
return ReportsPrefix + string(s) + "/"
}
// ReportKey is the object key for report id in status s: reports/<status>/<id>.
func ReportKey(s Status, id string) string {
return StatusPrefix(s) + id
}