| 1 | // Models for everything this package persists. |
| 2 | // |
| 3 | // They live here, in package store, rather than in package sheets where |
| 4 | // they started. The move happened when the HTTP layer needed to open a |
| 5 | // Handle: store imported sheets for these structs, so sheets could not |
| 6 | // import store back, and every route that reads a cell is in package |
| 7 | // sheets. Putting them where the persistence is removes the cycle |
| 8 | // rather than routing around it — and it is where models.go's own |
| 9 | // comment on Event already said they conceptually belonged. |
| 10 | // |
| 11 | // package sheets keeps Member, which is an app concern (who may hold a |
| 12 | // session) rather than a storage one, and keeps the Models list that |
| 13 | // `rastrillo migration check` reads: the list is a []any of values, so |
| 14 | // it names these types across the package boundary without needing |
| 15 | // them declared beside it. |
| 16 | package store |
| 17 | |
| 18 | import "time" |
| 19 | |
| 20 | // Sheet is one spreadsheet document. |
| 21 | type Sheet struct { |
| 22 | ID uint64 `gorm:"primaryKey"` |
| 23 | TitleSealed []byte // seal purpose "sheet.title" |
| 24 | CreatedBy uint64 |
| 25 | CreatedAt time.Time |
| 26 | UpdatedAt time.Time |
| 27 | DeletedAt *time.Time `gorm:"index"` |
| 28 | // FillingSince is set while a sheet is being filled from an |
| 29 | // imported file and cleared when that finishes. Non-nil means the |
| 30 | // sheet is NOT yet a thing anybody should see: every read path |
| 31 | // filters it out exactly as it filters DeletedAt. |
| 32 | // |
| 33 | // It exists because import cannot be one transaction. Cells are |
| 34 | // written and committed one at a time (Handle.Set recalculates and |
| 35 | // persists as it goes), so between the sheet row and the last cell |
| 36 | // there is a window in which the sheet exists and is wrong — and on |
| 37 | // this instance every team member sees every sheet, so that window |
| 38 | // is visible to the whole team. Three things go wrong in it: a |
| 39 | // half-filled sheet looks complete, a collaborator can edit one |
| 40 | // that is about to be discarded, and a process that stops |
| 41 | // mid-import (a deploy) leaves the partial behind forever, because |
| 42 | // the job that would have cleaned it up was in memory and died with |
| 43 | // it. |
| 44 | // |
| 45 | // A timestamp rather than a bool so the stale ones are also |
| 46 | // identifiable — an import interrupted by a deploy leaves a row |
| 47 | // whose FillingSince never clears, and a sweeper or an operator can |
| 48 | // find those by age. Invisible is already the safe state, so |
| 49 | // nothing has to run for correctness. |
| 50 | FillingSince *time.Time `gorm:"index"` |
| 51 | } |
| 52 | |
| 53 | // Tab is one sheet within a document — the tabs along the bottom. |
| 54 | type Tab struct { |
| 55 | ID uint64 `gorm:"primaryKey"` |
| 56 | SheetID uint64 `gorm:"index"` |
| 57 | NameSealed []byte // seal purpose "tab.name" |
| 58 | Position int |
| 59 | FrozenRows int |
| 60 | FrozenCols int |
| 61 | ContentVersion uint64 `gorm:"not null;default:0"` |
| 62 | } |
| 63 | |
| 64 | // Row and Col carry stable identity. Position is a property of the record, |
| 65 | // never part of a key — so inserting a row above a comment or a range does |
| 66 | // not move what they point at. See spec section 4.2 for what this does and |
| 67 | // does not buy: it removes reference rewriting for insert and delete only. |
| 68 | // |
| 69 | // (TabID, Position) is a uniqueIndex, not a plain one: at most one Row (or |
| 70 | // Col) may ever claim a given grid coordinate within a tab. Nothing wrote |
| 71 | // Row/Col rows until store.Handle.EnsureRow/EnsureCol (XLSX import), which |
| 72 | // is why this constraint arrives with them rather than with the schema |
| 73 | // itself — see that file's doc comment for the race a plain index would |
| 74 | // leave open across two processes. |
| 75 | type Row struct { |
| 76 | ID uint64 `gorm:"primaryKey"` |
| 77 | TabID uint64 `gorm:"uniqueIndex:idx_row_tab_pos"` |
| 78 | Position int `gorm:"uniqueIndex:idx_row_tab_pos"` |
| 79 | Height int |
| 80 | } |
| 81 | |
| 82 | type Col struct { |
| 83 | ID uint64 `gorm:"primaryKey"` |
| 84 | TabID uint64 `gorm:"uniqueIndex:idx_col_tab_pos"` |
| 85 | Position int `gorm:"uniqueIndex:idx_col_tab_pos"` |
| 86 | Width int |
| 87 | } |
| 88 | |
| 89 | // Cell is sparse: only non-empty cells exist. |
| 90 | // |
| 91 | // CacheVersion records the InputVersion its CachedSealed was derived from. |
| 92 | // A cache whose CacheVersion trails its input is stale and must not be |
| 93 | // served — which is how a crash mid-recalc is detected rather than assumed |
| 94 | // away. See spec section 4.4. |
| 95 | type Cell struct { |
| 96 | TabID uint64 `gorm:"primaryKey"` |
| 97 | RowID uint64 `gorm:"primaryKey"` |
| 98 | ColID uint64 `gorm:"primaryKey"` |
| 99 | InputSealed []byte // seal purpose "cell.input" |
| 100 | CachedSealed []byte // seal purpose "cell.cached" |
| 101 | InputVersion uint64 |
| 102 | CacheVersion uint64 |
| 103 | FormatID *uint64 |
| 104 | UpdatedBy uint64 |
| 105 | UpdatedAt time.Time |
| 106 | } |
| 107 | |
| 108 | // Format is interned. A column of 5,000 identically styled cells is one row |
| 109 | // here, not 5,000 — which is the difference between a format change being |
| 110 | // cheap and being a table rewrite. |
| 111 | type Format struct { |
| 112 | ID uint64 `gorm:"primaryKey"` |
| 113 | Digest string `gorm:"uniqueIndex;size:64"` |
| 114 | NumberFmt string |
| 115 | Bold bool |
| 116 | Italic bool |
| 117 | FillIntentH *float64 // OKLCH hue; nil means no fill |
| 118 | FillIntentC *float64 // OKLCH chroma |
| 119 | FillHex string // retained from XLSX import for lossless re-export |
| 120 | // InkHex is the AUTHOR'S font colour, retained from XLSX import the |
| 121 | // same way FillHex is, and for the same reason: re-exporting a file |
| 122 | // with a colour we invented would lie about it. |
| 123 | // |
| 124 | // It exists because spec §11.1's obligation needs a pair. A fill |
| 125 | // alone cannot fail a contrast check — a fill plus the ink somebody |
| 126 | // chose to put on it can, and Excel ships such a pair as a preset |
| 127 | // (#9C6500 on #FFEB9C, 4.12:1). Empty means the file said nothing, |
| 128 | // and the theme's own ink is used, which is a pairing this app |
| 129 | // controls and cannot get wrong. |
| 130 | InkHex string |
| 131 | // InkIntentH/InkIntentC are ink's hue and chroma with no lightness, |
| 132 | // the same shape as FillIntentH/C and for the same reason: a colour |
| 133 | // THIS app applied should follow the reader's light/dark choice, |
| 134 | // where a colour an imported file authored must not. So a text |
| 135 | // colour picked in the toolbar is stored as intent and resolved |
| 136 | // per-theme, and InkHex stays what it has always been — the |
| 137 | // author's literal, retained for lossless re-export. |
| 138 | // |
| 139 | // Nil means "no ink of ours"; the theme's own text colour is used, |
| 140 | // which is a pairing this app controls and cannot get wrong. |
| 141 | InkIntentH *float64 |
| 142 | InkIntentC *float64 |
| 143 | Align string |
| 144 | } |
| 145 | |
| 146 | // Event is the append-only log: live-sync fan-out, version history, undo |
| 147 | // and audit. Its behaviour (AppendInput, AppendRecalc, the Kind consts) |
| 148 | // lives in package store, which is where it conceptually belongs — store |
| 149 | // owns persistence. The struct lives here instead, aliased back as |
| 150 | // store.Event, purely because of what migrations.go needs: `rastrillo |
| 151 | // migration check` diffs this package's Models against Schema, and |
| 152 | // package sheets cannot import store (store already imports sheets for |
| 153 | // Sheet/Tab/Row/Col/Cell/Format, so the reverse import would cycle). A |
| 154 | // model the check can't see is a model it can never catch drifting, which |
| 155 | // defeats the point of this file. See migrations.go and store/events.go. |
| 156 | type Event struct { |
| 157 | ID uint64 `gorm:"primaryKey"` |
| 158 | Kind string `gorm:"index;size:16"` |
| 159 | TabID uint64 `gorm:"index"` |
| 160 | ActorID uint64 |
| 161 | PayloadSealed []byte // seal purpose "event.payload"; empty for recalc |
| 162 | TriggerID uint64 // recalc only: the input event that caused it |
| 163 | DerivedCount int // recalc only: how many cells the pass touched |
| 164 | CreatedAt time.Time |
| 165 | } |
| 166 | |