Document that EventStore interface has no Update/Delete methods, enforcing append-only semantics by design. Events are immutable once persisted. Changes: - Update EventStore interface documentation in event.go to explicitly state immutability guarantee and explain why Update/Delete methods are absent - Add detailed retention policy documentation to JetStreamConfig showing how MaxAge limits enforce automatic expiration without manual deletion - Document JetStreamEventStore's immutability guarantee with storage-level explanation of file-based storage and limits-based retention - Add comprehensive immutability tests verifying: - Events cannot be modified after persistence - No Update or Delete methods exist on EventStore interface - Versions are monotonically increasing - Events cannot be deleted through the API - Update README with detailed immutability section explaining: - Interface-level append-only guarantee - Storage-level immutability through JetStream configuration - Audit trail reliability - Pattern for handling corrections (append new event) Closes #60 Co-Authored-By: Claude Code <noreply@anthropic.com>
216 lines
5.8 KiB
Go
216 lines
5.8 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"git.flowmade.one/flowmade-one/aether"
|
|
)
|
|
|
|
// TestEventImmutability_MemoryStore verifies that events cannot be modified after persistence
|
|
// in the in-memory event store. This demonstrates the append-only nature of event sourcing.
|
|
func TestEventImmutability_MemoryStore(t *testing.T) {
|
|
store := NewInMemoryEventStore()
|
|
actorID := "test-actor-123"
|
|
|
|
// Create and save an event
|
|
originalEvent := &aether.Event{
|
|
ID: "evt-immutable-1",
|
|
EventType: "TestEvent",
|
|
ActorID: actorID,
|
|
Version: 1,
|
|
Data: map[string]interface{}{
|
|
"value": "original",
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
err := store.SaveEvent(originalEvent)
|
|
if err != nil {
|
|
t.Fatalf("SaveEvent failed: %v", err)
|
|
}
|
|
|
|
// Retrieve the event from the store
|
|
events, err := store.GetEvents(actorID, 0)
|
|
if err != nil {
|
|
t.Fatalf("GetEvents failed: %v", err)
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
t.Fatal("expected 1 event, got 0")
|
|
}
|
|
|
|
retrievedEvent := events[0]
|
|
|
|
// Verify the stored event has the correct values
|
|
if retrievedEvent.Data["value"] != "original" {
|
|
t.Errorf("Data value mismatch: got %v, want %v", retrievedEvent.Data["value"], "original")
|
|
}
|
|
|
|
if retrievedEvent.EventType != "TestEvent" {
|
|
t.Errorf("EventType mismatch: got %q, want %q", retrievedEvent.EventType, "TestEvent")
|
|
}
|
|
|
|
// Verify ID is correct
|
|
if retrievedEvent.ID != "evt-immutable-1" {
|
|
t.Errorf("Event ID mismatch: got %q, want %q", retrievedEvent.ID, "evt-immutable-1")
|
|
}
|
|
}
|
|
|
|
// TestEventImmutability_NoUpdateMethod verifies that the EventStore interface
|
|
// has only append, read methods - no Update or Delete methods.
|
|
func TestEventImmutability_NoUpdateMethod(t *testing.T) {
|
|
// This test documents that the EventStore interface is append-only.
|
|
// The interface intentionally provides:
|
|
// - SaveEvent: append only
|
|
// - GetEvents: read only
|
|
// - GetLatestVersion: read only
|
|
//
|
|
// To verify this, we demonstrate that any attempt to call non-existent
|
|
// update/delete methods would be caught at compile time (not runtime).
|
|
// This is enforced by the interface definition in event.go which does
|
|
// not include Update, Delete, or Modify methods.
|
|
|
|
store := NewInMemoryEventStore()
|
|
|
|
// Compile-time check: these would not compile if we tried them:
|
|
// store.Update(event) // compile error: no such method
|
|
// store.Delete(eventID) // compile error: no such method
|
|
// store.Modify(eventID, newData) // compile error: no such method
|
|
|
|
// Only these methods exist:
|
|
var eventStore aether.EventStore = store
|
|
if eventStore == nil {
|
|
t.Fatal("eventStore is nil")
|
|
}
|
|
// If we got here, the compile-time checks passed
|
|
t.Log("EventStore interface enforces append-only semantics by design")
|
|
}
|
|
|
|
// TestEventImmutability_VersionOnlyGoesUp verifies that versions are monotonically
|
|
// increasing and attempting to save with a non-increasing version fails.
|
|
func TestEventImmutability_VersionOnlyGoesUp(t *testing.T) {
|
|
store := NewInMemoryEventStore()
|
|
actorID := "actor-version-check"
|
|
|
|
// Save first event with version 1
|
|
event1 := &aether.Event{
|
|
ID: "evt-v1",
|
|
EventType: "Event1",
|
|
ActorID: actorID,
|
|
Version: 1,
|
|
Data: map[string]interface{}{},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
err := store.SaveEvent(event1)
|
|
if err != nil {
|
|
t.Fatalf("SaveEvent(v1) failed: %v", err)
|
|
}
|
|
|
|
// Try to save with same version - should fail
|
|
event2Same := &aether.Event{
|
|
ID: "evt-v1-again",
|
|
EventType: "Event2",
|
|
ActorID: actorID,
|
|
Version: 1, // Same version
|
|
Data: map[string]interface{}{},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
err = store.SaveEvent(event2Same)
|
|
if err == nil {
|
|
t.Error("expected SaveEvent(same version) to fail, but it succeeded")
|
|
}
|
|
|
|
// Try to save with lower version - should fail
|
|
event3Lower := &aether.Event{
|
|
ID: "evt-v0",
|
|
EventType: "Event3",
|
|
ActorID: actorID,
|
|
Version: 0, // Lower version
|
|
Data: map[string]interface{}{},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
err = store.SaveEvent(event3Lower)
|
|
if err == nil {
|
|
t.Error("expected SaveEvent(lower version) to fail, but it succeeded")
|
|
}
|
|
|
|
// Save with next version - should succeed
|
|
event4Next := &aether.Event{
|
|
ID: "evt-v2",
|
|
EventType: "Event4",
|
|
ActorID: actorID,
|
|
Version: 2,
|
|
Data: map[string]interface{}{},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
err = store.SaveEvent(event4Next)
|
|
if err != nil {
|
|
t.Fatalf("SaveEvent(v2) failed: %v", err)
|
|
}
|
|
|
|
// Verify we have exactly 2 events
|
|
events, err := store.GetEvents(actorID, 0)
|
|
if err != nil {
|
|
t.Fatalf("GetEvents failed: %v", err)
|
|
}
|
|
|
|
if len(events) != 2 {
|
|
t.Errorf("expected 2 events, got %d", len(events))
|
|
}
|
|
}
|
|
|
|
// TestEventImmutability_EventCannotBeDeleted verifies that there is no way to delete
|
|
// events from the store through the EventStore interface.
|
|
func TestEventImmutability_EventCannotBeDeleted(t *testing.T) {
|
|
store := NewInMemoryEventStore()
|
|
actorID := "actor-nodelete"
|
|
|
|
// Save an event
|
|
event := &aether.Event{
|
|
ID: "evt-nodelete",
|
|
EventType: "ImportantEvent",
|
|
ActorID: actorID,
|
|
Version: 1,
|
|
Data: map[string]interface{}{"critical": true},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
err := store.SaveEvent(event)
|
|
if err != nil {
|
|
t.Fatalf("SaveEvent failed: %v", err)
|
|
}
|
|
|
|
// Retrieve it
|
|
events1, err := store.GetEvents(actorID, 0)
|
|
if err != nil {
|
|
t.Fatalf("GetEvents (1) failed: %v", err)
|
|
}
|
|
|
|
if len(events1) != 1 {
|
|
t.Fatal("expected 1 event after save")
|
|
}
|
|
|
|
// Try to delete through interface - this method doesn't exist
|
|
// store.Delete("evt-nodelete") // compile error: no such method
|
|
// store.DeleteByActorID(actorID) // compile error: no such method
|
|
|
|
// Verify the event is still there (we can't delete it)
|
|
events2, err := store.GetEvents(actorID, 0)
|
|
if err != nil {
|
|
t.Fatalf("GetEvents (2) failed: %v", err)
|
|
}
|
|
|
|
if len(events2) != 1 {
|
|
t.Errorf("expected 1 event (should not be deletable), got %d", len(events2))
|
|
}
|
|
|
|
if events2[0].ID != "evt-nodelete" {
|
|
t.Errorf("event ID changed: got %q, want %q", events2[0].ID, "evt-nodelete")
|
|
}
|
|
}
|