oficina / sheets Public

The event log: sealed input events, valueless recalc markers

Paul Campbell 711cab9e10d37580ff65496ce1254e18a0326902 parent 5cf5b06
3 files changed, +174 −0
  • internal/sheets/store/events.go +74 −0
  • internal/sheets/store/events_test.go +49 −0
  • internal/sheets/store/helpers_test.go +51 −0
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)
+ }
+}