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>
87 lines
2.1 KiB
Go
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)
|