feat: initialize nuntius project with full server implementation
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
// Package storage provides file-based persistence for nuntius.
|
||||
//
|
||||
// Today it contains only the newsletter subscriber log. It is designed
|
||||
// to be zero-dependency (stdlib only) and crash-safe: each Append writes
|
||||
// a single JSON line to an append-only file, so partial writes do not
|
||||
// corrupt earlier records.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Subscriber is a single newsletter signup, one JSON object per line.
|
||||
type Subscriber struct {
|
||||
Email string `json:"email"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Form string `json:"form"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// NewsletterStore is a file-based append-only log of subscribers.
|
||||
// All methods are safe for concurrent use.
|
||||
type NewsletterStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewNewsletterStore returns a store that appends to path.
|
||||
// The file and its parent directory are created lazily on first Append.
|
||||
func NewNewsletterStore(path string) *NewsletterStore {
|
||||
return &NewsletterStore{path: path}
|
||||
}
|
||||
|
||||
// Path returns the file path this store writes to.
|
||||
func (s *NewsletterStore) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
// Append writes sub as a single JSON line to the log.
|
||||
// The file and parent directory are created on first call.
|
||||
func (s *NewsletterStore) Append(sub Subscriber) error {
|
||||
if sub.CreatedAt.IsZero() {
|
||||
sub.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
line, err := json.Marshal(sub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal subscriber: %w", err)
|
||||
}
|
||||
line = append(line, '\n')
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err)
|
||||
}
|
||||
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", s.path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(line); err != nil {
|
||||
return fmt.Errorf("write %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the number of valid subscriber lines in the log.
|
||||
// Malformed lines are silently skipped so a partial write does not
|
||||
// brick the entire file.
|
||||
func (s *NewsletterStore) Count() (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.countLocked()
|
||||
}
|
||||
|
||||
func (s *NewsletterStore) countLocked() (int, error) {
|
||||
f, err := os.Open(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open %s: %w", s.path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n := 0
|
||||
scanner := bufio.NewScanner(f)
|
||||
// Allow up to 1 MB per line in case a single record balloons.
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var sub Subscriber
|
||||
if err := json.Unmarshal(line, &sub); err != nil {
|
||||
// Skip malformed lines rather than failing the whole count.
|
||||
continue
|
||||
}
|
||||
if sub.Email != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return n, fmt.Errorf("scan %s: %w", s.path, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// List returns all valid subscribers in insertion order.
|
||||
// Use with care on large files; it reads the whole log into memory.
|
||||
func (s *NewsletterStore) List() ([]Subscriber, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
f, err := os.Open(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", s.path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var out []Subscriber
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var sub Subscriber
|
||||
if err := json.Unmarshal(line, &sub); err != nil {
|
||||
continue
|
||||
}
|
||||
if sub.Email != "" {
|
||||
out = append(out, sub)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scan %s: %w", s.path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewsletterStore_AppendCreatesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs", "subscribers.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
if err := s.Append(Subscriber{Email: "a@example.com", Form: "newsletter"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected file to exist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_AppendAndCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
for i, e := range []string{"a@x.com", "b@x.com", "c@x.com"} {
|
||||
if err := s.Append(Subscriber{Email: e, Form: "newsletter"}); err != nil {
|
||||
t.Fatalf("Append #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := s.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Count: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("expected 3 subscribers, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_AppendIsOneLinePerRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
for _, e := range []string{"a@x.com", "b@x.com"} {
|
||||
if err := s.Append(Subscriber{Email: e, Form: "n"}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Each line should be a self-contained JSON object.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
count := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var sub Subscriber
|
||||
if err := json.Unmarshal(line, &sub); err != nil {
|
||||
t.Fatalf("Unmarshal line: %v", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scanner.Err: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 records, decoded %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_CountOnMissingFileIsZero(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "does-not-exist.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
n, err := s.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Count on missing file: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0 on missing file, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_ListReturnsInsertionOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
|
||||
want := []string{"a@x.com", "b@x.com", "c@x.com"}
|
||||
for _, e := range want {
|
||||
_ = s.Append(Subscriber{Email: e, Form: "n"})
|
||||
}
|
||||
|
||||
subs, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(subs) != len(want) {
|
||||
t.Fatalf("expected %d, got %d", len(want), len(subs))
|
||||
}
|
||||
for i, sub := range subs {
|
||||
if sub.Email != want[i] {
|
||||
t.Errorf("position %d: want %q, got %q", i, want[i], sub.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_SkipsMalformedLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subs.jsonl")
|
||||
|
||||
// Write a mix of valid and garbage lines.
|
||||
content := `{"email":"good1@x.com","form":"n","created_at":"2026-01-01T00:00:00Z"}
|
||||
this is not valid json
|
||||
{"email":"good2@x.com","form":"n","created_at":"2026-01-02T00:00:00Z"}
|
||||
{not even close to json
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
s := NewNewsletterStore(path)
|
||||
n, err := s.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Count: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("expected 2 valid records (skipping malformed), got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterStore_PathReturnsConfiguredPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "subs.jsonl")
|
||||
s := NewNewsletterStore(path)
|
||||
if got := s.Path(); got != path {
|
||||
t.Errorf("Path: want %q, got %q", path, got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user