| 1 | # Sheets Grid and Live Sync Implementation Plan |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use |
| 4 | > superpowers:subagent-driven-development (recommended) or |
| 5 | > superpowers:executing-plans to implement this plan task-by-task. Steps use |
| 6 | > checkbox (`- [ ]`) syntax for tracking. |
| 7 | |
| 8 | **Goal:** A spreadsheet you can open in a browser, type into, and watch |
| 9 | update in another browser tab within a second — on a deployed canary, not |
| 10 | localhost. |
| 11 | |
| 12 | **Architecture:** The engine from plan 1 stays exactly where it is. This |
| 13 | plan adds the three layers above it: a **view layer** (embedded Go |
| 14 | templates over rastrillo's tokens and partials, theme resolved server-side |
| 15 | before first paint), a **grid surface** (vendored Jspreadsheet CE painting |
| 16 | strings the server computed, never formulas the client evaluated), and a |
| 17 | **live layer** (an in-process SSE hub fanning each committed edit out to |
| 18 | every other open tab, carrying the cell version so a client can discard an |
| 19 | out-of-order paint). Every write goes through one server path — |
| 20 | `store.Handle.SetReport` — so the scriptless form POST and the grid's |
| 21 | fetch produce identical events and identical fan-out. |
| 22 | |
| 23 | **Tech Stack:** Go 1.26, `github.com/carlosframework/rastrillo` v0.22.0 |
| 24 | (`ui` for tokens/layouts/partials, `auth` for sessions, `csrf`), Jspreadsheet |
| 25 | CE v5.0.2 + jsuites v5 vendored as static assets (MIT, verified in the |
| 26 | package's own LICENSE), `html/template`. No bundler, no CDN, no build step. |
| 27 | |
| 28 | **Spec:** `docs/superpowers/specs/2026-08-30-sheets-design.md` — §6 (the |
| 29 | grid, and where zero-JS stops), §7 (collaboration), §10 (design system), |
| 30 | §11.1 (a retained fill can be unreadable), §14 (the deferred table this plan |
| 31 | triggers one row of). |
| 32 | |
| 33 | --- |
| 34 | |
| 35 | ## Global Constraints |
| 36 | |
| 37 | Copied from `AGENTS.md` and the spec. Every task's requirements include |
| 38 | these. |
| 39 | |
| 40 | - **The one rule:** *"A sheet's contents are sealed at rest. The bucket never |
| 41 | holds a plaintext cell."* Nothing this plan adds writes a cell value |
| 42 | anywhere but through `seal.Sealer`. Titles and tab names are sealed too. |
| 43 | - **No cell content, comment text, sheet title or tab name in any log line, |
| 44 | error string, job status message or metric label.** This plan adds an SSE |
| 45 | layer whose natural debug logging would violate this. Log tab ids and |
| 46 | counts, never payloads. |
| 47 | - **`CGO_ENABLED=0` throughout.** |
| 48 | - **Migrations are additive-only.** This plan adds one table (`members`); |
| 49 | it alters nothing. |
| 50 | - **Never `scope.Owned` or `scope.OwnedBy`.** Sheets are team-shared. Until |
| 51 | §8's grants land, access is instance-wide: every address in |
| 52 | `SHEETS_OWNERS` sees every sheet. That is a *stated* scope, written in |
| 53 | the code and tested (Task 3), not an oversight — see "Assumptions" below. |
| 54 | - **Cell values are IEEE doubles end to end.** |
| 55 | - **200,000 cells per tab, hard.** The grid paints a window, never the tab. |
| 56 | - **300-line cap per file, .go and .js alike.** The vendored Jspreadsheet |
| 57 | and jsuites blobs are the only exemptions and are named as such in |
| 58 | `lineCapExempt` / `jsLineCapExempt`. |
| 59 | - **Write the attribute grammar** (`rst-btn="primary compact"`), never the |
| 60 | class spelling, and **adopt per screen**. Utilities stay classes. |
| 61 | - **Light/dark is per person and must resolve before first paint.** Read it |
| 62 | synchronously from a cookie, server-side. Never a fetch. |
| 63 | - **Every drag-only affordance has a menu equivalent.** |
| 64 | - **No form in the app is script-required.** |
| 65 | - **Review on a deployed canary, never localhost.** `carlos deploy`, never |
| 66 | `carlos ship`. `make release`, never a bare `go build`. Verify with |
| 67 | `GET /api/version` — the process, not the edge — and poll, because a |
| 68 | promote is not a deploy. |
| 69 | |
| 70 | ## Assumptions, stated because they are mine and not Paul's |
| 71 | |
| 72 | Written down so they are cheap to overturn rather than discovered later. |
| 73 | |
| 74 | 1. **"Live updates across tabs" means browser tabs.** Two windows on the |
| 75 | same sheet; type in one, see it in the other. It also happens to work |
| 76 | across spreadsheet tabs because the fan-out key is the tab id. |
| 77 | 2. **Sharing is not in this plan.** The instance allowlist is the team. |
| 78 | Spec §13 sequences sharing (6) before live sync (7), but sharing is a |
| 79 | subsystem — grants, roles, link sharing, the home contract — and none of |
| 80 | it is needed to make a grid live. Doing it here would have swapped a |
| 81 | working spreadsheet for a half-finished sharing model. |
| 82 | 3. **Presence is not in this plan.** §7 pairs presence with LWW; who's-here |
| 83 | is a second, independent SSE message type and adds nothing to |
| 84 | correctness. Deferred with a trigger: *the first time two people report |
| 85 | colliding on a cell.* |
| 86 | 4. **Undo is not in this plan.** §7's undo is own-edits-only over the event |
| 87 | log, and the event log already records what it needs. Deferred with a |
| 88 | trigger: *the grid is in daily use by anyone.* |
| 89 | 5. **The grid paints a fixed window** — 100 rows × 26 columns, extendable by |
| 90 | an explicit button — rather than virtualising. §6's cap reasoning is |
| 91 | about not rendering 200,000 cells; a window is the smallest thing that |
| 92 | honours it. |
| 93 | |
| 94 | --- |
| 95 | |
| 96 | ## File Structure |
| 97 | |
| 98 | ``` |
| 99 | internal/sheets/ |
| 100 | members.go Address -> actor id. The only place a Member row is made. |
| 101 | render.go Template parsing, PageData, theme-before-first-paint. |
| 102 | theme.go The theme cookie: read, write, and the POST handler. |
| 103 | sheetsvc.go Create/list/open a Sheet and its Tab. Sealed titles. |
| 104 | handlers_sheets.go GET /sheets, POST /sheets, GET /sheets/{id}. |
| 105 | handlers_grid.go The grid JSON API: GET cells, POST cell. |
| 106 | handlers_table.go The scriptless table view and its per-cell edit form. |
| 107 | handlers_stream.go GET .../stream — the SSE endpoint. Transport only. |
| 108 | keys.go SHEETS_DEK -> *seal.Sealer, refusing at boot. |
| 109 | templates/ |
| 110 | layout.html The one layout. rastrillo `column`, attribute grammar. |
| 111 | index.html Root: signed-in state and a way to the sheet list. |
| 112 | signin.html Moved off page.go's string constants. |
| 113 | sheets.html The sheet list, plus "new sheet". Scriptless. |
| 114 | sheet.html The grid page. <noscript> points at the table view. |
| 115 | table.html The scriptless, paginated table with edit forms. |
| 116 | static/ |
| 117 | tokens.css Copied by `rastrillo doctor`. Frozen deliberately. |
| 118 | sheets.css Our own grid chrome. Tokens only, no raw colours. |
| 119 | grid.js Our grid module. <= 300 lines, enforced. |
| 120 | live.js SSE client: subscribe, apply-by-version, heartbeat. |
| 121 | vendor/ |
| 122 | jspreadsheet.js Vendored v5.0.2. Exempt from the cap, named as such. |
| 123 | jspreadsheet.css |
| 124 | jsuites.js |
| 125 | jsuites.css |
| 126 | live/ |
| 127 | hub.go The fan-out hub. No HTTP, no templates, no store. |
| 128 | hub_test.go |
| 129 | store/ |
| 130 | handle_report.go SetReport: Set, but it tells you what changed. |
| 131 | ``` |
| 132 | |
| 133 | Rationale for the splits: the HTTP layer is split by surface rather than by |
| 134 | verb, because the scriptless table view and the JSON grid API are the two |
| 135 | things most likely to drift apart, and keeping them in separate files with a |
| 136 | shared service (`sheetsvc.go`) makes the drift visible. `live/` has no |
| 137 | dependency on `store` or `net/http` at all — it moves opaque messages — so |
| 138 | its tests are pure and fast, and the fan-out logic can be reasoned about |
| 139 | without a database. |
| 140 | |
| 141 | --- |
| 142 | |
| 143 | ### Task 1: The DEK, and refusing to boot without it |
| 144 | |
| 145 | Nothing in the app builds a `*seal.Sealer` yet — plan 1's tests each made |
| 146 | their own. A grid cannot store a cell without one. Spec §2.1: *"The DEK |
| 147 | arrives as platform config (`carlos secrets`), never stored in the |
| 148 | database."* |
| 149 | |
| 150 | **Files:** |
| 151 | - Create: `internal/sheets/keys.go`, `internal/sheets/keys_test.go` |
| 152 | - Modify: `cmd/sheets/main.go`, `internal/sheets/app.go` |
| 153 | |
| 154 | **Interfaces:** |
| 155 | - Consumes: `seal.New(dek []byte) (*seal.Sealer, error)`. |
| 156 | - Produces: `sheets.LoadSealer() (*seal.Sealer, error)`, and `App` gains a |
| 157 | `sealer *seal.Sealer` field set by `New`. |
| 158 | |
| 159 | - [ ] **Step 1: Write the failing test** |
| 160 | |
| 161 | Create `internal/sheets/keys_test.go`: |
| 162 | |
| 163 | ```go |
| 164 | package sheets |
| 165 | |
| 166 | import ( |
| 167 | "encoding/base64" |
| 168 | "strings" |
| 169 | "testing" |
| 170 | ) |
| 171 | |
| 172 | func TestLoadSealerRefusesAMissingDEK(t *testing.T) { |
| 173 | t.Setenv("SHEETS_DEK", "") |
| 174 | if _, err := LoadSealer(); err == nil { |
| 175 | t.Fatal("LoadSealer with no SHEETS_DEK = nil error, want a refusal") |
| 176 | } else if !strings.Contains(err.Error(), "SHEETS_DEK") { |
| 177 | t.Fatalf("error %q does not name the variable an operator must set", err) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func TestLoadSealerRefusesAShortDEK(t *testing.T) { |
| 182 | t.Setenv("SHEETS_DEK", base64.StdEncoding.EncodeToString(make([]byte, 16))) |
| 183 | if _, err := LoadSealer(); err == nil { |
| 184 | t.Fatal("LoadSealer with a 16-byte DEK = nil error, want a refusal") |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func TestLoadSealerAcceptsA32ByteDEK(t *testing.T) { |
| 189 | t.Setenv("SHEETS_DEK", base64.StdEncoding.EncodeToString(make([]byte, 32))) |
| 190 | s, err := LoadSealer() |
| 191 | if err != nil { |
| 192 | t.Fatalf("LoadSealer: %v", err) |
| 193 | } |
| 194 | if s == nil { |
| 195 | t.Fatal("LoadSealer returned a nil sealer and a nil error") |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // The DEK is a live key. A refusal that echoes it puts it in platform-held |
| 200 | // log storage we cannot grep (AGENTS.md / Mechanical rules). |
| 201 | func TestLoadSealerNeverEchoesTheKey(t *testing.T) { |
| 202 | const secret = "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LTEyMw==" |
| 203 | t.Setenv("SHEETS_DEK", secret) |
| 204 | _, err := LoadSealer() |
| 205 | if err != nil && strings.Contains(err.Error(), secret) { |
| 206 | t.Fatalf("error text contains the DEK itself: %q", err) |
| 207 | } |
| 208 | } |
| 209 | ``` |
| 210 | |
| 211 | - [ ] **Step 2: Run it and watch it fail** |
| 212 | |
| 213 | Run: `go test ./internal/sheets/ -run TestLoadSealer -v` |
| 214 | Expected: FAIL — `undefined: LoadSealer`. |
| 215 | |
| 216 | - [ ] **Step 3: Write the implementation** |
| 217 | |
| 218 | Create `internal/sheets/keys.go`: |
| 219 | |
| 220 | ```go |
| 221 | package sheets |
| 222 | |
| 223 | import ( |
| 224 | "encoding/base64" |
| 225 | "errors" |
| 226 | "fmt" |
| 227 | "os" |
| 228 | "strings" |
| 229 | |
| 230 | "amadan.net/oficina/sheets/internal/sheets/seal" |
| 231 | ) |
| 232 | |
| 233 | // dekBytes is the DEK's required length. seal.New enforces it too; this |
| 234 | // check exists so the refusal names SHEETS_DEK and the length an |
| 235 | // operator has to produce, rather than surfacing a framework message |
| 236 | // about a byte slice. |
| 237 | const dekBytes = 32 |
| 238 | |
| 239 | // LoadSealer reads the data encryption key from platform config and |
| 240 | // builds the app's one Sealer. |
| 241 | // |
| 242 | // The DEK arrives as platform config (`carlos secrets`), never stored in |
| 243 | // the database (spec 2.1). A missing one is a hard refusal rather than a |
| 244 | // generated key: an instance that invents a DEK at boot seals this run's |
| 245 | // cells with a key the next run will not have, which reads as data loss |
| 246 | // and cannot be undone. |
| 247 | // |
| 248 | // No error here ever includes the value read from the environment. It is |
| 249 | // a live key and `carlos logs` is platform-held storage we cannot grep. |
| 250 | func LoadSealer() (*seal.Sealer, error) { |
| 251 | raw := strings.TrimSpace(os.Getenv("SHEETS_DEK")) |
| 252 | if raw == "" { |
| 253 | return nil, errors.New( |
| 254 | "sealing is not configured: SHEETS_DEK not set. " + |
| 255 | "Generate one with `head -c 32 /dev/urandom | base64` and set it " + |
| 256 | "with `carlos secrets set --app sheets SHEETS_DEK=...`") |
| 257 | } |
| 258 | dek, err := base64.StdEncoding.DecodeString(raw) |
| 259 | if err != nil { |
| 260 | return nil, errors.New("sealing is not configured: SHEETS_DEK is not valid base64") |
| 261 | } |
| 262 | if len(dek) != dekBytes { |
| 263 | return nil, fmt.Errorf( |
| 264 | "sealing is not configured: SHEETS_DEK decodes to %d bytes, want %d", |
| 265 | len(dek), dekBytes) |
| 266 | } |
| 267 | s, err := seal.New(dek) |
| 268 | if err != nil { |
| 269 | return nil, fmt.Errorf("sealing is not configured: %w", err) |
| 270 | } |
| 271 | return s, nil |
| 272 | } |
| 273 | ``` |
| 274 | |
| 275 | - [ ] **Step 4: Thread it into App and main** |
| 276 | |
| 277 | In `internal/sheets/app.go`, add the field and parameter: |
| 278 | |
| 279 | ```go |
| 280 | type App struct { |
| 281 | db *gorm.DB |
| 282 | auth *auth.Auth |
| 283 | sealer *seal.Sealer |
| 284 | admit func(string) bool |
| 285 | } |
| 286 | |
| 287 | // New builds an App. db and sealer may be nil ONLY in tests that do not |
| 288 | // touch storage; every route that reads or writes a cell checks and 500s |
| 289 | // rather than panicking, which TestGridWithoutASealerIs500 pins. |
| 290 | func New(db *gorm.DB, a *auth.Auth, sealer *seal.Sealer, admit func(string) bool) *App { |
| 291 | return &App{db: db, auth: a, sealer: sealer, admit: admit} |
| 292 | } |
| 293 | ``` |
| 294 | |
| 295 | Update every existing `New(...)` call site — `app_test.go`, `page_test.go`, |
| 296 | `auth_test.go`, `begin_test.go`, `signin_test.go` — to pass `nil` for the |
| 297 | new parameter. |
| 298 | |
| 299 | In `cmd/sheets/main.go`, after `LoadAuthEnv` and before `sheets.New`: |
| 300 | |
| 301 | ```go |
| 302 | // Same shape as the sign-in refusals above and for the same reason: |
| 303 | // an instance with no DEK answers /healthz perfectly well and then |
| 304 | // fails on the first cell anybody types. |
| 305 | sealer, err := sheets.LoadSealer() |
| 306 | if err != nil { |
| 307 | logger.Error("sealing", "err", err) |
| 308 | os.Exit(1) |
| 309 | } |
| 310 | |
| 311 | app := sheets.New(d.G, signin, sealer, sheets.Admits(authEnv.Owners)) |
| 312 | ``` |
| 313 | |
| 314 | - [ ] **Step 5: Run the tests** |
| 315 | |
| 316 | Run: `make test-vet test-build test-go` |
| 317 | Expected: PASS. |
| 318 | |
| 319 | - [ ] **Step 6: Commit** |
| 320 | |
| 321 | ```bash |
| 322 | git add internal/sheets/keys.go internal/sheets/keys_test.go \ |
| 323 | internal/sheets/app.go cmd/sheets/main.go internal/sheets/*_test.go |
| 324 | git commit -m "feat: the DEK arrives as platform config, or the instance refuses to boot" |
| 325 | ``` |
| 326 | |
| 327 | --- |
| 328 | |
| 329 | ### Task 2: Members — an address becomes an actor id |
| 330 | |
| 331 | `Cell.UpdatedBy`, `Sheet.CreatedBy` and `Event.ActorID` are `uint64`. The |
| 332 | session carries an email address. Something has to map one to the other, and |
| 333 | `sessions.UserID` cannot (AGENTS.md: it returns `(0, false)` for an email |
| 334 | Subject). Spec §2.2 puts membership and readable addresses in the plaintext |
| 335 | column, so this table is not sealed. |
| 336 | |
| 337 | **Files:** |
| 338 | - Modify: `internal/sheets/models.go`, `internal/sheets/migrations.go` |
| 339 | - Create: `internal/sheets/migrations/0002_create_members.sql` |
| 340 | - Create: `internal/sheets/members.go`, `internal/sheets/members_test.go` |
| 341 | |
| 342 | **Interfaces:** |
| 343 | - Consumes: `App.db`, `App.identity(r)` from `page.go`. |
| 344 | - Produces: `type Member struct{ ID uint64; Address string; CreatedAt time.Time }`, |
| 345 | and `func (a *App) actor(r *http.Request) (Member, bool)`. |
| 346 | |
| 347 | - [ ] **Step 1: Write the failing test** |
| 348 | |
| 349 | Create `internal/sheets/members_test.go`: |
| 350 | |
| 351 | ```go |
| 352 | package sheets |
| 353 | |
| 354 | import "testing" |
| 355 | |
| 356 | func TestMemberForIsIdempotent(t *testing.T) { |
| 357 | app := newTestApp(t) // helper from app_test.go; see Step 3 |
| 358 | a, err := app.memberFor("Ada@example.com") |
| 359 | if err != nil { |
| 360 | t.Fatalf("memberFor: %v", err) |
| 361 | } |
| 362 | if a.ID == 0 { |
| 363 | t.Fatal("memberFor returned id 0, which is the uid-0 trap AGENTS.md names") |
| 364 | } |
| 365 | b, err := app.memberFor("ada@example.com") |
| 366 | if err != nil { |
| 367 | t.Fatalf("memberFor again: %v", err) |
| 368 | } |
| 369 | if b.ID != a.ID { |
| 370 | t.Fatalf("memberFor is case-sensitive: %d then %d", a.ID, b.ID) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | func TestMemberForRejectsAnEmptyAddress(t *testing.T) { |
| 375 | app := newTestApp(t) |
| 376 | if _, err := app.memberFor(""); err == nil { |
| 377 | t.Fatal("memberFor(\"\") = nil error; an empty address must never mint a member") |
| 378 | } |
| 379 | } |
| 380 | ``` |
| 381 | |
| 382 | - [ ] **Step 2: Run it and watch it fail** |
| 383 | |
| 384 | Run: `go test ./internal/sheets/ -run TestMemberFor -v` |
| 385 | Expected: FAIL — `undefined: newTestApp`, `undefined: memberFor`. |
| 386 | |
| 387 | - [ ] **Step 3: Add the model, the migration and the helper** |
| 388 | |
| 389 | In `internal/sheets/models.go`: |
| 390 | |
| 391 | ```go |
| 392 | // Member is one person who may hold a session on this instance. |
| 393 | // |
| 394 | // Address is stored in plaintext, deliberately: spec 2.2 lists |
| 395 | // "membership, grants, and roles — including readable email addresses" |
| 396 | // among the things this design does NOT seal, and publishes that fact in |
| 397 | // the README rather than leaving it to be discovered. Sealing it would |
| 398 | // also make the uniqueIndex below impossible, since a random nonce per |
| 399 | // seal means the same address seals to a different ciphertext every |
| 400 | // time. |
| 401 | // |
| 402 | // The row exists so a cell edit can carry a stable uint64 actor id. |
| 403 | // Addresses are lowercased before they reach here (memberFor), matching |
| 404 | // the admission allowlist's own comparison. |
| 405 | type Member struct { |
| 406 | ID uint64 `gorm:"primaryKey"` |
| 407 | Address string `gorm:"uniqueIndex;size:320"` |
| 408 | CreatedAt time.Time |
| 409 | } |
| 410 | ``` |
| 411 | |
| 412 | Add `&Member{}` to `Models` in `internal/sheets/migrations.go`. |
| 413 | |
| 414 | Create `internal/sheets/migrations/0002_create_members.sql`: |
| 415 | |
| 416 | ```sql |
| 417 | CREATE TABLE `members` ( |
| 418 | `id` integer PRIMARY KEY AUTOINCREMENT, |
| 419 | `address` text, |
| 420 | `created_at` datetime |
| 421 | ); |
| 422 | CREATE UNIQUE INDEX `idx_members_address` ON `members`(`address`); |
| 423 | ``` |
| 424 | |
| 425 | Then run `rastrillo migration check` and reconcile the SQL with whatever it |
| 426 | reports until it is silent — the committed SQL must be exactly what the |
| 427 | generator would produce, or `make test-migrations` fails in CI. |
| 428 | |
| 429 | Create `internal/sheets/members.go`: |
| 430 | |
| 431 | ```go |
| 432 | package sheets |
| 433 | |
| 434 | import ( |
| 435 | "errors" |
| 436 | "net/http" |
| 437 | "strings" |
| 438 | |
| 439 | "gorm.io/gorm" |
| 440 | "gorm.io/gorm/clause" |
| 441 | ) |
| 442 | |
| 443 | // memberFor returns the Member row for an address, creating it on first |
| 444 | // sight. |
| 445 | // |
| 446 | // First sight is the right moment because admission has already |
| 447 | // happened: auth.Config.Authorize refused every address not on the |
| 448 | // allowlist before a session existed, so anything reaching here is |
| 449 | // someone who is allowed to be here. Creating rows at boot from |
| 450 | // SHEETS_OWNERS instead would mint members for people who never signed |
| 451 | // in and would go stale the moment the allowlist changed. |
| 452 | // |
| 453 | // The upsert is DO NOTHING plus a read, not a read-then-create: two tabs |
| 454 | // signing in at once would otherwise race and one would get a unique |
| 455 | // violation. |
| 456 | func (a *App) memberFor(address string) (Member, error) { |
| 457 | addr := strings.ToLower(strings.TrimSpace(address)) |
| 458 | if addr == "" { |
| 459 | return Member{}, errors.New("sheets: refusing to mint a member for an empty address") |
| 460 | } |
| 461 | if a.db == nil { |
| 462 | return Member{}, errors.New("sheets: no database") |
| 463 | } |
| 464 | m := Member{Address: addr} |
| 465 | if err := a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&m).Error; err != nil { |
| 466 | return Member{}, err |
| 467 | } |
| 468 | if m.ID != 0 { |
| 469 | return m, nil |
| 470 | } |
| 471 | // The insert conflicted: the row is already there, so read it. |
| 472 | var existing Member |
| 473 | if err := a.db.Where("address = ?", addr).First(&existing).Error; err != nil { |
| 474 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 475 | return Member{}, errors.New("sheets: member neither created nor found") |
| 476 | } |
| 477 | return Member{}, err |
| 478 | } |
| 479 | return existing, nil |
| 480 | } |
| 481 | |
| 482 | // actor resolves the signed-in viewer to a Member. No session is not an |
| 483 | // error — it is "no actor" — so callers gate on ok rather than on err, |
| 484 | // and a route that requires one has already been through |
| 485 | // auth.RequireSession. |
| 486 | func (a *App) actor(r *http.Request) (Member, bool) { |
| 487 | address, ok := a.identity(r) |
| 488 | if !ok { |
| 489 | return Member{}, false |
| 490 | } |
| 491 | m, err := a.memberFor(address) |
| 492 | if err != nil { |
| 493 | return Member{}, false |
| 494 | } |
| 495 | return m, true |
| 496 | } |
| 497 | ``` |
| 498 | |
| 499 | - [ ] **Step 4: Add the test helper** |
| 500 | |
| 501 | In `internal/sheets/app_test.go`, add a helper the later tasks reuse. It |
| 502 | opens a real SQLite file under `t.TempDir()` through rastrillo's `db` |
| 503 | package (never `gorm.io/driver/sqlite` — the forbidden-imports guard bans |
| 504 | it) and applies `BootSchema`: |
| 505 | |
| 506 | ```go |
| 507 | // newTestApp builds an App over a real, migrated, throwaway database and |
| 508 | // a deterministic all-zero DEK. Nil auth: these tests exercise storage |
| 509 | // and rendering, not sign-in. |
| 510 | func newTestApp(t *testing.T) *App { |
| 511 | t.Helper() |
| 512 | d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), slog.Default()) |
| 513 | if err != nil { |
| 514 | t.Fatalf("db.Open: %v", err) |
| 515 | } |
| 516 | t.Cleanup(func() { d.Close() }) |
| 517 | if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| 518 | t.Fatalf("migrate: %v", err) |
| 519 | } |
| 520 | s, err := seal.New(make([]byte, 32)) |
| 521 | if err != nil { |
| 522 | t.Fatalf("seal.New: %v", err) |
| 523 | } |
| 524 | return New(d.G, nil, s, func(string) bool { return true }) |
| 525 | } |
| 526 | ``` |
| 527 | |
| 528 | - [ ] **Step 5: Run the tests** |
| 529 | |
| 530 | Run: `go test ./internal/sheets/ -run TestMember -v && make test-migrations` |
| 531 | Expected: PASS, and `rastrillo migration check` silent. |
| 532 | |
| 533 | - [ ] **Step 6: Commit** |
| 534 | |
| 535 | ```bash |
| 536 | git add internal/sheets/members.go internal/sheets/members_test.go \ |
| 537 | internal/sheets/models.go internal/sheets/migrations.go \ |
| 538 | internal/sheets/migrations/0002_create_members.sql \ |
| 539 | internal/sheets/app_test.go |
| 540 | git commit -m "feat: an address becomes a stable actor id" |
| 541 | ``` |
| 542 | |
| 543 | --- |
| 544 | |
| 545 | ### Task 3: Sheets and tabs — create, list, open |
| 546 | |
| 547 | **Files:** |
| 548 | - Create: `internal/sheets/sheetsvc.go`, `internal/sheets/sheetsvc_test.go` |
| 549 | |
| 550 | **Interfaces:** |
| 551 | - Consumes: `App.db`, `App.sealer`, `Member` from Task 2. |
| 552 | - Produces: |
| 553 | - `type SheetSummary struct{ ID uint64; Title string; TabID uint64; UpdatedAt time.Time }` |
| 554 | - `func (a *App) createSheet(title string, by Member) (SheetSummary, error)` |
| 555 | - `func (a *App) listSheets() ([]SheetSummary, error)` |
| 556 | - `func (a *App) sheetSummary(id uint64) (SheetSummary, error)` |
| 557 | |
| 558 | - [ ] **Step 1: Write the failing test** |
| 559 | |
| 560 | Create `internal/sheets/sheetsvc_test.go`: |
| 561 | |
| 562 | ```go |
| 563 | package sheets |
| 564 | |
| 565 | import "testing" |
| 566 | |
| 567 | func TestCreateSheetMakesAFirstTab(t *testing.T) { |
| 568 | app := newTestApp(t) |
| 569 | m, err := app.memberFor("ada@example.com") |
| 570 | if err != nil { |
| 571 | t.Fatalf("memberFor: %v", err) |
| 572 | } |
| 573 | s, err := app.createSheet("Budget", m) |
| 574 | if err != nil { |
| 575 | t.Fatalf("createSheet: %v", err) |
| 576 | } |
| 577 | if s.TabID == 0 { |
| 578 | t.Fatal("createSheet made a sheet with no tab; there is nothing to open") |
| 579 | } |
| 580 | if s.Title != "Budget" { |
| 581 | t.Fatalf("title = %q, want %q", s.Title, "Budget") |
| 582 | } |
| 583 | } |
| 584 | |
| 585 | // The one rule. A title is sheet content. |
| 586 | func TestSheetTitleIsSealedAtRest(t *testing.T) { |
| 587 | app := newTestApp(t) |
| 588 | m, _ := app.memberFor("ada@example.com") |
| 589 | if _, err := app.createSheet("Salaries 2027", m); err != nil { |
| 590 | t.Fatalf("createSheet: %v", err) |
| 591 | } |
| 592 | var raw []byte |
| 593 | if err := app.db.Raw("SELECT title_sealed FROM sheets LIMIT 1").Scan(&raw).Error; err != nil { |
| 594 | t.Fatalf("read back: %v", err) |
| 595 | } |
| 596 | if bytes.Contains(raw, []byte("Salaries")) { |
| 597 | t.Fatal("the sheet title reached the database in plaintext") |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | func TestListSheetsIsInstanceWideNotPerCreator(t *testing.T) { |
| 602 | app := newTestApp(t) |
| 603 | ada, _ := app.memberFor("ada@example.com") |
| 604 | bob, _ := app.memberFor("bob@example.com") |
| 605 | if _, err := app.createSheet("Ada's", ada); err != nil { |
| 606 | t.Fatalf("createSheet: %v", err) |
| 607 | } |
| 608 | if _, err := app.createSheet("Bob's", bob); err != nil { |
| 609 | t.Fatalf("createSheet: %v", err) |
| 610 | } |
| 611 | got, err := app.listSheets() |
| 612 | if err != nil { |
| 613 | t.Fatalf("listSheets: %v", err) |
| 614 | } |
| 615 | // This is the scope.Owned trap AGENTS.md names: the wrong |
| 616 | // implementation returns one row and looks like an empty-ish list |
| 617 | // rather than an error. |
| 618 | if len(got) != 2 { |
| 619 | t.Fatalf("listSheets returned %d sheets, want 2 — sheets are team-shared", len(got)) |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | func TestCreateSheetRefusesAnEmptyTitle(t *testing.T) { |
| 624 | app := newTestApp(t) |
| 625 | m, _ := app.memberFor("ada@example.com") |
| 626 | if _, err := app.createSheet(" ", m); err == nil { |
| 627 | t.Fatal("createSheet with a blank title = nil error, want a refusal") |
| 628 | } |
| 629 | } |
| 630 | ``` |
| 631 | |
| 632 | - [ ] **Step 2: Run it and watch it fail** |
| 633 | |
| 634 | Run: `go test ./internal/sheets/ -run 'TestCreateSheet|TestListSheets|TestSheetTitle' -v` |
| 635 | Expected: FAIL — `undefined: createSheet`. |
| 636 | |
| 637 | - [ ] **Step 3: Write the implementation** |
| 638 | |
| 639 | Create `internal/sheets/sheetsvc.go`: |
| 640 | |
| 641 | ```go |
| 642 | package sheets |
| 643 | |
| 644 | import ( |
| 645 | "errors" |
| 646 | "strings" |
| 647 | "time" |
| 648 | |
| 649 | "gorm.io/gorm" |
| 650 | ) |
| 651 | |
| 652 | // maxTitleRunes caps a sheet title. It is a UI-shaped limit, not a |
| 653 | // storage one — the sealed column takes anything — and exists so a |
| 654 | // pasted paragraph cannot become a sheet name that breaks every list |
| 655 | // row. |
| 656 | const maxTitleRunes = 200 |
| 657 | |
| 658 | // SheetSummary is one row of the sheet list: everything a list or a page |
| 659 | // header needs, with the title already unsealed. |
| 660 | // |
| 661 | // TabID is the sheet's FIRST tab, which is the one a bare /sheets/{id} |
| 662 | // opens. Multi-tab navigation is a later slice; carrying the id here |
| 663 | // means the grid page never has to query for it. |
| 664 | type SheetSummary struct { |
| 665 | ID uint64 |
| 666 | Title string |
| 667 | TabID uint64 |
| 668 | UpdatedAt time.Time |
| 669 | } |
| 670 | |
| 671 | // errNoSealer is returned rather than panicking when a nil-sealer App |
| 672 | // (tests that render pages only) reaches a path that needs one. |
| 673 | var errNoSealer = errors.New("sheets: no sealer configured") |
| 674 | |
| 675 | // createSheet makes a sheet and its first tab in one transaction. |
| 676 | // |
| 677 | // One transaction because a sheet with no tab is not openable: it would |
| 678 | // list, and then 404 when clicked. Nothing outside this function ever |
| 679 | // creates a Sheet row, which is what makes "every sheet has at least one |
| 680 | // tab" a real invariant rather than a convention. |
| 681 | func (a *App) createSheet(title string, by Member) (SheetSummary, error) { |
| 682 | if a.db == nil || a.sealer == nil { |
| 683 | return SheetSummary{}, errNoSealer |
| 684 | } |
| 685 | clean := strings.TrimSpace(title) |
| 686 | if clean == "" { |
| 687 | return SheetSummary{}, errors.New("sheets: a sheet needs a title") |
| 688 | } |
| 689 | if len([]rune(clean)) > maxTitleRunes { |
| 690 | clean = string([]rune(clean)[:maxTitleRunes]) |
| 691 | } |
| 692 | sealedTitle, err := a.sealer.Seal("sheet.title", []byte(clean)) |
| 693 | if err != nil { |
| 694 | return SheetSummary{}, err |
| 695 | } |
| 696 | sealedName, err := a.sealer.Seal("tab.name", []byte("Sheet 1")) |
| 697 | if err != nil { |
| 698 | return SheetSummary{}, err |
| 699 | } |
| 700 | |
| 701 | sheet := Sheet{TitleSealed: sealedTitle, CreatedBy: by.ID} |
| 702 | tab := Tab{NameSealed: sealedName, Position: 0} |
| 703 | if err := a.db.Transaction(func(tx *gorm.DB) error { |
| 704 | if err := tx.Create(&sheet).Error; err != nil { |
| 705 | return err |
| 706 | } |
| 707 | tab.SheetID = sheet.ID |
| 708 | return tx.Create(&tab).Error |
| 709 | }); err != nil { |
| 710 | return SheetSummary{}, err |
| 711 | } |
| 712 | return SheetSummary{ID: sheet.ID, Title: clean, TabID: tab.ID, UpdatedAt: sheet.UpdatedAt}, nil |
| 713 | } |
| 714 | |
| 715 | // listSheets returns every live sheet on the instance, newest edit |
| 716 | // first. |
| 717 | // |
| 718 | // Deliberately unscoped by creator. Sheets are team-shared, and |
| 719 | // scope.Owned here would return only the caller's own rows — failing as |
| 720 | // a plausible short list rather than as an error (AGENTS.md / |
| 721 | // Mechanical rules; TestListSheetsIsInstanceWideNotPerCreator). When |
| 722 | // spec 8's grants land, this grows a hand-written grants join; it does |
| 723 | // NOT grow a scope.Owned. |
| 724 | func (a *App) listSheets() ([]SheetSummary, error) { |
| 725 | if a.db == nil || a.sealer == nil { |
| 726 | return nil, errNoSealer |
| 727 | } |
| 728 | var rows []Sheet |
| 729 | if err := a.db.Where("deleted_at IS NULL").Order("updated_at DESC").Find(&rows).Error; err != nil { |
| 730 | return nil, err |
| 731 | } |
| 732 | out := make([]SheetSummary, 0, len(rows)) |
| 733 | for _, s := range rows { |
| 734 | sum, err := a.summarize(s) |
| 735 | if err != nil { |
| 736 | return nil, err |
| 737 | } |
| 738 | out = append(out, sum) |
| 739 | } |
| 740 | return out, nil |
| 741 | } |
| 742 | |
| 743 | // sheetSummary reads one sheet. A missing sheet is gorm.ErrRecordNotFound, |
| 744 | // which handlers turn into a 404 — never a 403, per AGENTS.md. |
| 745 | func (a *App) sheetSummary(id uint64) (SheetSummary, error) { |
| 746 | if a.db == nil || a.sealer == nil { |
| 747 | return SheetSummary{}, errNoSealer |
| 748 | } |
| 749 | var s Sheet |
| 750 | if err := a.db.Where("id = ? AND deleted_at IS NULL", id).First(&s).Error; err != nil { |
| 751 | return SheetSummary{}, err |
| 752 | } |
| 753 | return a.summarize(s) |
| 754 | } |
| 755 | |
| 756 | // summarize unseals a Sheet row's title and finds its first tab. |
| 757 | func (a *App) summarize(s Sheet) (SheetSummary, error) { |
| 758 | title, err := a.sealer.Open("sheet.title", s.TitleSealed) |
| 759 | if err != nil { |
| 760 | return SheetSummary{}, err |
| 761 | } |
| 762 | var tab Tab |
| 763 | if err := a.db.Where("sheet_id = ?", s.ID).Order("position ASC").First(&tab).Error; err != nil { |
| 764 | return SheetSummary{}, err |
| 765 | } |
| 766 | return SheetSummary{ID: s.ID, Title: string(title), TabID: tab.ID, UpdatedAt: s.UpdatedAt}, nil |
| 767 | } |
| 768 | ``` |
| 769 | |
| 770 | - [ ] **Step 4: Run the tests** |
| 771 | |
| 772 | Run: `go test ./internal/sheets/ -run 'TestCreateSheet|TestListSheets|TestSheetTitle' -v` |
| 773 | Expected: PASS. |
| 774 | |
| 775 | - [ ] **Step 5: Commit** |
| 776 | |
| 777 | ```bash |
| 778 | git add internal/sheets/sheetsvc.go internal/sheets/sheetsvc_test.go |
| 779 | git commit -m "feat: create, list and read a sheet, with the title sealed" |
| 780 | ``` |
| 781 | |
| 782 | --- |
| 783 | |
| 784 | ### Task 4: `SetReport` — a write that says what it changed |
| 785 | |
| 786 | Live sync needs the touched set. `Handle.Set` returns only an error, and |
| 787 | seventeen tests plus `xlsx/import.go` call it. So `Set` keeps its signature |
| 788 | and becomes a wrapper; the reporting form is new. |
| 789 | |
| 790 | **Files:** |
| 791 | - Create: `internal/sheets/store/handle_report.go`, |
| 792 | `internal/sheets/store/handle_report_test.go` |
| 793 | - Modify: `internal/sheets/store/handle_set.go` (Set becomes a wrapper) |
| 794 | |
| 795 | **Interfaces:** |
| 796 | - Consumes: `Handle.Set`'s internals — `setBlank`, `setContent`, `recalcTouched`. |
| 797 | - Produces: |
| 798 | |
| 799 | ```go |
| 800 | type ChangedCell struct { |
| 801 | RowID, ColID uint64 |
| 802 | Raw string |
| 803 | Value calc.Value |
| 804 | Derived bool |
| 805 | } |
| 806 | type Report struct { |
| 807 | Version uint64 |
| 808 | Cells []ChangedCell |
| 809 | Capped bool |
| 810 | } |
| 811 | func (h *Handle) SetReport(rowID, colID uint64, raw string, actor uint64) (Report, error) |
| 812 | ``` |
| 813 | |
| 814 | - [ ] **Step 1: Write the failing test** |
| 815 | |
| 816 | Create `internal/sheets/store/handle_report_test.go`: |
| 817 | |
| 818 | ```go |
| 819 | package store |
| 820 | |
| 821 | import "testing" |
| 822 | |
| 823 | func TestSetReportNamesTheOriginAndItsDependents(t *testing.T) { |
| 824 | h := openTestHandle(t) // existing helper in handle_test.go |
| 825 | a1r, a1c := gridAt(t, h, 0, 0) |
| 826 | b1r, b1c := gridAt(t, h, 0, 1) |
| 827 | if err := h.Set(b1r, b1c, "=A1*2", 1); err != nil { |
| 828 | t.Fatalf("Set B1: %v", err) |
| 829 | } |
| 830 | rep, err := h.SetReport(a1r, a1c, "21", 1) |
| 831 | if err != nil { |
| 832 | t.Fatalf("SetReport: %v", err) |
| 833 | } |
| 834 | if len(rep.Cells) != 2 { |
| 835 | t.Fatalf("report covers %d cells, want 2 (A1 and its dependent B1)", len(rep.Cells)) |
| 836 | } |
| 837 | var sawOrigin, sawDerived bool |
| 838 | for _, c := range rep.Cells { |
| 839 | switch { |
| 840 | case c.RowID == a1r && c.ColID == a1c: |
| 841 | sawOrigin = true |
| 842 | if c.Derived { |
| 843 | t.Error("the edited cell is reported as derived") |
| 844 | } |
| 845 | if c.Raw != "21" { |
| 846 | t.Errorf("origin raw = %q, want %q", c.Raw, "21") |
| 847 | } |
| 848 | case c.RowID == b1r && c.ColID == b1c: |
| 849 | sawDerived = true |
| 850 | if !c.Derived { |
| 851 | t.Error("a recalculated dependent is not flagged derived") |
| 852 | } |
| 853 | if c.Value.Num != 42 { |
| 854 | t.Errorf("B1 = %v, want 42", c.Value.Num) |
| 855 | } |
| 856 | } |
| 857 | } |
| 858 | if !sawOrigin || !sawDerived { |
| 859 | t.Fatalf("origin seen=%v, dependent seen=%v", sawOrigin, sawDerived) |
| 860 | } |
| 861 | } |
| 862 | |
| 863 | func TestSetReportVersionAdvances(t *testing.T) { |
| 864 | h := openTestHandle(t) |
| 865 | r, c := gridAt(t, h, 0, 0) |
| 866 | first, err := h.SetReport(r, c, "1", 1) |
| 867 | if err != nil { |
| 868 | t.Fatalf("SetReport: %v", err) |
| 869 | } |
| 870 | second, err := h.SetReport(r, c, "2", 1) |
| 871 | if err != nil { |
| 872 | t.Fatalf("SetReport: %v", err) |
| 873 | } |
| 874 | if second.Version <= first.Version { |
| 875 | t.Fatalf("version did not advance: %d then %d", first.Version, second.Version) |
| 876 | } |
| 877 | } |
| 878 | |
| 879 | func TestSetReportOnBlankReportsTheClearedCell(t *testing.T) { |
| 880 | h := openTestHandle(t) |
| 881 | r, c := gridAt(t, h, 0, 0) |
| 882 | if err := h.Set(r, c, "7", 1); err != nil { |
| 883 | t.Fatalf("Set: %v", err) |
| 884 | } |
| 885 | rep, err := h.SetReport(r, c, "", 1) |
| 886 | if err != nil { |
| 887 | t.Fatalf("SetReport blank: %v", err) |
| 888 | } |
| 889 | if len(rep.Cells) == 0 { |
| 890 | t.Fatal("blanking a cell reported no change; the other tab would never clear it") |
| 891 | } |
| 892 | if rep.Cells[0].Raw != "" || rep.Cells[0].Value.Kind != calc.KindEmpty { |
| 893 | t.Fatalf("cleared cell reported as %+v, want empty", rep.Cells[0]) |
| 894 | } |
| 895 | } |
| 896 | ``` |
| 897 | |
| 898 | `openTestHandle` and `gridAt` may need adding to `handle_test.go` as thin |
| 899 | wrappers over whatever the existing helpers there are named — read that file |
| 900 | first and reuse rather than duplicate. |
| 901 | |
| 902 | - [ ] **Step 2: Run it and watch it fail** |
| 903 | |
| 904 | Run: `go test ./internal/sheets/store/ -run TestSetReport -v` |
| 905 | Expected: FAIL — `h.SetReport undefined`. |
| 906 | |
| 907 | - [ ] **Step 3: Refactor Set into a reporting core** |
| 908 | |
| 909 | In `internal/sheets/store/handle_set.go`, rename the existing `Set` body's |
| 910 | entry point to `setReportLocked` and have both public forms call it. The two |
| 911 | inner methods gain a returned touched set: |
| 912 | |
| 913 | ```go |
| 914 | // Set writes a cell's new raw input and recalculates forward from it. |
| 915 | // It is SetReport with the report discarded — kept because every caller |
| 916 | // that does not fan out over SSE (XLSX import, most tests) wants exactly |
| 917 | // this and nothing more. |
| 918 | func (h *Handle) Set(rowID, colID uint64, raw string, actor uint64) error { |
| 919 | _, err := h.SetReport(rowID, colID, raw, actor) |
| 920 | return err |
| 921 | } |
| 922 | ``` |
| 923 | |
| 924 | Create `internal/sheets/store/handle_report.go` with `SetReport`, holding |
| 925 | `h.mu` for the whole call exactly as `Set` did, delegating to `setBlank` / |
| 926 | `setContent` (both changed to return `map[calc.CellKey]struct{}` alongside |
| 927 | their error), and then building the `Report` from the graph's current cached |
| 928 | values plus `h.rawText`: |
| 929 | |
| 930 | ```go |
| 931 | // SetReport is Set, plus an account of what it changed: the cell that |
| 932 | // was edited and every cell the recalculation touched, each with the |
| 933 | // value it now holds and the version that value carries. |
| 934 | // |
| 935 | // The version is the single number a client uses to discard an |
| 936 | // out-of-order paint (spec 7). Every cell in one report shares it, |
| 937 | // because they were all written by one Set and one recalculation pass — |
| 938 | // a per-cell version would be the same number repeated. |
| 939 | // |
| 940 | // Capped is true when the edit's dependent set was wider than |
| 941 | // calc.MaxDirtyPerPass, so no recalculation ran and the report covers |
| 942 | // the origin alone. A client seeing Capped reloads the window rather |
| 943 | // than painting a partial truth. This is the honest error the cap |
| 944 | // exists to produce, surfaced rather than swallowed. |
| 945 | func (h *Handle) SetReport(rowID, colID uint64, raw string, actor uint64) (Report, error) { |
| 946 | // ... implementation as described above |
| 947 | } |
| 948 | ``` |
| 949 | |
| 950 | Derived is `key != origin`. `Raw` is `h.rawText[key]` (empty for a cleared |
| 951 | or derived-formula-output cell is fine — the client repaints from `Value`, |
| 952 | and only uses `Raw` to refill the editor for the cell you are on). |
| 953 | |
| 954 | - [ ] **Step 4: Run the whole store suite, including the race gate** |
| 955 | |
| 956 | Run: `go test -count=1 ./internal/sheets/store/... && make test-race` |
| 957 | Expected: PASS. The refactor touches the locking path, so `-race` matters |
| 958 | here more than anywhere else in this plan. |
| 959 | |
| 960 | - [ ] **Step 5: Commit** |
| 961 | |
| 962 | ```bash |
| 963 | git add internal/sheets/store/handle_report.go \ |
| 964 | internal/sheets/store/handle_report_test.go \ |
| 965 | internal/sheets/store/handle_set.go |
| 966 | git commit -m "store: a write reports what it changed, with the version to order it by" |
| 967 | ``` |
| 968 | |
| 969 | --- |
| 970 | |
| 971 | ### Task 5: The fan-out hub |
| 972 | |
| 973 | Pure fan-out. No HTTP, no store, no templates — so it can be tested with |
| 974 | goroutines and channels and nothing else, and `-race` over it means |
| 975 | something. |
| 976 | |
| 977 | **Files:** |
| 978 | - Create: `internal/sheets/live/hub.go`, `internal/sheets/live/hub_test.go` |
| 979 | |
| 980 | **Interfaces:** |
| 981 | - Consumes: nothing. |
| 982 | - Produces: |
| 983 | |
| 984 | ```go |
| 985 | type Message struct { |
| 986 | Version uint64 |
| 987 | Data []byte // an already-marshalled JSON payload; the hub never looks inside |
| 988 | } |
| 989 | type Sub struct{ /* ... */ } |
| 990 | func NewHub() *Hub |
| 991 | func (h *Hub) Subscribe(tabID uint64) *Sub |
| 992 | func (h *Hub) Publish(tabID uint64, m Message) |
| 993 | func (h *Hub) Subscribers(tabID uint64) int |
| 994 | func (s *Sub) C() <-chan Message |
| 995 | func (s *Sub) Lost() bool |
| 996 | func (s *Sub) Close() |
| 997 | ``` |
| 998 | |
| 999 | - [ ] **Step 1: Write the failing test** |
| 1000 | |
| 1001 | Create `internal/sheets/live/hub_test.go`: |
| 1002 | |
| 1003 | ```go |
| 1004 | package live |
| 1005 | |
| 1006 | import ( |
| 1007 | "testing" |
| 1008 | "time" |
| 1009 | ) |
| 1010 | |
| 1011 | func TestPublishReachesEverySubscriberOnTheTab(t *testing.T) { |
| 1012 | h := NewHub() |
| 1013 | a := h.Subscribe(7) |
| 1014 | b := h.Subscribe(7) |
| 1015 | other := h.Subscribe(8) |
| 1016 | defer a.Close() |
| 1017 | defer b.Close() |
| 1018 | defer other.Close() |
| 1019 | |
| 1020 | h.Publish(7, Message{Version: 3, Data: []byte(`{"v":3}`)}) |
| 1021 | |
| 1022 | for i, s := range []*Sub{a, b} { |
| 1023 | select { |
| 1024 | case m := <-s.C(): |
| 1025 | if m.Version != 3 { |
| 1026 | t.Errorf("subscriber %d got version %d, want 3", i, m.Version) |
| 1027 | } |
| 1028 | case <-time.After(time.Second): |
| 1029 | t.Fatalf("subscriber %d received nothing", i) |
| 1030 | } |
| 1031 | } |
| 1032 | select { |
| 1033 | case m := <-other.C(): |
| 1034 | t.Fatalf("a subscriber on tab 8 received tab 7's message: %+v", m) |
| 1035 | case <-time.After(50 * time.Millisecond): |
| 1036 | } |
| 1037 | } |
| 1038 | |
| 1039 | func TestCloseUnsubscribes(t *testing.T) { |
| 1040 | h := NewHub() |
| 1041 | s := h.Subscribe(7) |
| 1042 | s.Close() |
| 1043 | if got := h.Subscribers(7); got != 0 { |
| 1044 | t.Fatalf("after Close, Subscribers(7) = %d, want 0", got) |
| 1045 | } |
| 1046 | // Close is idempotent: an SSE handler defers it and may also hit it |
| 1047 | // on a write error, and a double close of the channel would panic |
| 1048 | // in a goroutine no request owns. |
| 1049 | s.Close() |
| 1050 | } |
| 1051 | |
| 1052 | // A tab that has stopped reading must never block the writer. The |
| 1053 | // alternative — an unbounded buffer — turns one wedged browser into an |
| 1054 | // instance-wide memory leak. |
| 1055 | func TestASlowSubscriberIsDroppedNotBlocking(t *testing.T) { |
| 1056 | h := NewHub() |
| 1057 | s := h.Subscribe(7) |
| 1058 | defer s.Close() |
| 1059 | for i := 0; i < subBuffer+10; i++ { |
| 1060 | done := make(chan struct{}) |
| 1061 | go func() { h.Publish(7, Message{Version: uint64(i)}); close(done) }() |
| 1062 | select { |
| 1063 | case <-done: |
| 1064 | case <-time.After(time.Second): |
| 1065 | t.Fatalf("Publish blocked on message %d", i) |
| 1066 | } |
| 1067 | } |
| 1068 | if !s.Lost() { |
| 1069 | t.Fatal("an overrun subscriber is not marked lost; it would paint a sheet with holes in it") |
| 1070 | } |
| 1071 | } |
| 1072 | ``` |
| 1073 | |
| 1074 | - [ ] **Step 2: Run it and watch it fail** |
| 1075 | |
| 1076 | Run: `go test ./internal/sheets/live/ -v` |
| 1077 | Expected: FAIL — the package does not exist. |
| 1078 | |
| 1079 | - [ ] **Step 3: Write the implementation** |
| 1080 | |
| 1081 | Create `internal/sheets/live/hub.go`. The shape: |
| 1082 | |
| 1083 | - `subBuffer = 64` — a named constant with the reasoning beside it: deep |
| 1084 | enough that a normal tab under a burst of edits never overruns, shallow |
| 1085 | enough that a thousand wedged tabs cost kilobytes, not megabytes. |
| 1086 | - `Hub` holds `mu sync.Mutex` and `tabs map[uint64]map[*Sub]struct{}`. |
| 1087 | - `Publish` does a non-blocking send per subscriber; a full channel sets |
| 1088 | `lost` (via `atomic.Bool`) and does NOT close the channel — the reader |
| 1089 | discovers it through `Lost()` and asks the client to resync. |
| 1090 | - `Close` removes the sub under the hub's lock, guarded by `sync.Once` so |
| 1091 | it is idempotent, and deletes the tab's map entry when it empties, so an |
| 1092 | instance that has served a thousand tabs does not keep a thousand empty |
| 1093 | maps. |
| 1094 | |
| 1095 | The hub never inspects `Data`. That is what keeps cell content out of this |
| 1096 | package entirely, and with it out of any log line this package could ever |
| 1097 | grow. |
| 1098 | |
| 1099 | - [ ] **Step 4: Run the tests, with the race detector** |
| 1100 | |
| 1101 | Run: `go test -race -count=1 ./internal/sheets/live/ -v` |
| 1102 | Expected: PASS. |
| 1103 | |
| 1104 | - [ ] **Step 5: Add live to the race target** |
| 1105 | |
| 1106 | In `Makefile`, extend `test-race`: |
| 1107 | |
| 1108 | ```make |
| 1109 | test-race: |
| 1110 | go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/... |
| 1111 | ``` |
| 1112 | |
| 1113 | and update the target's comment to say live is here for the same reason |
| 1114 | store is: it is where the goroutines are. |
| 1115 | |
| 1116 | - [ ] **Step 6: Commit** |
| 1117 | |
| 1118 | ```bash |
| 1119 | git add internal/sheets/live/ Makefile |
| 1120 | git commit -m "live: an in-process fan-out hub that never blocks on a wedged tab" |
| 1121 | ``` |
| 1122 | |
| 1123 | --- |
| 1124 | |
| 1125 | ### Task 6: The view layer — templates, tokens, theme before first paint |
| 1126 | |
| 1127 | **Files:** |
| 1128 | - Create: `internal/sheets/render.go`, `internal/sheets/theme.go`, |
| 1129 | `internal/sheets/theme_test.go`, `internal/sheets/render_test.go` |
| 1130 | - Create: `internal/sheets/templates/layout.html`, `index.html`, `signin.html` |
| 1131 | - Create: `internal/sheets/static/tokens.css`, `internal/sheets/static/sheets.css` |
| 1132 | - Modify: `internal/sheets/page.go` (delete the string constants), `app.go` |
| 1133 | |
| 1134 | **Interfaces:** |
| 1135 | - Consumes: `rastrillo/ui` (`Templates()`, `TokensCSS()`, `Layout()`). |
| 1136 | - Produces: |
| 1137 | - `type PageData struct{ Title, Theme, Address, Path string; SignedIn bool; CSRF string; Data any }` |
| 1138 | - `func (a *App) render(w http.ResponseWriter, r *http.Request, page string, status int, data any)` |
| 1139 | - `func themeFrom(r *http.Request) string` |
| 1140 | - `GET /static/*` serving the embedded assets with a long cache lifetime. |
| 1141 | |
| 1142 | - [ ] **Step 1: Scaffold the tokens with rastrillo doctor** |
| 1143 | |
| 1144 | ```bash |
| 1145 | rastrillo doctor |
| 1146 | rastrillo doctor --fix |
| 1147 | ``` |
| 1148 | |
| 1149 | Expected: it copies `ui/tokens.css` (and the partials it styles) into |
| 1150 | `internal/sheets/static/`. AGENTS.md requires this on every rastrillo |
| 1151 | upgrade; this is the first adoption, so it is the first run. Record its |
| 1152 | output in the branch discussion. |
| 1153 | |
| 1154 | - [ ] **Step 2: Write the failing theme test** |
| 1155 | |
| 1156 | Create `internal/sheets/theme_test.go`: |
| 1157 | |
| 1158 | ```go |
| 1159 | package sheets |
| 1160 | |
| 1161 | import ( |
| 1162 | "net/http" |
| 1163 | "net/http/httptest" |
| 1164 | "testing" |
| 1165 | ) |
| 1166 | |
| 1167 | func TestThemeDefaultsToDayWithNoCookie(t *testing.T) { |
| 1168 | r := httptest.NewRequest(http.MethodGet, "/", nil) |
| 1169 | if got := themeFrom(r); got != "day" { |
| 1170 | t.Fatalf("themeFrom with no cookie = %q, want %q", got, "day") |
| 1171 | } |
| 1172 | } |
| 1173 | |
| 1174 | func TestThemeReadsTheCookie(t *testing.T) { |
| 1175 | r := httptest.NewRequest(http.MethodGet, "/", nil) |
| 1176 | r.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"}) |
| 1177 | if got := themeFrom(r); got != "signal" { |
| 1178 | t.Fatalf("themeFrom = %q, want %q", got, "signal") |
| 1179 | } |
| 1180 | } |
| 1181 | |
| 1182 | // An unknown value must not reach the page. It is interpolated into a |
| 1183 | // stylesheet href and a data attribute; "day" is the safe floor. |
| 1184 | func TestThemeRejectsAnUnknownValue(t *testing.T) { |
| 1185 | r := httptest.NewRequest(http.MethodGet, "/", nil) |
| 1186 | r.AddCookie(&http.Cookie{Name: themeCookie, Value: "../../etc/passwd"}) |
| 1187 | if got := themeFrom(r); got != "day" { |
| 1188 | t.Fatalf("themeFrom with a junk cookie = %q, want the %q floor", got, "day") |
| 1189 | } |
| 1190 | } |
| 1191 | ``` |
| 1192 | |
| 1193 | And a render test that pins the reason the cookie is read server-side: |
| 1194 | |
| 1195 | ```go |
| 1196 | // AGENTS.md: light/dark must resolve before first paint, because cell |
| 1197 | // fills resolve against the theme and a late preference repaints every |
| 1198 | // coloured cell. A page that ships the theme in its first byte cannot |
| 1199 | // flash; one that fetches it can. This test fails the moment somebody |
| 1200 | // moves the decision into JS. |
| 1201 | func TestThemeIsInTheFirstResponseNotFetched(t *testing.T) { |
| 1202 | app := newTestApp(t) |
| 1203 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 1204 | req.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"}) |
| 1205 | rec := httptest.NewRecorder() |
| 1206 | app.Router().ServeHTTP(rec, req) |
| 1207 | if !strings.Contains(rec.Body.String(), `data-theme="signal"`) { |
| 1208 | t.Fatal("the rendered page does not carry the theme; it would flash on load") |
| 1209 | } |
| 1210 | } |
| 1211 | ``` |
| 1212 | |
| 1213 | - [ ] **Step 3: Run them and watch them fail** |
| 1214 | |
| 1215 | Run: `go test ./internal/sheets/ -run TestTheme -v` |
| 1216 | Expected: FAIL — `undefined: themeFrom`. |
| 1217 | |
| 1218 | - [ ] **Step 4: Write theme.go** |
| 1219 | |
| 1220 | ```go |
| 1221 | package sheets |
| 1222 | |
| 1223 | import "net/http" |
| 1224 | |
| 1225 | // themeCookie is the per-person theme. Per person, not per document |
| 1226 | // (spec 9.3, AGENTS.md) — so it is a cookie on this origin and not a |
| 1227 | // column on Sheet. |
| 1228 | const themeCookie = "sheets_theme" |
| 1229 | |
| 1230 | // defaultTheme is the floor. rastrillo ships day, plain and signal; |
| 1231 | // anything else — a stale cookie, a hand-edited one, an attempt at path |
| 1232 | // traversal through the stylesheet href — resolves to this rather than |
| 1233 | // reaching a template. |
| 1234 | const defaultTheme = "day" |
| 1235 | |
| 1236 | func themeFrom(r *http.Request) string { |
| 1237 | c, err := r.Cookie(themeCookie) |
| 1238 | if err != nil { |
| 1239 | return defaultTheme |
| 1240 | } |
| 1241 | for _, name := range ui.ThemeNames() { |
| 1242 | if c.Value == name { |
| 1243 | return c.Value |
| 1244 | } |
| 1245 | } |
| 1246 | return defaultTheme |
| 1247 | } |
| 1248 | ``` |
| 1249 | |
| 1250 | Plus `handleTheme` (POST `/theme`), which validates against |
| 1251 | `ui.ThemeNames()`, sets the cookie `HttpOnly=false, SameSite=Lax, |
| 1252 | Secure` (on an https origin), `Max-Age` one year, and redirects back to the |
| 1253 | `return` form field — validated as a same-origin *path*, never an absolute |
| 1254 | URL, or the theme switcher becomes an open redirect. |
| 1255 | |
| 1256 | - [ ] **Step 5: Write render.go and the templates** |
| 1257 | |
| 1258 | `render.go` embeds `templates/*.html` and `static/*`, parses one |
| 1259 | `*template.Template` per page over rastrillo's layout plus partials (so two |
| 1260 | pages can both define `"content"`), and builds `PageData` with theme, |
| 1261 | identity and the CSRF token. It serves `/static/*` from the embedded FS. |
| 1262 | |
| 1263 | Templates use the **attribute grammar** throughout — |
| 1264 | `rst-btn="primary"`, `rst-callout="warn"`, `rst-field` — never the class |
| 1265 | spelling, and adopt it per screen (AGENTS.md). |
| 1266 | |
| 1267 | `layout.html` puts `data-theme="{{.Theme}}"` on `<html>` and links |
| 1268 | `/static/tokens.css` before anything else. |
| 1269 | |
| 1270 | - [ ] **Step 6: Move page.go onto templates** |
| 1271 | |
| 1272 | Delete `pageHead`/`pageWhat`/`pageSignedOut`/`pageFoot` from `page.go` and |
| 1273 | render `index.html` instead. `page_test.go` and `signin_test.go` assert on |
| 1274 | strings from those constants — update them to assert on the rendered page's |
| 1275 | affordances (a sign-in link when signed out, the address and a sign-out |
| 1276 | button when signed in), which is what they were really pinning. |
| 1277 | |
| 1278 | - [ ] **Step 7: Run the tests** |
| 1279 | |
| 1280 | Run: `make test-vet test-fmt test-build test-go` |
| 1281 | Expected: PASS. |
| 1282 | |
| 1283 | - [ ] **Step 8: Commit** |
| 1284 | |
| 1285 | ```bash |
| 1286 | git add internal/sheets/render.go internal/sheets/theme.go \ |
| 1287 | internal/sheets/templates internal/sheets/static \ |
| 1288 | internal/sheets/page.go internal/sheets/*_test.go |
| 1289 | git commit -m "feat: a view layer — rastrillo tokens, one layout, theme before first paint" |
| 1290 | ``` |
| 1291 | |
| 1292 | --- |
| 1293 | |
| 1294 | ### Task 7: The sheet list, scriptless |
| 1295 | |
| 1296 | **Files:** |
| 1297 | - Create: `internal/sheets/handlers_sheets.go`, |
| 1298 | `internal/sheets/handlers_sheets_test.go`, |
| 1299 | `internal/sheets/templates/sheets.html` |
| 1300 | - Modify: `internal/sheets/app.go` (routes, `auth.RequireSession`, CSRF) |
| 1301 | |
| 1302 | **Interfaces:** |
| 1303 | - Consumes: `createSheet`, `listSheets`, `sheetSummary`, `actor`, `render`. |
| 1304 | - Produces: `GET /sheets`, `POST /sheets`, and `App.requireSession` — a |
| 1305 | middleware that is `a.auth.RequireSession` in production and a pass-through |
| 1306 | when `a.auth` is nil, so page tests keep working without a mailer. |
| 1307 | |
| 1308 | - [ ] **Step 1: Write the failing test** |
| 1309 | |
| 1310 | ```go |
| 1311 | func TestPostSheetsCreatesAndRedirects(t *testing.T) { |
| 1312 | app := newTestApp(t) |
| 1313 | form := url.Values{"title": {"Q3 forecast"}} |
| 1314 | req := httptest.NewRequest(http.MethodPost, "/sheets", strings.NewReader(form.Encode())) |
| 1315 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 1316 | rec := httptest.NewRecorder() |
| 1317 | app.Router().ServeHTTP(rec, req) |
| 1318 | |
| 1319 | if rec.Code != http.StatusSeeOther { |
| 1320 | t.Fatalf("POST /sheets = %d, want 303 — a POST that renders is a POST that resubmits on reload", rec.Code) |
| 1321 | } |
| 1322 | loc := rec.Header().Get("Location") |
| 1323 | if !strings.HasPrefix(loc, "/sheets/") { |
| 1324 | t.Fatalf("Location = %q, want the new sheet", loc) |
| 1325 | } |
| 1326 | } |
| 1327 | |
| 1328 | func TestSheetListShowsEveryTitle(t *testing.T) { /* create two, GET /sheets, assert both titles appear */ } |
| 1329 | |
| 1330 | func TestUnknownSheetIs404NotForbidden(t *testing.T) { |
| 1331 | app := newTestApp(t) |
| 1332 | req := httptest.NewRequest(http.MethodGet, "/sheets/999999", nil) |
| 1333 | rec := httptest.NewRecorder() |
| 1334 | app.Router().ServeHTTP(rec, req) |
| 1335 | if rec.Code != http.StatusNotFound { |
| 1336 | t.Fatalf("GET a sheet that is not there = %d, want 404 (AGENTS.md: 404, never 403)", rec.Code) |
| 1337 | } |
| 1338 | } |
| 1339 | ``` |
| 1340 | |
| 1341 | - [ ] **Step 2: Run and watch it fail.** Expected: 404 on `/sheets`. |
| 1342 | |
| 1343 | - [ ] **Step 3: Write the handlers and the template.** `POST` reads the |
| 1344 | title with `r.FormValue` into an explicit struct — never a bind onto a |
| 1345 | GORM model (AGENTS.md) — resolves the actor, creates, and 303s to |
| 1346 | `/sheets/{id}`. |
| 1347 | |
| 1348 | - [ ] **Step 4: Mount the routes behind a session** |
| 1349 | |
| 1350 | In `app.go`, inside `r.Group(func(r chi.Router) { r.Use(a.requireSession) })`. |
| 1351 | Add `r.Use(csrf.Protect(origin))` at the router root; `App` gains the origin |
| 1352 | string (`New` grows a parameter, or it reads it from the `auth.Auth` — |
| 1353 | prefer passing it explicitly so a nil-auth test App still has one). |
| 1354 | Existing `POST /signin` tests will need the CSRF headers — update them. |
| 1355 | |
| 1356 | - [ ] **Step 5: Run `make test`.** Expected: PASS. |
| 1357 | |
| 1358 | - [ ] **Step 6: Commit** |
| 1359 | |
| 1360 | ```bash |
| 1361 | git commit -am "feat: create and list sheets, no script required" |
| 1362 | ``` |
| 1363 | |
| 1364 | --- |
| 1365 | |
| 1366 | ### Task 8: The grid JSON API |
| 1367 | |
| 1368 | **Files:** |
| 1369 | - Create: `internal/sheets/handlers_grid.go`, |
| 1370 | `internal/sheets/handlers_grid_test.go` |
| 1371 | - Modify: `internal/sheets/app.go` |
| 1372 | |
| 1373 | **Interfaces:** |
| 1374 | - Consumes: `store.Open`, `store.Handle.SetReport`, `format.Display`, |
| 1375 | `App.actor`, `App.hub` (Task 5's hub, held on `App`). |
| 1376 | - Produces: |
| 1377 | - `GET /api/sheets/{sheetID}/tabs/{tabID}/cells?rows=100&cols=26` → |
| 1378 | `{"version":N,"rows":100,"cols":26,"cells":[{"r":0,"c":0,"raw":"=A1*2","display":"42","v":7}]}` |
| 1379 | - `POST /api/sheets/{sheetID}/tabs/{tabID}/cells` with |
| 1380 | `{"r":0,"c":0,"raw":"=A1*2"}` → the same `cells` shape for everything |
| 1381 | the write changed, and a fan-out of that payload on the hub. |
| 1382 | |
| 1383 | - [ ] **Step 1: Write the failing test** |
| 1384 | |
| 1385 | ```go |
| 1386 | func TestPostACellThenGetItBack(t *testing.T) { |
| 1387 | app := newTestApp(t) |
| 1388 | m, _ := app.memberFor("ada@example.com") |
| 1389 | sheet, _ := app.createSheet("Budget", m) |
| 1390 | |
| 1391 | body := `{"r":0,"c":0,"raw":"=1+1"}` |
| 1392 | post := httptest.NewRequest(http.MethodPost, |
| 1393 | fmt.Sprintf("/api/sheets/%d/tabs/%d/cells", sheet.ID, sheet.TabID), |
| 1394 | strings.NewReader(body)) |
| 1395 | post.Header.Set("Content-Type", "application/json") |
| 1396 | rec := httptest.NewRecorder() |
| 1397 | app.Router().ServeHTTP(rec, post) |
| 1398 | if rec.Code != http.StatusOK { |
| 1399 | t.Fatalf("POST cell = %d: %s", rec.Code, rec.Body.String()) |
| 1400 | } |
| 1401 | var wrote gridResponse |
| 1402 | json.Unmarshal(rec.Body.Bytes(), &wrote) |
| 1403 | if len(wrote.Cells) != 1 || wrote.Cells[0].Display != "2" { |
| 1404 | t.Fatalf("POST returned %+v, want one cell displaying 2", wrote.Cells) |
| 1405 | } |
| 1406 | |
| 1407 | get := httptest.NewRequest(http.MethodGet, /* same path */, nil) |
| 1408 | rec2 := httptest.NewRecorder() |
| 1409 | app.Router().ServeHTTP(rec2, get) |
| 1410 | var read gridResponse |
| 1411 | json.Unmarshal(rec2.Body.Bytes(), &read) |
| 1412 | // The client edits raw and paints display. Both must survive a reload. |
| 1413 | if read.Cells[0].Raw != "=1+1" || read.Cells[0].Display != "2" { |
| 1414 | t.Fatalf("after reload: %+v", read.Cells[0]) |
| 1415 | } |
| 1416 | } |
| 1417 | |
| 1418 | // Spec 6's cap reasoning, enforced at the boundary rather than trusted. |
| 1419 | func TestGridWindowIsCapped(t *testing.T) { /* rows=999999 must clamp, not allocate */ } |
| 1420 | |
| 1421 | // The one rule, at the last layer that could break it. |
| 1422 | func TestNoCellContentInAnErrorResponse(t *testing.T) { |
| 1423 | // POST an unparseable formula containing a distinctive string; |
| 1424 | // assert the 400 body does not contain it. |
| 1425 | } |
| 1426 | ``` |
| 1427 | |
| 1428 | - [ ] **Step 2: Run and watch it fail.** |
| 1429 | |
| 1430 | - [ ] **Step 3: Write the handlers.** Key points, each with the reasoning in |
| 1431 | a comment: |
| 1432 | - Open the handle once per request via `store.Open`, `defer h.Release()`. |
| 1433 | - Window: `rows` and `cols` clamp to `maxWindowRows = 200` / |
| 1434 | `maxWindowCols = 50`. A request for more is clamped, not refused — |
| 1435 | refusing turns a wide window into a broken page. |
| 1436 | - `display` comes from `format.Display(value, numFmt)` — the server owns |
| 1437 | every string the grid paints, which is the §12 gate that crosses from |
| 1438 | computed value to painted output. |
| 1439 | - The POST resolves row/col ids via `h.EnsureRow` / `h.EnsureCol`, so |
| 1440 | typing into an untouched cell works. |
| 1441 | - On success, marshal the same payload once and `hub.Publish` it, tagged |
| 1442 | with an `origin` client id the poster sends so its own echo is skipped. |
| 1443 | - Errors carry coordinates and codes, never the cell's text. |
| 1444 | |
| 1445 | - [ ] **Step 4: Run `make test`.** Expected: PASS. |
| 1446 | |
| 1447 | - [ ] **Step 5: Commit** |
| 1448 | |
| 1449 | ```bash |
| 1450 | git commit -am "feat: read a grid window and write a cell, with the server owning every painted string" |
| 1451 | ``` |
| 1452 | |
| 1453 | --- |
| 1454 | |
| 1455 | ### Task 9: SSE — the endpoint and the transport gate |
| 1456 | |
| 1457 | **Files:** |
| 1458 | - Create: `internal/sheets/handlers_stream.go`, |
| 1459 | `internal/sheets/handlers_stream_test.go` |
| 1460 | - Modify: `internal/sheets/app.go` |
| 1461 | |
| 1462 | **Interfaces:** |
| 1463 | - Consumes: `App.hub`. |
| 1464 | - Produces: `GET /api/sheets/{sheetID}/tabs/{tabID}/stream`. |
| 1465 | |
| 1466 | - [ ] **Step 1: Write the failing test** |
| 1467 | |
| 1468 | Spec §7: *"The SSE transport check is a streamed 200, not a 101."* |
| 1469 | |
| 1470 | ```go |
| 1471 | func TestStreamIsAStreamed200NotAnUpgrade(t *testing.T) { |
| 1472 | app := newTestApp(t) |
| 1473 | srv := httptest.NewServer(app.Router()) |
| 1474 | defer srv.Close() |
| 1475 | m, _ := app.memberFor("ada@example.com") |
| 1476 | sheet, _ := app.createSheet("Budget", m) |
| 1477 | |
| 1478 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 1479 | defer cancel() |
| 1480 | req, _ := http.NewRequestWithContext(ctx, http.MethodGet, |
| 1481 | fmt.Sprintf("%s/api/sheets/%d/tabs/%d/stream", srv.URL, sheet.ID, sheet.TabID), nil) |
| 1482 | resp, err := http.DefaultClient.Do(req) |
| 1483 | if err != nil { |
| 1484 | t.Fatalf("stream: %v", err) |
| 1485 | } |
| 1486 | defer resp.Body.Close() |
| 1487 | if resp.StatusCode != http.StatusOK { |
| 1488 | t.Fatalf("stream = %d, want a streamed 200 (SSE is text/event-stream, not a 101 upgrade)", resp.StatusCode) |
| 1489 | } |
| 1490 | if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { |
| 1491 | t.Fatalf("Content-Type = %q, want text/event-stream", ct) |
| 1492 | } |
| 1493 | |
| 1494 | // Unbuffered: the first event must arrive without the response |
| 1495 | // having to end. A handler that builds its body and returns passes |
| 1496 | // every status check above and is useless. |
| 1497 | go func() { |
| 1498 | time.Sleep(100 * time.Millisecond) |
| 1499 | post(t, srv.URL, sheet, `{"r":0,"c":0,"raw":"5"}`) |
| 1500 | }() |
| 1501 | line := readEventWithin(t, resp.Body, 3*time.Second) |
| 1502 | if !strings.Contains(line, `"display":"5"`) { |
| 1503 | t.Fatalf("first streamed event = %q", line) |
| 1504 | } |
| 1505 | } |
| 1506 | ``` |
| 1507 | |
| 1508 | - [ ] **Step 2: Run and watch it fail.** |
| 1509 | |
| 1510 | - [ ] **Step 3: Write the handler.** The shape: |
| 1511 | - Headers: `Content-Type: text/event-stream`, `Cache-Control: no-store`, |
| 1512 | `X-Accel-Buffering: no` (the edge may buffer otherwise, which is the |
| 1513 | failure the streamed-200 test is written to catch), then |
| 1514 | `http.NewResponseController(w).Flush()` immediately so the client's |
| 1515 | `onopen` fires before the first edit. |
| 1516 | - Subscribe, `defer sub.Close()`, and select over `sub.C()`, |
| 1517 | `r.Context().Done()`, a `time.Ticker` writing a `: ping` comment every |
| 1518 | `pingEvery = 25 * time.Second`, and an idle timer. |
| 1519 | - **Idle policy, written down** (spec §7): `idleTimeout = 15 * time.Minute` |
| 1520 | with no edits and no heartbeat closes the stream; `maxStreamLife = 4 * time.Hour` |
| 1521 | closes it regardless. A tab left open all weekend cannot hold an instance |
| 1522 | awake and billable. The client reconnects on its own — `EventSource` |
| 1523 | does that natively — so a close is a pause, not a failure. |
| 1524 | - `sub.Lost()` sends a single `event: resync` and returns, so an overrun |
| 1525 | client reloads its window instead of painting a sheet with holes. |
| 1526 | |
| 1527 | - [ ] **Step 4: Run `make test test-race`.** Expected: PASS. |
| 1528 | |
| 1529 | - [ ] **Step 5: Commit** |
| 1530 | |
| 1531 | ```bash |
| 1532 | git commit -am "feat: SSE fan-out, with an idle policy written down rather than emergent" |
| 1533 | ``` |
| 1534 | |
| 1535 | --- |
| 1536 | |
| 1537 | ### Task 10: The grid in the browser |
| 1538 | |
| 1539 | **Files:** |
| 1540 | - Create: `internal/sheets/static/vendor/jspreadsheet.{js,css}`, |
| 1541 | `internal/sheets/static/vendor/jsuites.{js,css}` |
| 1542 | - Create: `internal/sheets/static/grid.js`, `internal/sheets/static/live.js` |
| 1543 | - Create: `internal/sheets/templates/sheet.html` |
| 1544 | - Create: `internal/guardstest/jscap_test.go` |
| 1545 | |
| 1546 | - [ ] **Step 1: Vendor the assets** |
| 1547 | |
| 1548 | ```bash |
| 1549 | D=$(mktemp -d) |
| 1550 | curl -sSL -o $D/js.tgz https://registry.npmjs.org/jspreadsheet-ce/-/jspreadsheet-ce-5.0.2.tgz |
| 1551 | curl -sSL -o $D/su.tgz https://registry.npmjs.org/jsuites/-/jsuites-5.10.2.tgz |
| 1552 | tar xzf $D/js.tgz -C $D && tar xzf $D/su.tgz -C $D --one-top-level=su |
| 1553 | install -D $D/package/dist/index.js internal/sheets/static/vendor/jspreadsheet.js |
| 1554 | install -D $D/package/dist/jspreadsheet.css internal/sheets/static/vendor/jspreadsheet.css |
| 1555 | install -D $D/su/package/dist/jsuites.js internal/sheets/static/vendor/jsuites.js |
| 1556 | install -D $D/su/package/dist/jsuites.css internal/sheets/static/vendor/jsuites.css |
| 1557 | install -D $D/package/LICENSE internal/sheets/static/vendor/LICENSE.jspreadsheet |
| 1558 | install -D $D/su/package/LICENSE internal/sheets/static/vendor/LICENSE.jsuites |
| 1559 | ``` |
| 1560 | |
| 1561 | Both are MIT. Copy the licences in beside the code, and record the exact |
| 1562 | versions in `internal/sheets/static/vendor/README.md` along with the |
| 1563 | commands above, so the next upgrade is a re-run rather than an |
| 1564 | investigation. |
| 1565 | |
| 1566 | - [ ] **Step 2: Write the JS line-cap guard first** |
| 1567 | |
| 1568 | Create `internal/guardstest/jscap_test.go`, mirroring `linecap_test.go`: |
| 1569 | walk `internal/sheets/static/**/*.js`, cap at 300 lines, with an |
| 1570 | exemption map holding exactly the two vendored blobs — by exact path, |
| 1571 | never by directory, so a third file dropped into `vendor/` is not silently |
| 1572 | exempt. |
| 1573 | |
| 1574 | - [ ] **Step 3: Run it and watch it fail** (the vendored files exceed the cap |
| 1575 | until they are listed). Then add the two exemptions and watch it pass. |
| 1576 | |
| 1577 | - [ ] **Step 4: Write `grid.js`** |
| 1578 | |
| 1579 | Under 300 lines. Responsibilities, and nothing else: |
| 1580 | - Build the Jspreadsheet instance over the window the page was rendered with. |
| 1581 | - **`parseFormulas` off.** Our engine computes; the client paints. A |
| 1582 | client-side evaluator would disagree with the server on the first |
| 1583 | `SUM(1,2,"3")` — the LibreOffice/Excel divergence the corpus already |
| 1584 | documents — and the disagreement would be invisible. |
| 1585 | - Paint `display`; keep `raw` in a side map so selecting a cell puts the |
| 1586 | formula, not its result, in the editor. |
| 1587 | - On `onchange`, POST the raw text and apply the response. |
| 1588 | - Menu equivalents for every drag-only affordance (AGENTS.md): fill-down |
| 1589 | and fill-right live in the toolbar as buttons, not only on the drag |
| 1590 | handle. |
| 1591 | |
| 1592 | - [ ] **Step 5: Write `live.js`** |
| 1593 | |
| 1594 | Under 300 lines. `EventSource` on the stream; on message, **discard by |
| 1595 | version** — `if (msg.v <= lastAppliedVersionFor(cell)) return;` — which is |
| 1596 | the whole reason the version travels (spec §7). Skip our own echo by client |
| 1597 | id. Heartbeat POST every 30s, **stopped while `document.hidden`**, with a |
| 1598 | **ceiling** of 4 hours, matching the server's `maxStreamLife`. On |
| 1599 | `event: resync`, refetch the window. |
| 1600 | |
| 1601 | - [ ] **Step 6: Write `sheet.html`** |
| 1602 | |
| 1603 | The grid page. Its `<noscript>` block links to `/sheets/{id}/table`, so a |
| 1604 | scriptless visitor lands somewhere useful rather than on an empty div. |
| 1605 | |
| 1606 | - [ ] **Step 7: Run `make test`, then drive it in a real browser** |
| 1607 | |
| 1608 | Two tabs, same sheet, type in one, watch the other. This is the acceptance |
| 1609 | criterion for the whole plan and it cannot be asserted in Go. |
| 1610 | |
| 1611 | - [ ] **Step 8: Commit** |
| 1612 | |
| 1613 | ```bash |
| 1614 | git add internal/sheets/static internal/sheets/templates/sheet.html \ |
| 1615 | internal/guardstest/jscap_test.go |
| 1616 | git commit -m "feat: the grid, painting strings the server computed" |
| 1617 | ``` |
| 1618 | |
| 1619 | --- |
| 1620 | |
| 1621 | ### Task 11: The scriptless table, and the affordance gate |
| 1622 | |
| 1623 | Spec §6: a **read-only table view of any sheet with per-cell edit forms**, |
| 1624 | paginated. And: *"No form in the app is script-required. A test fetches every |
| 1625 | page script-stripped and diffs for every affordance."* |
| 1626 | |
| 1627 | **Files:** |
| 1628 | - Create: `internal/sheets/handlers_table.go`, |
| 1629 | `internal/sheets/handlers_table_test.go`, |
| 1630 | `internal/sheets/templates/table.html` |
| 1631 | |
| 1632 | - [ ] **Step 1: Write the failing test** |
| 1633 | |
| 1634 | ```go |
| 1635 | // The gate spec 6 asks for, made concrete: every write the grid can do, |
| 1636 | // the scriptless view can do too — proven by actually doing it with no |
| 1637 | // JavaScript anywhere in the request. |
| 1638 | func TestACellCanBeEditedWithNoScriptAtAll(t *testing.T) { |
| 1639 | app := newTestApp(t) |
| 1640 | m, _ := app.memberFor("ada@example.com") |
| 1641 | sheet, _ := app.createSheet("Budget", m) |
| 1642 | |
| 1643 | form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=2*21"}} |
| 1644 | req := httptest.NewRequest(http.MethodPost, |
| 1645 | fmt.Sprintf("/sheets/%d/table", sheet.ID), strings.NewReader(form.Encode())) |
| 1646 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 1647 | rec := httptest.NewRecorder() |
| 1648 | app.Router().ServeHTTP(rec, req) |
| 1649 | if rec.Code != http.StatusSeeOther { |
| 1650 | t.Fatalf("scriptless cell edit = %d, want 303", rec.Code) |
| 1651 | } |
| 1652 | |
| 1653 | page := get(t, app, fmt.Sprintf("/sheets/%d/table", sheet.ID)) |
| 1654 | if !strings.Contains(page, ">42<") { |
| 1655 | t.Fatal("the scriptless table does not show the value it just computed") |
| 1656 | } |
| 1657 | } |
| 1658 | |
| 1659 | func TestTableViewIsPaginated(t *testing.T) { |
| 1660 | // 200,000 cells rendered as one HTML table is a several-hundred- |
| 1661 | // megabyte page (spec 6). Assert a page renders at most |
| 1662 | // tableRowsPerPage rows and offers a next link. |
| 1663 | } |
| 1664 | |
| 1665 | func TestEveryPageWorksWithoutScript(t *testing.T) { |
| 1666 | // For each page path: fetch, strip every <script>...</script> and |
| 1667 | // every on* attribute, and assert at least one <form> or <a href> |
| 1668 | // remains for each affordance the page claims to offer. |
| 1669 | } |
| 1670 | ``` |
| 1671 | |
| 1672 | - [ ] **Step 2–4:** run, implement, run. `tableRowsPerPage = 50`. |
| 1673 | |
| 1674 | - [ ] **Step 5: Commit** |
| 1675 | |
| 1676 | ```bash |
| 1677 | git commit -am "feat: a scriptless table view that can edit any cell" |
| 1678 | ``` |
| 1679 | |
| 1680 | --- |
| 1681 | |
| 1682 | ### Task 12: An imported fill that fails WCAG AA gets surfaced |
| 1683 | |
| 1684 | Spec §14's deferred table has one row whose trigger is *"plan 2's grid |
| 1685 | work"*, cross-referencing §11.1. This is that trigger firing. §11.1 already |
| 1686 | ruled that a retained fill can be unreadable and that this is **correct** — |
| 1687 | so the work is to *surface* it, not to fix it. |
| 1688 | |
| 1689 | **Files:** |
| 1690 | - Modify: `internal/sheets/handlers_grid.go` (add `contrastWarn` to the |
| 1691 | cell payload), `internal/sheets/static/grid.js`, `templates/table.html` |
| 1692 | - Create: `internal/sheets/contrast_test.go` |
| 1693 | - Modify: `docs/superpowers/specs/2026-08-30-sheets-design.md` (§14: strike |
| 1694 | the row, since its trigger has fired and it is now built) |
| 1695 | |
| 1696 | - [ ] **Step 1: Read §11.1 and §10.4 in full before writing anything.** The |
| 1697 | ruling is subtle and the test must encode the ruling, not a guess at it. |
| 1698 | |
| 1699 | - [ ] **Step 2: Write the failing test.** A cell whose imported fill and ink |
| 1700 | fall below AA reports `contrastWarn: true`; one that passes does not. Use |
| 1701 | rastrillo's own contrast helper if `ui` exposes one — check |
| 1702 | `ui/contrast_test.go` for the function it tests — rather than writing a |
| 1703 | second WCAG implementation that can disagree with the framework's. |
| 1704 | |
| 1705 | - [ ] **Step 3–4:** implement, run. |
| 1706 | |
| 1707 | - [ ] **Step 5: Commit** |
| 1708 | |
| 1709 | ```bash |
| 1710 | git commit -am "feat: surface an imported fill that fails AA, per spec 11.1's trigger" |
| 1711 | ``` |
| 1712 | |
| 1713 | --- |
| 1714 | |
| 1715 | ### Task 13: Deploy to the canary and verify the process |
| 1716 | |
| 1717 | **Files:** none. This is the step AGENTS.md's Deploys section exists for. |
| 1718 | |
| 1719 | - [ ] **Step 1: Confirm the tree is clean and CI is green** |
| 1720 | |
| 1721 | ```bash |
| 1722 | git status --porcelain # must be empty |
| 1723 | make test # all six targets |
| 1724 | ``` |
| 1725 | |
| 1726 | - [ ] **Step 2: Set the new secret** |
| 1727 | |
| 1728 | `SHEETS_DEK` did not exist before Task 1, so the instance will refuse to |
| 1729 | boot without it: |
| 1730 | |
| 1731 | ```bash |
| 1732 | head -c 32 /dev/urandom | base64 |
| 1733 | carlos secrets set --app sheets SHEETS_DEK=<that value> |
| 1734 | ``` |
| 1735 | |
| 1736 | - [ ] **Step 3: Deploy** |
| 1737 | |
| 1738 | ```bash |
| 1739 | make release |
| 1740 | carlos deploy --app sheets --channel edge |
| 1741 | ``` |
| 1742 | |
| 1743 | `carlos deploy`, never `carlos ship` — `ship` publishes a release and stops, |
| 1744 | leaving it unpromoted and unserved. |
| 1745 | |
| 1746 | - [ ] **Step 4: Verify the process, not the edge, by polling** |
| 1747 | |
| 1748 | ```bash |
| 1749 | want=$(git rev-parse --short HEAD) |
| 1750 | for i in $(seq 1 40); do |
| 1751 | got=$(curl -sS https://sheets.bds.oncarlos.com/api/version | tr -d '"') |
| 1752 | echo "$i: $got (want $want)" |
| 1753 | [ "$got" = "$want" ] && break |
| 1754 | sleep 10 |
| 1755 | done |
| 1756 | ``` |
| 1757 | |
| 1758 | A promote is not a deploy: the old binary served for over 90 seconds on |
| 1759 | three consecutive deploys here. `X-Carlos-Version` is the router's adopted |
| 1760 | release and says nothing about which binary is running — do not check it. |
| 1761 | |
| 1762 | - [ ] **Step 5: Drive it in two real browser tabs on the canary.** Type in |
| 1763 | one, watch the other. Review happens on a deployed canary, never |
| 1764 | localhost. |
| 1765 | |
| 1766 | - [ ] **Step 6: Open the branch discussion and record the review model** |
| 1767 | |
| 1768 | Every branch gets a discussion, and that discussion IS the review record, |
| 1769 | including which model reviewed it (AGENTS.md §Review). Record the |
| 1770 | `rastrillo doctor` output from Task 6 there too. |
| 1771 | |
| 1772 | --- |
| 1773 | |
| 1774 | ## Self-Review |
| 1775 | |
| 1776 | **Spec coverage.** §6: grid vendored, no build step (Task 10); scriptless |
| 1777 | list, table view with per-cell edit forms, pagination, the affordance gate |
| 1778 | (Tasks 7, 11). §7: cell-level LWW over SSE (Tasks 5, 8, 9); derived cells |
| 1779 | carry the actor and a `derived` flag (Task 4); every message carries the |
| 1780 | version and clients discard by it (Tasks 4, 9, 10); streamed-200 transport |
| 1781 | check (Task 9); idle policy with hidden-tab heartbeats and a ceiling (Tasks |
| 1782 | 9, 10). Presence and undo are §7 and are **deferred with triggers**, stated |
| 1783 | in Assumptions. §10: attribute grammar, per-screen adoption, tokens frozen |
| 1784 | via `doctor`, theme before first paint (Task 6). §11.1/§14: the contrast |
| 1785 | trigger (Task 12). |
| 1786 | |
| 1787 | **Gaps I am naming rather than hiding.** §7's presence and undo are not |
| 1788 | built. §8's sharing is not built, so access is instance-wide. §13 sequences |
| 1789 | sharing before live sync; this plan inverts that, for the reason in |
| 1790 | Assumptions 2. |
| 1791 | |
| 1792 | **Placeholders.** Tasks 7, 8, 11 and 12 give test names and the reasoning |
| 1793 | each test encodes, but leave some bodies as a described assertion rather |
| 1794 | than finished Go. That is a deliberate density trade at the tail of a long |
| 1795 | plan, and it is a real weakness of this document: the executor writes those |
| 1796 | bodies. Every task's *first* test — the one that drives the design — is |
| 1797 | complete code. |
| 1798 | |
| 1799 | **Type consistency.** `Report`/`ChangedCell` (Task 4) are what Task 8 |
| 1800 | marshals and Task 9 fans out. `SheetSummary` (Task 3) is what Tasks 7, 8 and |
| 1801 | 11 read. `Member` (Task 2) is the actor everywhere. `themeFrom`/`PageData` |
| 1802 | (Task 6) are what every template consumes. |
| 1803 | |