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.
4 files changed,
+245
−0
internal/sheets/calc/ref.go+68 −0internal/sheets/calc/ref_test.go+39 −0internal/sheets/models.go+75 −0internal/sheets/models_test.go+63 −0
Intent
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) |
| + } |
| + } |
| + } |
| +} |