| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | crand "crypto/rand" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "html/template" |
| 9 | "log/slog" |
| 10 | mrand "math/rand/v2" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/google/uuid" |
| 15 | "gorm.io/gorm" |
| 16 | |
| 17 | "github.com/carlosframework/docs/internal/core" |
| 18 | ) |
| 19 | |
| 20 | // Store is documents' persistence: rows in SQLite for identity and |
| 21 | // the update log, and the document's actual text read back through |
| 22 | // the real CRDT core rather than any column here — see Text. |
| 23 | type Store struct { |
| 24 | team *teamRuntime |
| 25 | db *gorm.DB |
| 26 | core *core.Runtime |
| 27 | content *contentCipher |
| 28 | |
| 29 | // sampleDocuments mirrors Config.SampleDocuments, already parsed — |
| 30 | // see EnsurePerson's own doc comment for what it gates and why the |
| 31 | // gate lives here rather than at the HTTP layer. |
| 32 | sampleDocuments bool |
| 33 | |
| 34 | // seedOnlyMembers, when set, keeps the sample library away from a |
| 35 | // person admitted only by a grant. |
| 36 | // |
| 37 | // The sample documents are "welcome to your instance" content. Somebody |
| 38 | // invited to read one document is not arriving at their instance; they |
| 39 | // are arriving at somebody else's, to read one thing. Seeding them six |
| 40 | // documents of ours is the outcome 0005_grants.sql's comment says the |
| 41 | // design avoids — it just avoided it at grant time and then did it at |
| 42 | // sign-in time instead. (Branch review, finding 9.) |
| 43 | // |
| 44 | // nil means "seed everybody", which is what every construction without |
| 45 | // an allowlist gets, and matches sampleDocuments' own default shape. |
| 46 | seedOnlyMembers func(address string) bool |
| 47 | |
| 48 | // logger is where a sample-seeding failure goes — see |
| 49 | // seedIfEnabled's own doc comment for why that failure is logged |
| 50 | // and swallowed here rather than ever returned to EnsurePerson's |
| 51 | // caller. |
| 52 | logger *slog.Logger |
| 53 | } |
| 54 | |
| 55 | // NewStore builds a Store over an already-migrated *gorm.DB and a |
| 56 | // shared *core.Runtime — one Runtime compiles the wasm module once |
| 57 | // and is reused for every document (core.New's doc comment); each |
| 58 | // call into it below opens its own per-document instance (a |
| 59 | // *core.Handle) and closes it again. |
| 60 | // |
| 61 | // sampleDocuments and logger back EnsurePerson's own per-person |
| 62 | // sample seeding (see its doc comment): sampleDocuments is |
| 63 | // Config.SampleDocuments already parsed by parseSampleDocuments |
| 64 | // (app.go), and logger is where a seeding failure is reported rather |
| 65 | // than propagated. A nil logger defaults to slog.Default(), the same |
| 66 | // fallback New (app.go) applies to Config.Logger — this Store can be |
| 67 | // built directly in a test (scope_test.go and others already do) with |
| 68 | // no logger of its own to pass. |
| 69 | func NewStore(db *gorm.DB, rt *core.Runtime, sampleDocuments bool, logger *slog.Logger) *Store { |
| 70 | if logger == nil { |
| 71 | logger = slog.Default() |
| 72 | } |
| 73 | return &Store{db: db, core: rt, sampleDocuments: sampleDocuments, logger: logger} |
| 74 | } |
| 75 | |
| 76 | // sqidAlphabet avoids visually ambiguous characters (0/O, 1/l/I) — |
| 77 | // sqids appear in URLs a person may read aloud or retype. |
| 78 | const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" |
| 79 | |
| 80 | const sqidLength = 10 |
| 81 | |
| 82 | // newSqid generates a random opaque document identifier, distinct |
| 83 | // from the row's integer id, safe to expose in a URL. len(sqidAlphabet) |
| 84 | // is 31 (8 digits, 23 letters — 0/O, 1/l/I excluded), not a power of |
| 85 | // two, so taking each random byte mod 31 is not perfectly uniform: |
| 86 | // 256 % 31 == 8, so the first eight symbols (the digits '2'-'9', the |
| 87 | // low end of sqidAlphabet) come out of this loop about 3% more often |
| 88 | // than the rest. Fix round 3 corrected this comment, which used to |
| 89 | // assert a false "32, so no bias" — a wrong claim about a |
| 90 | // security-adjacent property is worse than admitting the real one. |
| 91 | // The bias itself is harmless at sqidLength characters: it narrows |
| 92 | // the effective keyspace by a small, bounded fraction, nowhere near |
| 93 | // enough to make ten random symbols from a 31-letter alphabet |
| 94 | // guessable. |
| 95 | func newSqid() (string, error) { |
| 96 | raw := make([]byte, sqidLength) |
| 97 | if _, err := crand.Read(raw); err != nil { |
| 98 | return "", fmt.Errorf("docs: generate sqid: %w", err) |
| 99 | } |
| 100 | out := make([]byte, sqidLength) |
| 101 | for i, b := range raw { |
| 102 | out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)] |
| 103 | } |
| 104 | return string(out), nil |
| 105 | } |
| 106 | |
| 107 | // maxSqidAttempts bounds Create's retry loop on a sqid collision. At |
| 108 | // sqidLength characters over sqidAlphabet's 31 symbols, a collision is |
| 109 | // astronomically unlikely; this exists only so a UNIQUE violation is |
| 110 | // handled rather than surprising, not because collisions are |
| 111 | // expected. |
| 112 | const maxSqidAttempts = 5 |
| 113 | |
| 114 | // owned scopes g to documents owner may see — the one seam every |
| 115 | // query in this file goes through, per the plan's own ruling. |
| 116 | // Deliberately not rastrillo's scope.Owned (SKILL.md §3): that helper |
| 117 | // is `g.Where("user_id = ?", owner)`, which is exactly what this is |
| 118 | // today, but authored, versioned and tested outside this package. |
| 119 | // Applied to a table that later grows sharing (spec §4.2's `grants` |
| 120 | // join, out of scope for this plan), scope.Owned would keep silently |
| 121 | // filtering to creator-only forever — a document library that looks |
| 122 | // empty rather than broken, the worst available failure shape, on the |
| 123 | // helper an author would naturally reach for. Writing `owned` here |
| 124 | // instead means the day sharing lands, this is the one function that |
| 125 | // changes (to add the join), not a search-and-replace across |
| 126 | // scattered WHERE clauses — and TestOwnedScopesByOwner (scope_test.go) |
| 127 | // pins its current, ownership-only contract so a future edit that |
| 128 | // widens it does so on purpose. |
| 129 | // |
| 130 | // owner <= 0 matches nothing, deliberately, rather than trusting every |
| 131 | // caller to reject it first. people.id starts at 1 (SQLite's INTEGER |
| 132 | // PRIMARY KEY), so 0 is never a real person — it is exactly the value |
| 133 | // 0002_owners.sql's migration backfilled onto every document that |
| 134 | // existed before ownership did (see that file's own comment, and |
| 135 | // TestOrphanedDocumentsAreVisibleToNobody in scope_test.go, which |
| 136 | // pins this). Fix round 1 on this task: a reviewer found owned(g, 0), |
| 137 | // Store.List(ctx, 0) and Store.Get(ctx, 0, ...) all happily returned |
| 138 | // those orphaned rows — nothing upstream exploited that yet, but nothing |
| 139 | // prevented a careless future caller from doing so either, and the |
| 140 | // seam is the one place that can make it structurally impossible |
| 141 | // rather than merely unexercised today. Matching nothing (a `1 = 0` |
| 142 | // clause) rather than returning an error keeps every call site's |
| 143 | // chaining (.Where/.Order/.Find) unchanged, and gives owner 0 the same |
| 144 | // outcome as any other owner with no visible rows — no distinct error |
| 145 | // shape for "invalid owner" to leak through a 500 or otherwise |
| 146 | // distinguish itself from "you just have nothing here" (spec §4.2's |
| 147 | // own reasoning against a distinguishable failure mode, applied here |
| 148 | // to the seam itself rather than only to the 404-vs-403 handler |
| 149 | // boundary). |
| 150 | // |
| 151 | // It does not filter deleted_at: that is not an ownership concern. |
| 152 | // Folding it in here would make `owned` do two unrelated jobs under |
| 153 | // one name, and would silently change behaviour for any future caller |
| 154 | // that wants every one of an owner's rows regardless of deletion state |
| 155 | // (an admin or export path, say) without ever needing to touch this |
| 156 | // function. List and Get add `deleted_at IS NULL` themselves, |
| 157 | // deliberately visible at the call site. |
| 158 | func owned(g *gorm.DB, owner int64) *gorm.DB { |
| 159 | if owner <= 0 { |
| 160 | return g.Where("1 = 0") |
| 161 | } |
| 162 | return g.Where("owner_id = ?", owner) |
| 163 | } |
| 164 | |
| 165 | // EnsurePerson maps an authenticated address to this app's own, |
| 166 | // numeric Person id — get-or-create, keyed on a case-insensitive, |
| 167 | // trimmed Email. This is the translation rastrillo SKILL.md §5 |
| 168 | // requires under the magic-link plugin: the session Subject is the |
| 169 | // verified email itself, not a numeric id, so sessions.UserID returns |
| 170 | // (0, false) and must never be used (see Document.OwnerID's doc |
| 171 | // comment). handlers.go's owner helper calls this on every |
| 172 | // authenticated request; TestPerson (testing.go) calls it directly so |
| 173 | // a test can mint an owner id without going through HTTP at all — the |
| 174 | // two must and do agree, since TestPerson is this method and nothing |
| 175 | // else. |
| 176 | // |
| 177 | // The create branch below — and only the create branch — is also |
| 178 | // where a brand-new person's sample documents get seeded |
| 179 | // (seedIfEnabled), when this instance has opted in. That is |
| 180 | // deliberate and is the whole reason this hangs off "a Person row was |
| 181 | // just created" rather than off "List came back empty": a person who |
| 182 | // deletes every one of their documents still has a Person row, so |
| 183 | // their next sign-in takes the found branch just above and returns |
| 184 | // early — never reaching seedIfEnabled — and gets nothing back. |
| 185 | // Seeding is a one-time event in a person's history, tied to the one |
| 186 | // moment that can only ever happen once: their row not existing yet. |
| 187 | func (s *Store) EnsurePerson(ctx context.Context, email string) (int64, error) { |
| 188 | email = strings.TrimSpace(strings.ToLower(email)) |
| 189 | if email == "" { |
| 190 | return 0, errors.New("docs: EnsurePerson: address is empty") |
| 191 | } |
| 192 | |
| 193 | var p Person |
| 194 | err := s.db.WithContext(ctx).Where("email = ?", email).First(&p).Error |
| 195 | if err == nil { |
| 196 | return p.ID, nil |
| 197 | } |
| 198 | if !errors.Is(err, gorm.ErrRecordNotFound) { |
| 199 | return 0, fmt.Errorf("docs: look up person %q: %w", email, err) |
| 200 | } |
| 201 | |
| 202 | p = Person{Email: email, CreatedAt: time.Now().UTC()} |
| 203 | if err := s.db.WithContext(ctx).Create(&p).Error; err != nil { |
| 204 | // A concurrent first sign-in from the same brand-new address |
| 205 | // can race two inserts against the unique index on email; the |
| 206 | // loser looks up what the winner just created rather than |
| 207 | // failing the request over a benign collision. The loser never |
| 208 | // seeds: only the goroutine whose INSERT actually won reaches |
| 209 | // seedIfEnabled below, so two simultaneous first sign-ins for |
| 210 | // the same address still produce exactly one set of sample |
| 211 | // documents, not two. |
| 212 | if errors.Is(err, gorm.ErrDuplicatedKey) { |
| 213 | if err2 := s.db.WithContext(ctx).Where("email = ?", email).First(&p).Error; err2 == nil { |
| 214 | return p.ID, nil |
| 215 | } |
| 216 | } |
| 217 | return 0, fmt.Errorf("docs: create person %q: %w", email, err) |
| 218 | } |
| 219 | |
| 220 | s.seedIfEnabled(ctx, p.ID, email) |
| 221 | return p.ID, nil |
| 222 | } |
| 223 | |
| 224 | // seedIfEnabled seeds newOwner's document list with CARLOS Docs' own |
| 225 | // sample content, when this instance has opted in |
| 226 | // (Config.SampleDocuments, threaded through as Store.sampleDocuments) |
| 227 | // — called only from EnsurePerson's just-created-the-row branch above, |
| 228 | // never from its found-an-existing-row branch, and never called twice |
| 229 | // for the same person: EnsurePerson's own doc comment has the full |
| 230 | // reasoning for why that hook point is the one that gives "seed once, |
| 231 | // per person, ever" for free rather than needing its own bookkeeping. |
| 232 | // |
| 233 | // This runs after the Create above has already returned nil — not |
| 234 | // wrapped in a transaction of its own, and deliberately not folded |
| 235 | // into a transaction around Create either (there isn't one today: |
| 236 | // that INSERT already autocommits on its own). Seed's own work below |
| 237 | // is six Store.Create calls, six Store.ApplyUpdate calls — each of |
| 238 | // which replays a document's update log and applies a fresh one |
| 239 | // through internal/core's WebAssembly runtime, not a trivial |
| 240 | // in-transaction operation — and six backdating UPDATEs. Holding all |
| 241 | // of that, plus the person row's own INSERT, inside one atomic unit |
| 242 | // would mean a slow seed holds whatever locks SQLite takes for the |
| 243 | // duration, and would mean a seed failure could take the newly |
| 244 | // created person row down with it on rollback. Neither is |
| 245 | // acceptable: a person's own row must be durable the moment Create |
| 246 | // above succeeds, regardless of what happens next. |
| 247 | // |
| 248 | // So the error boundary sits here, not in Seed and not in |
| 249 | // EnsurePerson's return: a failure is logged, with the address and |
| 250 | // the new person id for context, and then dropped on the floor. |
| 251 | // EnsurePerson still returns (p.ID, nil) no matter what happens in |
| 252 | // here. A person who cannot sign in because their sample content |
| 253 | // failed to generate is a far worse outcome than a person who signs |
| 254 | // in to an empty list — the second is this feature not having worked |
| 255 | // yet; the first is this feature having broken sign-in, which is the |
| 256 | // exact failure this whole plan exists to fix, not reintroduce. |
| 257 | func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string) { |
| 258 | if s.team != nil || !s.sampleDocuments { |
| 259 | return |
| 260 | } |
| 261 | if s.seedOnlyMembers != nil && !s.seedOnlyMembers(email) { |
| 262 | return |
| 263 | } |
| 264 | if err := Seed(ctx, s, localActor(newOwner, email)); err != nil { |
| 265 | s.logger.Error("docs: seed sample documents for new person", "person", newOwner, "address", email, "err", err) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // Create makes a new document owned by owner: a GUID and a client id |
| 270 | // assigned here by the app (never drawn from yrs — see Document's doc |
| 271 | // comment) and stored, an app-generated sqid, and the given title. It |
| 272 | // does not touch the CRDT core at all: an empty document needs no |
| 273 | // update log entry, and Text/ApplyUpdate are what open a core handle |
| 274 | // when there is work for one to do. |
| 275 | // |
| 276 | // owner is always a Person id the caller already resolved from the |
| 277 | // signed-in session (handlers.go's owner helper, or TestPerson in |
| 278 | // tests) — never a value read off the request body. rastrillo |
| 279 | // SKILL.md §3: "Creating takes the owner from the session, never the |
| 280 | // form." |
| 281 | func (s *Store) Create(ctx context.Context, owner int64, title string) (*Document, error) { |
| 282 | return s.CreateDraft(ctx, owner, title, "") |
| 283 | } |
| 284 | |
| 285 | // CreateDraft makes retries after a lost acknowledgement return the same document. |
| 286 | // The token is scoped to the authenticated owner, never a document capability. |
| 287 | func (s *Store) CreateDraft(ctx context.Context, owner int64, title, token string) (*Document, error) { |
| 288 | actor := s.ownerActor(ctx, owner) |
| 289 | if err := s.requireMember(ctx, actor); err != nil { |
| 290 | return nil, err |
| 291 | } |
| 292 | guid := uuid.NewString() |
| 293 | // math/rand/v2's Int64 returns a non-negative value in [0, 1<<63), |
| 294 | // so converting to uint64 never wraps — the brief's "a counter or |
| 295 | // random int64 from math/rand/v2". |
| 296 | clientID := uint64(mrand.Int64()) |
| 297 | now := time.Now().UTC() |
| 298 | |
| 299 | var lastErr error |
| 300 | for attempt := 0; attempt < maxSqidAttempts; attempt++ { |
| 301 | sqid, err := newSqid() |
| 302 | if err != nil { |
| 303 | return nil, err |
| 304 | } |
| 305 | doc := &Document{ |
| 306 | Sqid: sqid, |
| 307 | Title: title, |
| 308 | OwnerID: owner, |
| 309 | GUID: guid, |
| 310 | ClientID: clientID, |
| 311 | CreatedAt: now, |
| 312 | UpdatedAt: now, |
| 313 | } |
| 314 | err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 315 | if err := s.requireMember(ctx, actor); err != nil { |
| 316 | return err |
| 317 | } |
| 318 | if token != "" { |
| 319 | var existing Document |
| 320 | err := tx.Where("owner_id = ? AND creation_token = ?", owner, token).First(&existing).Error |
| 321 | if err == nil { |
| 322 | if existing.DeletedAt != nil { |
| 323 | return gorm.ErrRecordNotFound |
| 324 | } |
| 325 | *doc = existing |
| 326 | return s.requireMember(ctx, actor) |
| 327 | } |
| 328 | if !errors.Is(err, gorm.ErrRecordNotFound) { |
| 329 | return err |
| 330 | } |
| 331 | doc.CreationToken = &token |
| 332 | } |
| 333 | if err := tx.Create(doc).Error; err != nil { |
| 334 | return err |
| 335 | } |
| 336 | return s.requireMember(ctx, actor) |
| 337 | }) |
| 338 | if err == nil { |
| 339 | return doc, nil |
| 340 | } |
| 341 | lastErr = err |
| 342 | if !errors.Is(err, gorm.ErrDuplicatedKey) { |
| 343 | break |
| 344 | } |
| 345 | } |
| 346 | return nil, fmt.Errorf("docs: create document: %w", lastErr) |
| 347 | } |
| 348 | |
| 349 | // List returns every one of owner's non-deleted documents, newest |
| 350 | // first — "newest" meaning most recently touched (UpdatedAt), the same |
| 351 | // clock handlers.go's groupByRecency/formatWhen bucket and display, |
| 352 | // and the one ApplyUpdate now bumps on every applied update. Ordering |
| 353 | // by CreatedAt instead (fix round 2's own bug: they agreed only by |
| 354 | // accident, because nothing bumped UpdatedAt away from CreatedAt) let |
| 355 | // this list and its own recency buckets disagree the moment a |
| 356 | // document's UpdatedAt ever moved without a matching reorder here — |
| 357 | // see store_test.go's TestListOrdersByUpdatedAt. |
| 358 | // |
| 359 | // A document owner does not own is not in this list — not filtered |
| 360 | // out afterward, never selected in the first place, through owned |
| 361 | // above. |
| 362 | func (s *Store) List(ctx context.Context, owner int64) ([]*Document, error) { |
| 363 | var docs []*Document |
| 364 | err := s.permissionScope(ctx, s.db.WithContext(ctx), s.ownerActor(ctx, owner), permissionOwner). |
| 365 | Where("deleted_at IS NULL"). |
| 366 | Order("updated_at DESC, id DESC"). |
| 367 | Find(&docs).Error |
| 368 | if err != nil { |
| 369 | return nil, fmt.Errorf("docs: list documents: %w", err) |
| 370 | } |
| 371 | s.rememberResponse(ctx, permissionOwner, docs...) |
| 372 | return docs, nil |
| 373 | } |
| 374 | |
| 375 | // Get looks up one of owner's documents by its sqid. A sqid that |
| 376 | // exists but belongs to someone else is indistinguishable from a sqid |
| 377 | // that does not exist at all — both return gorm.ErrRecordNotFound |
| 378 | // through owned above, never a distinct "yes, but not yours" outcome — |
| 379 | // which is what lets handlers.go answer 404 and never 403 for a |
| 380 | // document (spec §4.2). |
| 381 | func (s *Store) Get(ctx context.Context, owner int64, sqid string) (*Document, error) { |
| 382 | var doc Document |
| 383 | err := s.permissionScope(ctx, s.db.WithContext(ctx), s.ownerActor(ctx, owner), permissionOwner). |
| 384 | Where("sqid = ? AND deleted_at IS NULL", sqid). |
| 385 | First(&doc).Error |
| 386 | if err != nil { |
| 387 | return nil, s.permissionError(ctx, s.ownerActor(ctx, owner), fmt.Errorf("docs: get document %q: %w", sqid, err)) |
| 388 | } |
| 389 | s.rememberResponse(ctx, permissionOwner, &doc) |
| 390 | return &doc, nil |
| 391 | } |
| 392 | |
| 393 | // replay opens a fresh core.Handle for doc (guid and client id come |
| 394 | // from the stored row, never from yrs — see Document) and rebuilds the |
| 395 | // document's CRDT state into it: the newest usable snapshot, then only |
| 396 | // the doc_updates rows after it, in seq order. With no usable snapshot |
| 397 | // it applies every row instead. The caller owns the returned Handle |
| 398 | // and must Close it. |
| 399 | // |
| 400 | // Snapshot-then-tail is the snapshots design's D6. It is what keeps |
| 401 | // the retained-update count in a core.Handle clear of |
| 402 | // core.MaxRetainedUpdates: while snapshot writes keep succeeding, |
| 403 | // replay is one snapshot plus roughly snapshotCadence updates, so the |
| 404 | // cliff ErrUpdateLogFull describes stops being reachable in the |
| 405 | // ordinary case. That bound is conditional on those writes, and |
| 406 | // snapshotCadence's own comment says what it costs when they stop |
| 407 | // succeeding. The export it restores |
| 408 | // from (ABI v4's docs_core_snapshot_len/read, via core.Handle.Snapshot) |
| 409 | // is exactly the one core.go's maxRetainedUpdates comment asks for; |
| 410 | // ApplyUpdate below is the host caller that persists it. |
| 411 | // |
| 412 | // A snapshot is an accelerator, never the source of truth (D2). |
| 413 | // doc_updates keeps every row, and any snapshot that cannot be read — |
| 414 | // unloadable row, bytes the core refuses, an ABI newestSnapshot |
| 415 | // already filters out — is discarded and the whole log replayed |
| 416 | // instead. That fallback is slower and always correct, which is the |
| 417 | // bar every failure path in this file is held to. |
| 418 | // |
| 419 | // Slower, and correct even for a log longer than |
| 420 | // core.MaxRetainedUpdates — which snapshots themselves made possible, |
| 421 | // since before them ApplyUpdate refused once a handle filled and no log |
| 422 | // could grow past the cap. A full-log replay of such a document would |
| 423 | // meet core.ErrUpdateLogFull partway through and fail, leaving the |
| 424 | // document unreadable AND unwritable (ApplyUpdate returns here, before |
| 425 | // the writeSnapshot that would repair it). It would take one ABI bump |
| 426 | // to do that to every long document at once, newestSnapshot's ABI |
| 427 | // filter being exact. So the tail loop below does not fail there: it |
| 428 | // re-seeds a fresh handle from the state it has reached, in memory, |
| 429 | // and carries on. See reseedForReplay. |
| 430 | // |
| 431 | // The two seqs come back with the handle rather than being asked for |
| 432 | // again: replayedSeq is the highest seq this replay actually |
| 433 | // incorporated (the last row applied, or the snapshot's own seq when |
| 434 | // the tail was empty), and lastSnapshotSeq is the seq of the snapshot |
| 435 | // it started from, or 0 when it started from nothing — including when |
| 436 | // a snapshot existed but was discarded, so the next write repairs it. |
| 437 | // ApplyUpdate's cadence check is the difference between them, computed |
| 438 | // from rows already read and never from a second query — the same |
| 439 | // discipline the live-sync design's D2 sets for Docs-Seq, for the same |
| 440 | // reason. Text, HTML and JSON below discard both: reading a document |
| 441 | // never writes a snapshot, because a snapshot is only ever taken on |
| 442 | // the write path (D3), where a request has already proved the instance |
| 443 | // is awake. |
| 444 | func (s *Store) replay(ctx context.Context, doc *Document) (h *core.Handle, replayedSeq int64, lastSnapshotSeq int64, err error) { |
| 445 | h, err = s.core.NewDoc(ctx, doc.GUID, doc.ClientID) |
| 446 | if err != nil { |
| 447 | return nil, 0, 0, fmt.Errorf("docs: open core handle for document %d: %w", doc.ID, err) |
| 448 | } |
| 449 | |
| 450 | // A snapshot that cannot even be read out of the database is not |
| 451 | // an error to return: the log alone reproduces the document, so |
| 452 | // this is logged and abandoned rather than propagated. |
| 453 | snap, snapErr := s.newestSnapshot(ctx, doc.ID) |
| 454 | if snapErr != nil { |
| 455 | s.logger.Error("docs: load newest snapshot, replaying the whole log instead", "doc_id", doc.ID, "err", snapErr) |
| 456 | snap = nil |
| 457 | } |
| 458 | if snap != nil { |
| 459 | if applyErr := h.Apply(ctx, snap.State); applyErr != nil { |
| 460 | s.logger.Error("docs: apply snapshot, replaying the whole log instead", "doc_id", doc.ID, "snapshot_seq", snap.Seq, "err", applyErr) |
| 461 | // The handle is discarded along with the snapshot rather |
| 462 | // than reused. Handle.Apply leaves the instance usable |
| 463 | // after a clean rejection and rebuilds it after a trap, |
| 464 | // but its third shape — a rebuild that itself failed — |
| 465 | // makes no such promise, and a fresh NewDoc on this |
| 466 | // already-exceptional path costs less than reasoning |
| 467 | // about which shape this was. |
| 468 | h.Close(ctx) |
| 469 | snap = nil |
| 470 | h, err = s.core.NewDoc(ctx, doc.GUID, doc.ClientID) |
| 471 | if err != nil { |
| 472 | return nil, 0, 0, fmt.Errorf("docs: reopen core handle for document %d after a bad snapshot: %w", doc.ID, err) |
| 473 | } |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | tail := s.db.WithContext(ctx).Where("doc_id = ?", doc.ID) |
| 478 | if snap != nil { |
| 479 | lastSnapshotSeq = snap.Seq |
| 480 | replayedSeq = snap.Seq |
| 481 | tail = tail.Where("seq > ?", snap.Seq) |
| 482 | } |
| 483 | |
| 484 | var updates []DocUpdate |
| 485 | if err := tail.Order("seq ASC").Find(&updates).Error; err != nil { |
| 486 | h.Close(ctx) |
| 487 | return nil, 0, 0, fmt.Errorf("docs: load updates for document %d: %w", doc.ID, err) |
| 488 | } |
| 489 | |
| 490 | for _, u := range updates { |
| 491 | if err := s.openContentUpdate(doc.GUID, &u); err != nil { |
| 492 | h.Close(ctx) |
| 493 | return nil, 0, 0, err |
| 494 | } |
| 495 | applyErr := h.Apply(ctx, u.UpdateBin) |
| 496 | if errors.Is(applyErr, core.ErrUpdateLogFull) { |
| 497 | // This handle has retained core.MaxRetainedUpdates updates |
| 498 | // and will accept no more, but the document is not over — |
| 499 | // there are rows left, and reaching them is the difference |
| 500 | // between a slow document and an unreadable one. So the |
| 501 | // replay continues in a fresh handle re-seeded from this |
| 502 | // one's state; see reseedForReplay. |
| 503 | // |
| 504 | // u itself was NOT applied: Handle.Apply's cap check is its |
| 505 | // first statement and returns before it touches the guest, |
| 506 | // so this row is still owed and is applied below, into the |
| 507 | // fresh handle, exactly once. |
| 508 | // |
| 509 | // Warn, not Error, and not silence either. Not Error, |
| 510 | // because nothing is wrong with the document: it is |
| 511 | // complete, correct and merely slower to read (a re-seeding |
| 512 | // replay costs an apply per row, tens of milliseconds per |
| 513 | // thousand, against about a millisecond for snapshot plus a |
| 514 | // short tail), and this document's next write repairs it |
| 515 | // with an ordinary snapshot. That is precisely the |
| 516 | // distinction an operator needs — this line means "slow, |
| 517 | // self-healing", where the two Error lines above mean "a |
| 518 | // snapshot is unusable". |
| 519 | // |
| 520 | // And not silence, which is what this was at first, on the |
| 521 | // reasoning that the next write clears it. That reasoning |
| 522 | // assumes a write arrives. Reads write nothing (D3: Text, |
| 523 | // HTML and JSON discard both seqs), so a long document that |
| 524 | // is finished and only ever read again — a large share of |
| 525 | // what an ABI bump invalidates at once — re-seeds on every |
| 526 | // single read, forever, with latency as the only evidence. |
| 527 | // One line per read is the price of that not being invisible. |
| 528 | s.logger.Warn("docs: replay past the retained-update cap, re-seeding a fresh handle and continuing; slower until the next write writes a snapshot", |
| 529 | "doc_id", doc.ID, "seq", u.Seq) |
| 530 | |
| 531 | h, err = s.reseedForReplay(ctx, doc, h, u.Seq) |
| 532 | if err != nil { |
| 533 | return nil, 0, 0, err |
| 534 | } |
| 535 | applyErr = h.Apply(ctx, u.UpdateBin) |
| 536 | } |
| 537 | if applyErr != nil { |
| 538 | h.Close(ctx) |
| 539 | // Surfaced, not swallowed: whether this is a clean |
| 540 | // rejection, core.ErrUpdateLogFull, or a *core.RecoveredError |
| 541 | // (the specific update's outcome unknown, never retried — |
| 542 | // see Handle.Apply's doc comment), the log this Store |
| 543 | // itself wrote can no longer be trusted to reproduce the |
| 544 | // document, and callers must see that rather than silently |
| 545 | // getting a wrong or truncated text back. |
| 546 | return nil, 0, 0, fmt.Errorf("docs: replay update seq %d for document %d: %w", u.Seq, doc.ID, applyErr) |
| 547 | } |
| 548 | replayedSeq = u.Seq |
| 549 | } |
| 550 | return h, replayedSeq, lastSnapshotSeq, nil |
| 551 | } |
| 552 | |
| 553 | // reseedForReplay returns a fresh handle holding everything full holds, |
| 554 | // so a replay that has exhausted full's retained-update budget |
| 555 | // (core.MaxRetainedUpdates, core.ErrUpdateLogFull) can carry on instead |
| 556 | // of failing. full is closed either way; on any error the fresh handle |
| 557 | // is closed too and nothing is returned for the caller to close. |
| 558 | // |
| 559 | // The mechanism is the one this whole feature is built on, used |
| 560 | // in memory rather than through the database: full.Snapshot encodes its |
| 561 | // entire state as one Yjs update (Handle.Snapshot, ABI v4's |
| 562 | // docs_core_snapshot_len/read), and applying that to an empty document |
| 563 | // reproduces it — the same property doc_snapshots relies on, and the |
| 564 | // one TestReSeedingMidReplayRendersTheSameDocument pins here. The fresh |
| 565 | // handle's retained count is 1 afterwards, so the next |
| 566 | // core.MaxRetainedUpdates rows fit and a log of any length gets through |
| 567 | // in as many re-seeds as it needs. That also puts a ceiling on what a |
| 568 | // replay retains regardless of how long the log is, which is what the |
| 569 | // cap was protecting in the first place. |
| 570 | // |
| 571 | // Three things this deliberately does not do: |
| 572 | // |
| 573 | // It writes nothing. replay is the read path — Text, HTML and JSON all |
| 574 | // go through it — and a read that wrote to the database would be a |
| 575 | // different change with different consequences (D3 keeps snapshot |
| 576 | // writes on the write path, where a request has already proved the |
| 577 | // instance is awake). The persisted snapshot is still repaired the |
| 578 | // ordinary way, and getting here guarantees it: reaching the cap means |
| 579 | // more than core.MaxRetainedUpdates rows sat past whatever this replay |
| 580 | // started from, so replayedSeq-lastSnapshotSeq is past snapshotCadence |
| 581 | // by a factor of sixteen, and this document's next ApplyUpdate runs |
| 582 | // writeSnapshot — at the current ABI, which is what an ABI bump needs. |
| 583 | // |
| 584 | // It invents no identity. The fresh handle is opened exactly as replay |
| 585 | // opens its first one, from the stored row's GUID and ClientID, so the |
| 586 | // CRDT library is never a source of entropy here either (see Document). |
| 587 | // |
| 588 | // It never continues on a failure. If the snapshot, the fresh handle or |
| 589 | // its re-seeding fails, this returns the error rather than carrying on |
| 590 | // with full — carrying on would meet ErrUpdateLogFull again on the very |
| 591 | // next row and reintroduce the failure one row later, with the cause |
| 592 | // two frames further away. Every message names the document and the seq |
| 593 | // the replay had reached, because this is the one path here where an |
| 594 | // operator has to go and look at a specific document. |
| 595 | func (s *Store) reseedForReplay(ctx context.Context, doc *Document, full *core.Handle, atSeq int64) (*core.Handle, error) { |
| 596 | state, err := full.Snapshot(ctx) |
| 597 | if err != nil { |
| 598 | full.Close(ctx) |
| 599 | return nil, fmt.Errorf("docs: replay of document %d reached the retained-update cap at seq %d and could not snapshot its own state to continue: %w", doc.ID, atSeq, err) |
| 600 | } |
| 601 | |
| 602 | fresh, err := s.core.NewDoc(ctx, doc.GUID, doc.ClientID) |
| 603 | full.Close(ctx) |
| 604 | if err != nil { |
| 605 | return nil, fmt.Errorf("docs: replay of document %d reached the retained-update cap at seq %d and could not open a fresh core handle to continue: %w", doc.ID, atSeq, err) |
| 606 | } |
| 607 | if err := fresh.Apply(ctx, state); err != nil { |
| 608 | fresh.Close(ctx) |
| 609 | return nil, fmt.Errorf("docs: replay of document %d reached the retained-update cap at seq %d and could not re-seed a fresh handle from its own state: %w", doc.ID, atSeq, err) |
| 610 | } |
| 611 | return fresh, nil |
| 612 | } |
| 613 | |
| 614 | // Text returns doc's current text — read from the real CRDT core by |
| 615 | // replaying its update log, never from a database column. |
| 616 | func (s *Store) Text(ctx context.Context, doc *Document) (string, error) { |
| 617 | h, _, _, err := s.replay(ctx, doc) |
| 618 | if err != nil { |
| 619 | return "", err |
| 620 | } |
| 621 | defer h.Close(ctx) |
| 622 | |
| 623 | text, err := h.Text(ctx) |
| 624 | if err != nil { |
| 625 | return "", fmt.Errorf("docs: read text for document %d: %w", doc.ID, err) |
| 626 | } |
| 627 | return text, nil |
| 628 | } |
| 629 | |
| 630 | // HTML returns doc's current content rendered as HTML — read from the |
| 631 | // real CRDT core by replaying its update log, never from a database |
| 632 | // column. Mirrors Text exactly (same replay through s.replay, same |
| 633 | // defer h.Close(ctx)); the only difference is the core call this |
| 634 | // makes once the handle is open (h.HTML instead of h.Text) and the |
| 635 | // wrapping return type. |
| 636 | // |
| 637 | // Returning template.HTML rather than string is deliberate, not a |
| 638 | // convenience: core/src/fragment.rs escapes every text run and emits |
| 639 | // no attributes at all (Task 2's renderer), so this markup is safe to |
| 640 | // place in a template unescaped — and the typed return is where that |
| 641 | // "this is trusted markup" decision is made, once, next to the |
| 642 | // escaping that earns it, rather than left for views/show.html to |
| 643 | // assert with a {{ ... | html }} pipeline of its own. |
| 644 | func (s *Store) HTML(ctx context.Context, doc *Document) (template.HTML, error) { |
| 645 | h, _, _, err := s.replay(ctx, doc) |
| 646 | if err != nil { |
| 647 | return "", err |
| 648 | } |
| 649 | defer h.Close(ctx) |
| 650 | |
| 651 | html, err := h.HTML(ctx) |
| 652 | if err != nil { |
| 653 | return "", fmt.Errorf("docs: read html for document %d: %w", doc.ID, err) |
| 654 | } |
| 655 | return template.HTML(html), nil |
| 656 | } |
| 657 | |
| 658 | // JSON returns doc's structure as ProseMirror-shaped JSON — the same |
| 659 | // replay through the same core as Text and HTML above, reading the |
| 660 | // third renderer instead (core/src/fragment.rs's fragment_json). |
| 661 | // |
| 662 | // Returns a plain string, not template.HTML: this is never written |
| 663 | // into a page. It goes to internal/export, which decodes it and lays |
| 664 | // out Markdown or a PDF from the tree, and the trust decision HTML's |
| 665 | // typed return records has no counterpart here — a JSON string that |
| 666 | // reached a template would need escaping like any other data. |
| 667 | func (s *Store) JSON(ctx context.Context, doc *Document) (string, error) { |
| 668 | h, _, _, err := s.replay(ctx, doc) |
| 669 | if err != nil { |
| 670 | return "", err |
| 671 | } |
| 672 | defer h.Close(ctx) |
| 673 | |
| 674 | structure, err := h.JSON(ctx) |
| 675 | if err != nil { |
| 676 | return "", fmt.Errorf("docs: read structure for document %d: %w", doc.ID, err) |
| 677 | } |
| 678 | return structure, nil |
| 679 | } |
| 680 | |
| 681 | // updateRejection wraps whatever error Handle.Apply(update) itself |
| 682 | // returned for the update just submitted to ApplyUpdate below — never |
| 683 | // for a failure while replay (above) rebuilds this document's |
| 684 | // existing history, and never for an ordinary database error further |
| 685 | // down in ApplyUpdate. It exists purely so a caller outside this |
| 686 | // package (handlers.go's updatesPost, fix round 1 on Task 5) can tell |
| 687 | // "the core said no to the bytes the client just sent" apart from |
| 688 | // "something on our own end broke", using core.Recovered/core.Rejected/ |
| 689 | // errors.Is(..., core.ErrUpdateLogFull) on the unwrapped error, without |
| 690 | // mistaking a failure from replay for one — replay calls the exact |
| 691 | // same core.Handle.Apply, on the exact same *core.Handle, and would |
| 692 | // satisfy those same checks if it were not kept structurally distinct |
| 693 | // from this wrapper. A replay failure means EXISTING, already-accepted |
| 694 | // history came back corrupted or unreplayable — never the client's |
| 695 | // fault, regardless of what shape the underlying core error happens to |
| 696 | // take — so it is deliberately never given this wrapper. |
| 697 | type updateRejection struct{ err error } |
| 698 | |
| 699 | func (e *updateRejection) Error() string { return e.err.Error() } |
| 700 | func (e *updateRejection) Unwrap() error { return e.err } |
| 701 | |
| 702 | // currentSeq reports the highest seq in doc's update log, or 0 for a |
| 703 | // document nothing has been written to yet. |
| 704 | // |
| 705 | // This is the stream handler's connect-time answer (stream.go) and |
| 706 | // nothing else reads it. Note what it is NOT for: filling the |
| 707 | // Docs-Seq header on GET /d/{sqid}/updates. That header is derived |
| 708 | // from the rows being returned, precisely so that no query like this |
| 709 | // one can observe a row the response does not carry — see the design |
| 710 | // spec's D2 for the update that would otherwise go missing. |
| 711 | func (s *Store) currentSeq(ctx context.Context, docID int64) (int64, error) { |
| 712 | var seq int64 |
| 713 | if err := s.db.WithContext(ctx). |
| 714 | Model(&DocUpdate{}). |
| 715 | Where("doc_id = ?", docID). |
| 716 | Select("COALESCE(MAX(seq), 0)"). |
| 717 | Scan(&seq).Error; err != nil { |
| 718 | return 0, fmt.Errorf("docs: current seq for document %d: %w", docID, err) |
| 719 | } |
| 720 | return seq, nil |
| 721 | } |
| 722 | |
| 723 | // ApplyUpdate applies a new Yjs update to doc: it replays the |
| 724 | // existing log to rebuild current state, applies update against that |
| 725 | // live core instance, and only once the core has accepted it does it |
| 726 | // persist the update as the next row in doc_updates. A rejection from |
| 727 | // the core (a clean rejection, core.ErrUpdateLogFull, or a |
| 728 | // *core.RecoveredError) is returned as-is, wrapped in *updateRejection |
| 729 | // (above) so a caller can positively identify it as such, and the |
| 730 | // update is never persisted — see the errors.Is-friendly wrapping in |
| 731 | // replay's doc comment and Handle.Apply's for why an uncertain outcome |
| 732 | // must not be written down as a fact. |
| 733 | // |
| 734 | // Before any of that, doc.OwnerID is verified against the row itself. |
| 735 | // Fix round 2 on this task (Finding 1 again, this time for real): the |
| 736 | // previous fix scoped only the trailing updated_at bump through |
| 737 | // owned(doc.OwnerID), on the theory that it was "the one write in this |
| 738 | // file that skipped the seam." That was wrong — the doc_updates INSERT |
| 739 | // above (and the MAX(seq) query that sizes it) is keyed on doc.ID |
| 740 | // alone and always has been, and it runs long before the bump. Scoping |
| 741 | // only the last write meant a *Document carrying a stranger's OwnerID |
| 742 | // still replayed, applied and persisted the content; only the |
| 743 | // timestamp was refused, and a gorm UPDATE matching zero rows returns |
| 744 | // no error, so the whole call returned nil. Content wrote, silently, |
| 745 | // on a mismatched owner. |
| 746 | // |
| 747 | // The honest fix is not to scope some subset of the writes below — it |
| 748 | // is to refuse the entire operation before any of them run. doc is a |
| 749 | // value a caller can construct or mutate freely (as the regression |
| 750 | // test below does); it is not a database guarantee. Store.Get is |
| 751 | // reader-scoped, so doc.OwnerID is trustworthy *when doc came from |
| 752 | // Get* — but nothing at this call site enforces that it did, which is |
| 753 | // the entire defect. So rather than trust the struct, this re-derives |
| 754 | // ownership from the database itself: look up document doc.ID through |
| 755 | // the same owned(doc.OwnerID) seam every other query in this file |
| 756 | // uses, and require the row to exist. owned's owner <= 0 rule (see |
| 757 | // its own doc comment) still applies unchanged here: an orphaned |
| 758 | // document with owner_id 0 cannot be "owned" by any doc.OwnerID, |
| 759 | // including a tampered 0, so it can never pass this check and this |
| 760 | // route can never make an orphan writable. A mismatched or missing |
| 761 | // owner makes the Where match zero rows, First returns |
| 762 | // gorm.ErrRecordNotFound, and this method returns before replay is |
| 763 | // even called — no core touched, no row written. |
| 764 | // |
| 765 | // AN INVARIANT THIS METHOD OWNS, written down because something now |
| 766 | // depends on it: every write that changes a document's content bumps |
| 767 | // its updated_at. The document list's thumbnail cache (preview.go) is |
| 768 | // keyed on that column, so a content write that left it alone would |
| 769 | // serve a preview of the document as it used to be, with nothing |
| 770 | // anywhere raising a hand. Today this is the only content write, so |
| 771 | // the invariant holds by having one writer; the writes that will test |
| 772 | // it are version restore (spec §14) and any future log compaction. |
| 773 | // Note also that this bumps the COLUMN and not the *Document it was |
| 774 | // handed — a caller holding that struct has a stale UpdatedAt, which |
| 775 | // is why the cache takes its key from the row Store.List returned. |
| 776 | func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, update []byte) (int64, error) { |
| 777 | // Authorize the ACTOR, not the document. |
| 778 | // |
| 779 | // This check used to be owned(db, doc.OwnerID).Where("id = ?", doc.ID) — |
| 780 | // the document against its own owner, which is true of every document by |
| 781 | // construction. That was harmless only because the sole way to hold a |
| 782 | // *Document was to have fetched it through a scoped query, so this |
| 783 | // was a belt on top of the read scope's braces. Sharing removes that |
| 784 | // guarantee: a reader can now legitimately fetch a document, and the old |
| 785 | // check would have waved their write through, because it never asked |
| 786 | // anything about the person doing the writing. |
| 787 | // |
| 788 | // Reader and stranger are refused identically. The write path does not |
| 789 | // tell somebody which of the two they are, for the same reason §4.2 gives |
| 790 | // about reads. |
| 791 | if err := s.checkPermission(ctx, s.db.WithContext(ctx), actor, doc.ID, permissionWrite); err != nil { |
| 792 | return 0, err |
| 793 | } |
| 794 | |
| 795 | h, replayedSeq, lastSnapshotSeq, err := s.replay(ctx, doc) |
| 796 | if err != nil { |
| 797 | return 0, err |
| 798 | } |
| 799 | defer h.Close(ctx) |
| 800 | |
| 801 | // The snapshot is taken HERE — from the replayed state, before the |
| 802 | // incoming update is applied — and the placement is the whole of |
| 803 | // its correctness (the snapshots design, D3). |
| 804 | // |
| 805 | // Taking it after a successful Apply is wrong twice. The replay |
| 806 | // above runs outside the transaction below, so a concurrent POST |
| 807 | // can commit a row this handle never saw; a snapshot taken from |
| 808 | // that handle would claim a seq whose content it does not hold, |
| 809 | // and D5's verification cannot notice, because it compares the |
| 810 | // candidate against this same stale handle. And a document already |
| 811 | // at core.MaxRetainedUpdates never reaches an after-success hook |
| 812 | // at all — Apply refuses with ErrUpdateLogFull first — so the one |
| 813 | // case this feature exists for would stay broken forever. Written |
| 814 | // here, that document gets its snapshot, fails this one POST, and |
| 815 | // the client's own retry replays snapshot plus an empty tail and |
| 816 | // succeeds. |
| 817 | // |
| 818 | // The replayed prefix is complete by construction: seq is gapless |
| 819 | // (MAX+1 inside the single-writer transaction below) and replay |
| 820 | // read every row that existed when it ran. So replayedSeq is |
| 821 | // unconditionally truthful about what the handle holds. |
| 822 | if replayedSeq-lastSnapshotSeq >= snapshotCadence { |
| 823 | if err := s.writeSnapshot(ctx, doc, h, replayedSeq); err != nil { |
| 824 | if s.team != nil { |
| 825 | if errors.Is(err, ErrNotWritable) { |
| 826 | return 0, err |
| 827 | } |
| 828 | return 0, errTeamUnavailable |
| 829 | } |
| 830 | // Never fatal: the update below still stands, the log is |
| 831 | // untouched, and the next POST tries again — and so does |
| 832 | // every one after it, since the distance only grows until |
| 833 | // a write succeeds. Error rather than Warn because the |
| 834 | // headroom that keeps ErrUpdateLogFull unreachable is |
| 835 | // spent by roughly fifteen consecutive failures here (D4). |
| 836 | s.logger.Error("docs: write snapshot", "doc_id", doc.ID, "seq", replayedSeq, "err", err) |
| 837 | } |
| 838 | } |
| 839 | |
| 840 | if err := h.Apply(ctx, update); err != nil { |
| 841 | return 0, fmt.Errorf("docs: apply update to document %d: %w", doc.ID, &updateRejection{err}) |
| 842 | } |
| 843 | |
| 844 | // MAX(seq) then INSERT used to be two separate statements, not one |
| 845 | // atomic step: two concurrent POSTs to this same document (Task 6's |
| 846 | // flush timer and its beforeunload beacon are exactly the pair that |
| 847 | // races on a real page close) could both read the same MAX(seq) |
| 848 | // before either had committed its own INSERT, and both then write |
| 849 | // the same (doc_id, seq) pair. doc_updates' UNIQUE(doc_id, seq) |
| 850 | // index (migration 0003) is the backstop that would turn a second |
| 851 | // such bug into a loud error instead of silent duplicate data, but |
| 852 | // the actual fix is this transaction: rastrillo/db's pool (db.go's |
| 853 | // own doc comment) caps the WRITE pool at exactly one connection — |
| 854 | // SQLite allows one writer — and gorm.DB.Transaction always opens |
| 855 | // on that pool's underlying *sql.DB, never on the read replicas |
| 856 | // dbresolver would otherwise route a bare SELECT to. With only one |
| 857 | // connection available, a second concurrent call to this method |
| 858 | // cannot even begin its own transaction until this one commits and |
| 859 | // releases it — so by the time that second transaction's SELECT |
| 860 | // MAX(seq) runs, it is guaranteed to see this row already |
| 861 | // committed. That is a stronger guarantee than retrying on a |
| 862 | // UNIQUE-constraint collision (the migration's own alternative): |
| 863 | // a bounded retry loop can still be exhausted once enough requests |
| 864 | // race the same document at once, so a busy document would start |
| 865 | // returning errors under load that this queue-instead-of-fail |
| 866 | // approach never does — confirmed empirically: an earlier version |
| 867 | // of this fix used a 5-attempt retry loop, and |
| 868 | // updates_test.go's TestConcurrentUpdatesDoNotCollideOnSeq (21 |
| 869 | // concurrent POSTs to one document) genuinely exhausted it on a |
| 870 | // real run before this transaction replaced it. |
| 871 | // assignedSeq is captured out of the transaction closure so this |
| 872 | // method can return it. It is only ever read after Transaction |
| 873 | // returns nil, so it names a row that is committed and durable — |
| 874 | // which is exactly the contract this method's callers rely on |
| 875 | // (design D4: the seq is meaningful only when err == nil). Nothing |
| 876 | // about the single-writer MAX(seq)->INSERT guarantee above changes |
| 877 | // by observing the number the closure already computed. |
| 878 | var assignedSeq int64 |
| 879 | if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 880 | if err := s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite); err != nil { |
| 881 | return err |
| 882 | } |
| 883 | var maxSeq int64 |
| 884 | if err := tx.Model(&DocUpdate{}). |
| 885 | Where("doc_id = ?", doc.ID). |
| 886 | Select("COALESCE(MAX(seq), 0)"). |
| 887 | Scan(&maxSeq).Error; err != nil { |
| 888 | return fmt.Errorf("docs: determine next seq for document %d: %w", doc.ID, err) |
| 889 | } |
| 890 | |
| 891 | row := &DocUpdate{ |
| 892 | DocID: doc.ID, |
| 893 | Seq: maxSeq + 1, |
| 894 | UpdateBin: update, |
| 895 | CreatedAt: time.Now().UTC(), |
| 896 | } |
| 897 | if err := s.prepareContentUpdate(tx, doc.GUID, row); err != nil { |
| 898 | return err |
| 899 | } |
| 900 | if err := tx.Create(row).Error; err != nil { |
| 901 | return fmt.Errorf("docs: persist update for document %d: %w", doc.ID, err) |
| 902 | } |
| 903 | |
| 904 | if err := s.permissionScope(ctx, tx, actor, permissionWrite).Model(&Document{}).Where("documents.id = ?", doc.ID).Update("updated_at", time.Now().UTC()).Error; err != nil { |
| 905 | return s.permissionError(ctx, actor, err) |
| 906 | } |
| 907 | assignedSeq = row.Seq |
| 908 | return s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite) |
| 909 | }); err != nil { |
| 910 | return 0, err |
| 911 | } |
| 912 | |
| 913 | return assignedSeq, nil |
| 914 | } |
| 915 | |
| 916 | // Rename repeats write authorization within the transaction, as ApplyUpdate does. |
| 917 | func (s *Store) Rename(ctx context.Context, actor Actor, doc *Document, title string) error { |
| 918 | if err := s.checkPermission(ctx, s.db.WithContext(ctx), actor, doc.ID, permissionWrite); err != nil { |
| 919 | return err |
| 920 | } |
| 921 | return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 922 | if err := s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite); err != nil { |
| 923 | return err |
| 924 | } |
| 925 | if err := s.permissionScope(ctx, tx.Model(&Document{}), actor, permissionWrite).Where("documents.id = ? AND documents.deleted_at IS NULL", doc.ID).Updates(map[string]any{"title": title, "updated_at": time.Now().UTC()}).Error; err != nil { |
| 926 | return err |
| 927 | } |
| 928 | return s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite) |
| 929 | }) |
| 930 | } |
| 931 | |