Files
LibreMail-Bug-Report-Ingest/internal/storage/memory.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

87 lines
2.1 KiB
Go

package storage
import (
"context"
"sort"
"strings"
"sync"
)
// MemoryStore is an in-memory ObjectStore for tests, cmd/devserver, and local
// experimentation. It retains every object in a map and is not a production
// backend (it grows without bound). It is safe for concurrent use.
type MemoryStore struct {
mu sync.Mutex
objects map[string][]byte
}
// NewMemoryStore returns an empty MemoryStore.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{objects: make(map[string][]byte)}
}
// Put stores a copy of data at key.
func (m *MemoryStore) Put(_ context.Context, key string, data []byte) error {
cp := append([]byte(nil), data...)
m.mu.Lock()
defer m.mu.Unlock()
m.objects[key] = cp
return nil
}
// Get returns a copy of the object at key, or ErrNotFound.
func (m *MemoryStore) Get(_ context.Context, key string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
data, ok := m.objects[key]
if !ok {
return nil, ErrNotFound
}
return append([]byte(nil), data...), nil
}
// List returns the stored keys that start with prefix, in sorted order. A prefix
// of "" lists every key (equivalent to Keys).
func (m *MemoryStore) List(_ context.Context, prefix string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
var keys []string
for k := range m.objects {
if strings.HasPrefix(k, prefix) {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys, nil
}
// Delete removes the object at key. Deleting an absent key is a no-op (nil),
// matching R2's idempotent delete so a retried transition behaves identically.
func (m *MemoryStore) Delete(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.objects, key)
return nil
}
// Keys returns the stored object keys in sorted order (test/ops helper).
func (m *MemoryStore) Keys() []string {
m.mu.Lock()
defer m.mu.Unlock()
keys := make([]string, 0, len(m.objects))
for k := range m.objects {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// Len reports how many objects are stored.
func (m *MemoryStore) Len() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.objects)
}
var _ ObjectStore = (*MemoryStore)(nil)