The event log: sealed input events, valueless recalc markers
3 files changed,
+174
−0
internal/sheets/store/events.go+74 −0internal/sheets/store/events_test.go+49 −0internal/sheets/store/helpers_test.go+51 −0
Intent
diff --git a/internal/sheets/store/events.go b/internal/sheets/store/events.go| new file mode 100644 |
| index 0000000..656f235 |
| --- /dev/null |
| +++ b/internal/sheets/store/events.go |
| @@ -0,0 +1,74 @@ |
| +// Package store owns persistence: cells, rows, columns, formats and the |
| +// append-only event log. |
| +package store |
| + |
| +import ( |
| + "encoding/json" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| +) |
| + |
| +// Event kinds. Two, deliberately: conflating them is what made an earlier |
| +// draft of the leak list wrong. |
| +const ( |
| + KindInput = "input" |
| + KindRecalc = "recalc" |
| +) |
| + |
| +// Event is the append-only log. It does four jobs — live-sync fan-out, |
| +// version history, undo, and audit — which is why the storage model pays. |
| +type Event struct { |
| + ID uint64 `gorm:"primaryKey"` |
| + Kind string `gorm:"index;size:16"` |
| + TabID uint64 `gorm:"index"` |
| + ActorID uint64 |
| + PayloadSealed []byte // seal purpose "event.payload"; empty for recalc |
| + TriggerID uint64 // recalc only: the input event that caused it |
| + DerivedCount int // recalc only: how many cells the pass touched |
| + CreatedAt time.Time |
| +} |
| + |
| +// InputEvent is a person's edit, before sealing. |
| +type InputEvent struct { |
| + TabID, RowID, ColID uint64 |
| + ActorID uint64 |
| + OldRaw, NewRaw string |
| +} |
| + |
| +type inputPayload struct { |
| + RowID uint64 `json:"r"` |
| + ColID uint64 `json:"c"` |
| + OldRaw string `json:"o"` |
| + NewRaw string `json:"n"` |
| +} |
| + |
| +// AppendInput writes a sealed input event. Old and new raw input are user |
| +// content, so the payload is sealed; the row's shape is not. |
| +func AppendInput(tx *gorm.DB, s *seal.Sealer, e InputEvent) error { |
| + body, err := json.Marshal(inputPayload{ |
| + RowID: e.RowID, ColID: e.ColID, OldRaw: e.OldRaw, NewRaw: e.NewRaw, |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + sealed, err := s.Seal("event.payload", body) |
| + if err != nil { |
| + return err |
| + } |
| + return tx.Create(&Event{ |
| + Kind: KindInput, TabID: e.TabID, ActorID: e.ActorID, |
| + PayloadSealed: sealed, |
| + }).Error |
| +} |
| + |
| +// AppendRecalc records that a recalculation pass ran and how wide it was. |
| +// It carries no values: version history replays inputs and recomputes, which |
| +// is slower to restore and the only version that stays honest at scale. |
| +func AppendRecalc(tx *gorm.DB, triggerID uint64, derived int) error { |
| + return tx.Create(&Event{ |
| + Kind: KindRecalc, TriggerID: triggerID, DerivedCount: derived, |
| + }).Error |
| +} |
diff --git a/internal/sheets/store/events_test.go b/internal/sheets/store/events_test.go| new file mode 100644 |
| index 0000000..3460490 |
| --- /dev/null |
| +++ b/internal/sheets/store/events_test.go |
| @@ -0,0 +1,49 @@ |
| +package store_test |
| + |
| +import ( |
| + "path/filepath" |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| + "amadan.net/oficina/sheets/internal/sheets/sealtest" |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +func TestInputEventIsSealedOnDisk(t *testing.T) { |
| + dir := t.TempDir() |
| + dbPath := filepath.Join(dir, "test.db") |
| + db := openTestDB(t, dbPath) // helper defined below |
| + s, _ := seal.New(make32()) |
| + |
| + err := store.AppendInput(db, s, store.InputEvent{ |
| + TabID: 1, RowID: 1, ColID: 1, ActorID: 7, |
| + OldRaw: "", |
| + NewRaw: "CANARY-CELL-VALUE-8f3a", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + checkpoint(t, db) // force a WAL checkpoint so bytes hit the file |
| + |
| + sealtest.AssertNoPlaintext(t, dbPath) |
| +} |
| + |
| +// A recalc marker records that a pass happened and how wide it was. It must |
| +// never carry derived values: logging them makes the log grow with the |
| +// fan-out of every edit rather than with the number of edits, in a file |
| +// Litestream ships continuously. See spec section 4.3. |
| +func TestRecalcMarkerHoldsNoValues(t *testing.T) { |
| + dir := t.TempDir() |
| + db := openTestDB(t, filepath.Join(dir, "test.db")) |
| + if err := store.AppendRecalc(db, 42, 1200); err != nil { |
| + t.Fatal(err) |
| + } |
| + var e store.Event |
| + db.Where("kind = ?", store.KindRecalc).First(&e) |
| + if len(e.PayloadSealed) != 0 { |
| + t.Fatal("recalc marker carries a payload; it must not") |
| + } |
| + if e.DerivedCount != 1200 { |
| + t.Fatalf("DerivedCount = %d, want 1200", e.DerivedCount) |
| + } |
| +} |
diff --git a/internal/sheets/store/helpers_test.go b/internal/sheets/store/helpers_test.go| new file mode 100644 |
| index 0000000..e09d5cb |
| --- /dev/null |
| +++ b/internal/sheets/store/helpers_test.go |
| @@ -0,0 +1,51 @@ |
| +package store_test |
| + |
| +import ( |
| + "testing" |
| + |
| + rdb "github.com/carlosframework/rastrillo/db" |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// openTestDB opens a real SQLite database through rastrillo's db package — |
| +// the same cgo-free modernc driver, WAL journal mode and pragma order the |
| +// app uses at runtime — and migrates the event log table onto it. This is |
| +// deliberately not sqlite's :memory: mode and not a hand-rolled driver |
| +// setup: the plaintext gate has to inspect the actual file bytes an actor |
| +// with bucket access would see, so the test path has to be the real one. |
| +func openTestDB(t *testing.T, path string) *gorm.DB { |
| + t.Helper() |
| + d, err := rdb.Open(path, nil) |
| + if err != nil { |
| + t.Fatalf("open db: %v", err) |
| + } |
| + t.Cleanup(func() { |
| + if err := d.Close(); err != nil { |
| + t.Errorf("close db: %v", err) |
| + } |
| + }) |
| + if err := d.G.AutoMigrate(&store.Event{}); err != nil { |
| + t.Fatalf("migrate: %v", err) |
| + } |
| + return d.G |
| +} |
| + |
| +// make32 returns a 32-byte all-zero stand-in DEK, sufficient for seal.New |
| +// in tests that care about sealing behaviour, not key material. |
| +func make32() []byte { |
| + return make([]byte, 32) |
| +} |
| + |
| +// checkpoint forces WAL frames into the main database file with |
| +// PRAGMA wal_checkpoint(TRUNCATE), so a test that reads the main file |
| +// afterward sees settled bytes rather than data that may still be sitting |
| +// only in the WAL (or, before any write reaches the OS, only in a driver |
| +// buffer in memory). |
| +func checkpoint(t *testing.T, db *gorm.DB) { |
| + t.Helper() |
| + if err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil { |
| + t.Fatalf("checkpoint: %v", err) |
| + } |
| +} |