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>
51 lines
2.1 KiB
Go
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
|
|
}
|