oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

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.
16package store
17
18import "time"
19
20// Sheet is one spreadsheet document.
21type 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.
54type 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.
75type 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
82type 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.
95type 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.
111type 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.
156type 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