oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
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.
23type 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.
69func 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.
78const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
79
80const 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.
95func 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.
112const 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.
158func 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.
187func (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.
257func (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."
281func (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.
287func (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.
362func (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).
381func (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.
444func (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.
595func (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.
616func (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.
644func (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.
667func (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.
697type updateRejection struct{ err error }
698
699func (e *updateRejection) Error() string { return e.err.Error() }
700func (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.
711func (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.
776func (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.
917func (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