oficina / sheets Public

Storage schema, stable row and column identity, A1 as a view

Row and Col carry stable ids; Position is a plain field on the record,
never part of a key, so A1 references are rendered at view time from
calc.ColLabel/ParseColLabel/A1 rather than stored or rewritten on
insert/delete. Cell is sparse and keyed by (TabID, RowID, ColID); its
InputSealed/CachedSealed columns are the only place cell content lives,
with CacheVersion trailing InputVersion detecting a stale cache from a
crash mid-recalc. Format is interned by content digest.

Replaced the brief's TestNoPlaintextValueColumns, which only compared a
hardcoded string list to itself, with a reflection-based assertion over
the model structs: every *Sealed field must be []byte, and no field may
be named Title/Name/Input/Cached/Value. Verified by hand that adding
Title string to Sheet, or retyping NameSealed to string, both fail the
test as intended.
Paul Campbell 5cf5b06a9555c2338a8e3b3e526c44e99f38ab7d parent 6a5b434
4 files changed, +245 −0
  • internal/sheets/calc/ref.go +68 −0
  • internal/sheets/calc/ref_test.go +39 −0
  • internal/sheets/models.go +75 −0
  • internal/sheets/models_test.go +63 −0
diff --git a/internal/sheets/calc/ref.go b/internal/sheets/calc/ref.go
new file mode 100644
index 0000000..3e1cda8
--- /dev/null
+++ b/internal/sheets/calc/ref.go
@@ -0,0 +1,68 @@
+// Package calc owns everything about formulas: references, lexing, parsing,
+// evaluation and the dependency graph.
+package calc
+
+import (
+ "errors"
+ "strings"
+)
+
+// ColLabel renders a zero-based column position as a spreadsheet label.
+// This is bijective base-26, not ordinary base-26: there is no zero digit,
+// which is why 25 is "Z" and 26 is "AA" rather than "BA".
+func ColLabel(pos int) string {
+ if pos < 0 {
+ return ""
+ }
+ var sb strings.Builder
+ pos++
+ for pos > 0 {
+ pos--
+ sb.WriteByte(byte('A' + pos%26))
+ pos /= 26
+ }
+ b := []byte(sb.String())
+ for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
+ b[i], b[j] = b[j], b[i]
+ }
+ return string(b)
+}
+
+// ParseColLabel is ColLabel's inverse.
+func ParseColLabel(s string) (int, error) {
+ if s == "" {
+ return 0, errors.New("calc: empty column label")
+ }
+ n := 0
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c >= 'a' && c <= 'z' {
+ c -= 'a' - 'A'
+ }
+ if c < 'A' || c > 'Z' {
+ return 0, errors.New("calc: bad column label")
+ }
+ n = n*26 + int(c-'A') + 1
+ }
+ return n - 1, nil
+}
+
+// A1 renders zero-based positions as an A1 reference. Positions, not ids:
+// stable ids are the key, A1 is the view.
+func A1(rowPos, colPos int) string {
+ return ColLabel(colPos) + itoa(rowPos+1)
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var b [20]byte
+ i := len(b)
+ for n > 0 {
+ i--
+ b[i] = byte('0' + n%10)
+ n /= 10
+ }
+ return string(b[i:])
+}
diff --git a/internal/sheets/calc/ref_test.go b/internal/sheets/calc/ref_test.go
new file mode 100644
index 0000000..b55fbaf
--- /dev/null
+++ b/internal/sheets/calc/ref_test.go
@@ -0,0 +1,39 @@
+package calc
+
+import "testing"
+
+func TestColLabel(t *testing.T) {
+ for _, tc := range []struct {
+ pos int
+ want string
+ }{
+ {0, "A"}, {1, "B"}, {25, "Z"},
+ {26, "AA"}, {27, "AB"}, {51, "AZ"},
+ {52, "BA"}, {701, "ZZ"}, {702, "AAA"},
+ } {
+ if got := ColLabel(tc.pos); got != tc.want {
+ t.Errorf("ColLabel(%d) = %q, want %q", tc.pos, got, tc.want)
+ }
+ }
+}
+
+func TestParseColLabelRoundTrip(t *testing.T) {
+ for pos := 0; pos < 3000; pos++ {
+ got, err := ParseColLabel(ColLabel(pos))
+ if err != nil {
+ t.Fatalf("pos %d: %v", pos, err)
+ }
+ if got != pos {
+ t.Fatalf("round trip %d -> %q -> %d", pos, ColLabel(pos), got)
+ }
+ }
+}
+
+func TestA1(t *testing.T) {
+ if got := A1(0, 0); got != "A1" {
+ t.Errorf("A1(0,0) = %q, want A1", got)
+ }
+ if got := A1(6, 1); got != "B7" {
+ t.Errorf("A1(6,1) = %q, want B7", got)
+ }
+}
diff --git a/internal/sheets/models.go b/internal/sheets/models.go
new file mode 100644
index 0000000..674ceff
--- /dev/null
+++ b/internal/sheets/models.go
@@ -0,0 +1,75 @@
+package sheets
+
+import "time"
+
+// Sheet is one spreadsheet document.
+type Sheet struct {
+ ID uint64 `gorm:"primaryKey"`
+ TitleSealed []byte // seal purpose "sheet.title"
+ CreatedBy uint64
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ DeletedAt *time.Time `gorm:"index"`
+}
+
+// Tab is one sheet within a document — the tabs along the bottom.
+type Tab struct {
+ ID uint64 `gorm:"primaryKey"`
+ SheetID uint64 `gorm:"index"`
+ NameSealed []byte // seal purpose "tab.name"
+ Position int
+ FrozenRows int
+ FrozenCols int
+}
+
+// Row and Col carry stable identity. Position is a property of the record,
+// never part of a key — so inserting a row above a comment or a range does
+// not move what they point at. See spec section 4.2 for what this does and
+// does not buy: it removes reference rewriting for insert and delete only.
+type Row struct {
+ ID uint64 `gorm:"primaryKey"`
+ TabID uint64 `gorm:"index:idx_row_tab_pos"`
+ Position int `gorm:"index:idx_row_tab_pos"`
+ Height int
+}
+
+type Col struct {
+ ID uint64 `gorm:"primaryKey"`
+ TabID uint64 `gorm:"index:idx_col_tab_pos"`
+ Position int `gorm:"index:idx_col_tab_pos"`
+ Width int
+}
+
+// Cell is sparse: only non-empty cells exist.
+//
+// CacheVersion records the InputVersion its CachedSealed was derived from.
+// A cache whose CacheVersion trails its input is stale and must not be
+// served — which is how a crash mid-recalc is detected rather than assumed
+// away. See spec section 4.4.
+type Cell struct {
+ TabID uint64 `gorm:"primaryKey"`
+ RowID uint64 `gorm:"primaryKey"`
+ ColID uint64 `gorm:"primaryKey"`
+ InputSealed []byte // seal purpose "cell.input"
+ CachedSealed []byte // seal purpose "cell.cached"
+ InputVersion uint64
+ CacheVersion uint64
+ FormatID *uint64
+ UpdatedBy uint64
+ UpdatedAt time.Time
+}
+
+// Format is interned. A column of 5,000 identically styled cells is one row
+// here, not 5,000 — which is the difference between a format change being
+// cheap and being a table rewrite.
+type Format struct {
+ ID uint64 `gorm:"primaryKey"`
+ Digest string `gorm:"uniqueIndex;size:64"`
+ NumberFmt string
+ Bold bool
+ Italic bool
+ FillIntentH *float64 // OKLCH hue; nil means no fill
+ FillIntentC *float64 // OKLCH chroma
+ FillHex string // retained from XLSX import for lossless re-export
+ Align string
+}
diff --git a/internal/sheets/models_test.go b/internal/sheets/models_test.go
new file mode 100644
index 0000000..7600b1a
--- /dev/null
+++ b/internal/sheets/models_test.go
@@ -0,0 +1,63 @@
+package sheets
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// The models must carry no column that could hold a readable cell value.
+// This is a schema-level restatement of the one rule: there is nothing to
+// accidentally populate.
+//
+// This is a genuine reflection-based assertion, not a check of a hardcoded
+// list against itself. It walks every model struct that can hold user
+// content and asserts two properties directly on the struct shape:
+//
+// 1. every field whose name ends in "Sealed" has type []byte — a sealed
+// field typed string would mean somebody stored plaintext in it.
+// 2. no field is named exactly one of the forbidden bare names — those
+// names without the "Sealed" suffix are the shape a plaintext column
+// takes.
+//
+// Add "Title string" to Sheet and this test fails; that is the point.
+func TestNoPlaintextValueColumns(t *testing.T) {
+ forbidden := map[string]bool{
+ "Title": true,
+ "Name": true,
+ "Input": true,
+ "Cached": true,
+ "Value": true,
+ }
+
+ models := []any{
+ Sheet{},
+ Tab{},
+ Row{},
+ Col{},
+ Cell{},
+ Format{},
+ }
+
+ byteSliceType := reflect.TypeOf([]byte(nil))
+
+ for _, m := range models {
+ typ := reflect.TypeOf(m)
+ for i := 0; i < typ.NumField(); i++ {
+ field := typ.Field(i)
+
+ if strings.HasSuffix(field.Name, "Sealed") {
+ if field.Type != byteSliceType {
+ t.Errorf("%s.%s ends in Sealed but has type %s, want []byte",
+ typ.Name(), field.Name, field.Type)
+ }
+ continue
+ }
+
+ if forbidden[field.Name] {
+ t.Errorf("%s.%s is a plaintext-shaped field name; sealed content must use a %sSealed []byte field instead",
+ typ.Name(), field.Name, field.Name)
+ }
+ }
+ }
+}