Files
pad/internal/store/documents.go
T
xarmian 427540706c fix(documents): bound the rename cascade at the title and at the projected total (BUG-2798, BUG-2796) (#1218)
* fix(documents): bound the rename cascade at the title and at the projected total (BUG-2798, BUG-2796)

A document rename rewrites [[oldTitle]] into every linking document. Neither
factor of the output size was bounded: titles had no length validation, and
the cascade holds every rewritten body in memory before writing any of them.
One rename could project 10 GB from a 500 KB input -- 20,000x, measured -- and
OOM while holding the workspace rename lock.

Two walls, per Dave's day-63 ruling.

1. Title length, bounded at write time (models.MaxDocumentTitleRunes = 255).
   Runes, not bytes: "255 characters" is what a user and a UI counter mean.
   Existing over-limit titles stay valid until their next rename -- no
   retro-breakage of stored data.

2. The cascade's projected TOTAL, bounded at 16 MiB
   (store.MaxRenameCascadeProjectedBytes), accumulated across the linking set
   and refused before the first rewrite is built.

The total is the right quantity and a per-document cap would not have been.
Measured, with the title bound already in place: one linker holding the
largest body a 2 MiB request can carry projects 108,632,370 bytes -- 51.8x --
and the aggregate is linear in the number of linkers (108.6 / 217.3 /
434.5 MB at k = 1/2/4, allocation tracking output at ~1.02x). A per-document
cap of C still admits k * C, which is the same unbounded shape one level up.
The 16 MiB figure has a receipt in the constant's doc comment: it sits above
the absolute ceiling of any cascade this development instance could produce
(its entire wiki-linking corpus is 10,077,476 bytes) and 6.5x below the
single-document attack.

The refusal is permanent-shaped and deliberately NOT in
ErrLinkCascadeContention's family: 413 with the projection in the message and
no Retry-After. Contention means "someone got there first, try again"; this
means "this rename cannot be performed as asked". Answering it from the
retryable family would tell a client to retry forever.

BUG-2796 folds in at the same validation point, as ruled -- a title containing
wiki-link syntax is emitted raw by links.ReplaceTitle, so renaming to
`A]] [[A` produced two broken links and reported success. The rule is derived
from the two mechanisms that consume a stored bracket (the grammar at
markdown.ts:327 and the unescaper at markdown.ts:753) rather than from a
character blacklist: the first version of this fix banned `]`, `\` and `|`
because all three "look like wiki-link syntax", and the round-trip test
refuted two thirds of that. `|` in particular is a title shape resolveWikiBody
contains a dedicated branch to support, and `[` passes the grammar untouched.

Doors enumerated rather than assumed (CONVE-24): store.CreateDocument and
UpdateDocument have exactly two callers between them, both HTTP handlers. No
CLI, import, or seed path writes a document title. Update previously validated
doc_type and status and NOT title -- the one field that drives the cascade --
so the handler tests drive real requests through both doors (CONVE-19).

BUG-2798, BUG-2796

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): count retained bytes, bound the retry path, escape the cascade's LIKE pattern (BUG-2798)

Codex round 1 on #1218. Three findings, all real, all fixed here.

1. The guard bounded projected OUTPUT, which bounds nothing when the new title
   is SHORTER than the old one. Renaming a 255-character title to a
   one-character title makes each 2 MiB linker project ~40 KiB while the
   cascade still retains its 2 MiB read for the compare-and-set, so hundreds
   of linkers exhaust memory while the counter reports well under the cap.

   The counter now sums RETAINED bytes — read plus written, both alive at once
   — so the cap is a statement about resident memory rather than about output.
   MaxRenameCascadeProjectedBytes becomes MaxRenameCascadeRetainedBytes and
   moves 16 -> 32 MiB, because the legitimate ceiling it clears doubles under
   the new metric (that instance's whole wiki-linking corpus retains
   ~20,154,952 bytes); the single-document attack retains 110,729,522, so it
   is still refused by 3.3x.

2. The compare-and-set's retry path bypassed the guard entirely. On
   contention it re-reads the linker and calls ReplaceTitle on whatever the
   winner wrote — a NEW input, bounded by nothing the scan had checked — so a
   content edit landing inside the cascade's window could grow a linker from
   harmless to enormous and walk the rename back into the amplification it
   would have been refused for. Each document's compare-and-set now carries
   the cap less what the other linkers hold, and re-checks the grown body
   against it.

3. The cascade's `content LIKE ?` search term went in unescaped, so a document
   TITLE decided how the pattern was read. `\` is the default LIKE escape
   character on Postgres and NOT on SQLite, so `[[Alpha\Beta]]` was searched
   for as itself on one dialect and as `[[AlphaBeta]]` on the other: linkers
   not found, cascade rewrites nothing, rename reports success, every link
   left stale. Silent and dialect-dependent.

   Codex named the backslash; `%` and `_` are the rest of the class (CONVE-18)
   — wildcards on both dialects, so a title carrying them selects documents
   that do not link it. An explicit `ESCAPE '\'` clause plus escapeLikePattern
   makes both dialects agree, rather than leaving SQLite correct by accident.

Finding 3 also constrains finding 3 of the ORIGINAL fix: models' validator
allows a lone backslash in a title on the grounds that both renderers handle
it, which was true of rendering and false of cascading. That comment now
records the dependency — allowing it is only correct while the cascade's
pattern stays escaped.

Tests, four new, each mutation-verified against the code it guards:

- CountsRetainedBytesNotJustOutput — the shrinking rename. Asserts as a
  PRECONDITION that the projected-output total stays under the cap, so the
  test cannot pass for the old reason.
- RetryRecheckesTheBudgetAgainstTheGrownBody — drives the real race through
  the afterLinkCascadeRead seam. POSTGRES ONLY and skipped loudly elsewhere:
  SQLite's BEGIN IMMEDIATE closes the window structurally, so a green run
  there would be a property of the DSN.
- FindsLinkersWhoseTitleContainsABackslash — Postgres only, same reasoning
  inverted: SQLite is the dialect that was accidentally right.
- DoesNotSpendTheBudgetOnDocumentsThatDoNotLinkTheTitle — `%` and `_`. Its
  first version asserted the decoy's content was untouched and passed against
  the unescaped pattern, because over-matched rows rewrite to themselves. The
  observable harm is that they spend the caller's budget, so that is what it
  now asserts.

Mutation matrix for this round: output-only counter -> only the shrinking test
fails; retry check removed -> only the retry test fails (PG); LIKE unescaped ->
the budget legs fail on SQLite and the backslash test fails on PG.

Gates: `go test ./...` under Postgres 17 EXIT=0; SQLite packages EXIT=0;
gofmt clean; `make lint` 0 issues.

BUG-2798, BUG-2796

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): tighten the retry budget, stop charging no-op rewrites, order the typed check first (BUG-2798)

Codex round 3 on #1218, an edge-case angle over the new arithmetic and control
flow. Three findings fixed, one declined.

1. The retry budget credited back this document's own share, on the reasoning
   that the retry replaces it. It does not: the original read and rewritten
   bodies stay reachable through `updates` while the write loop runs, so the
   re-read and its rewrite are allocated ON TOP of them. The bound could be
   exceeded by up to one document's share while the arithmetic still reported
   it satisfied. The budget is now the genuine headroom, `cap - retained`.

2. A concurrent edit that REMOVES the link left a body with no occurrences,
   which cascadeRetainedBytes still charged twice — once for the read and once
   for a rewritten copy that does not exist, because strings.Replace returns
   its input unchanged when there is nothing to replace. That could refuse an
   otherwise valid rename for memory the cascade never allocates.

3. The handler classified this error by PROSE before testing it by identity.
   The UNIQUE-constraint arm matches a substring, and the refusal error embeds
   the caller's title verbatim, so renaming a document to a title containing
   the words "UNIQUE constraint" came back as a 409 name collision — advice to
   pick a different name, for a rename that was refused for size and would
   fail identically under any name. Typed sentinel now tested first.

DECLINED: unchecked int64 arithmetic in the projection. The multiplicands are
derived from the length of a string already resident in memory, so overflowing
int64 needs a single document body of roughly nine exabytes; and the
accumulator returns as soon as it passes the cap, so it cannot run away either.
Saturating arithmetic here would be guarding a state the machine cannot reach.

Tests, three new, each mutation-verified:

- RetryBudgetExcludesThisDocumentsOwnStrings — deliberately separate from the
  existing retry test, because that one catches the check being ABSENT and this
  one catches it being too GENEROUS. The grown body is sized to fall BETWEEN
  the two budgets; a body far over the cap cannot tell them apart.
- ConcurrentEditThatRemovesTheLinkDoesNotRefuseTheRename — its first version
  sized the link-free body against the CAP rather than against the retry's real
  headroom, so the refusal it caught was correct behaviour and the test was
  wrong, not the code. Re-sized against the headroom: fits when charged once,
  does not when charged twice.
- IsNotMisreportedAsATitleCollision — at the handler, since the defect is
  entirely in its classification order.

Mutation matrix for this round: credit the share back -> only the tight-budget
test fails; charge the no-op body twice -> only the link-removed test fails;
order the substring arm first -> only the misclassification test fails.

Gates: `go test ./...` under Postgres 17 EXIT=0; touched packages re-run after
the lint fix EXIT=0; gofmt clean; `make lint` 0 issues. CI green on b09aca12.

BUG-2798, BUG-2796

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* test(documents): close four ways the cascade-bound tests could pass for the wrong reason (BUG-2798)

Codex round 4, aimed at the TESTS rather than the code. No production behaviour
changes here; four instruments that were weaker than they read.

1. Every retained-byte case exceeded the cap under `max(read, rewritten)` as
   well as under `read + rewritten`, so none of them could tell the two
   arithmetics apart — and taking the larger would hold twice the cap. Added
   CountsBothStringsNotTheLargerOne: an ordinary same-length rename over
   content totalling ~60% of the cap, which the sum refuses and the max
   admits. Its preconditions assert both halves of that gap.

2. Nothing pinned the 255 itself. Every length case derived its inputs from
   MaxDocumentTitleRunes, so changing the constant to 512 left them all green.
   That is fine for arithmetic and wrong for this number: it is a product
   decision Dave ruled, and a silent change to it silently changes how much
   amplification the cheap door lets through. Deliberately NOT done for
   MaxRenameCascadeRetainedBytes, which is mine and carries a measured receipt
   that is expected to be re-measured.

3. The oversize tests asserted only THAT a rename is refused, never that it is
   refused BEFORE the amplified string is built — which is the entire point of
   the guard. Moving links.ReplaceTitle above it would have kept them green.
   Added RefusesBeforeBuildingTheRewrittenBody, measuring cumulative
   allocation with a ~20x margin: refusing costs the one body it had to scan,
   building first costs ~108 MB. Verified by mutation — with the guard moved
   after the rewrite it reports 110,748,144 bytes against a 52,428,800
   ceiling.

   This filing warned that measuring memory to prove the ABSENCE of
   amplification is flaky by construction. That still holds for the shape it
   described, a peak-RSS floor. This is the opposite: a generous ceiling on a
   deterministic counter, with the two outcomes twenty times apart.

4. The "reports the projection" assertions checked for the words `maximum` and
   `bytes`, which a message saying "maximum bytes exceeded" would satisfy
   while telling a caller nothing. They now require the cap's actual value and
   a real byte count.

Mutation matrix: charge only the larger string -> only CountsBothStrings fails;
move the rewrite above the guard -> only RefusesBeforeBuilding fails. Seventeen
mutations across four rounds, each detected by the test that should catch it.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): compose the 413 from typed fields instead of splicing err.Error() (BUG-2798)

Codex round 5, on the side effects of a REFUSED rename. Side effects were
otherwise clean — rollback removes versions, link rewrites, attachment stamps
and the title change, and no activity row, SSE event or webhook is emitted —
but the response body was built by appending err.Error() to a public sentence.

That published whatever any layer had wrapped around the error on its way up.
Today that is "update links: store: ", which is a call path clients have no
business seeing; tomorrow it is whatever the next wrapper adds, with no
decision point in between. The response is now composed from typed fields on a
new store.RenameCascadeTooLargeError (NewTitle, Retained, Max), reached with
errors.As. Unwrap keeps errors.Is(err, ErrRenameCascadeTooLarge) true, so every
existing sentinel check is unaffected.

Both FIGURES stay in the message, deliberately: Dave's day-63 ruling asked the
refusal to state what it would hold and what the cap is, so "split the rename"
is actionable advice rather than a shrug. The reviewer read those numbers as a
content-size oracle; that framing does not survive the trust boundary — a
rename requires `editor`, documents are readable at `viewer`, so the caller can
already read every document the figure summarises and learns nothing from it.
What they had no business receiving was the internal call path, and that is
what changed.

This is round 3's lesson applied in the other direction. There, prose was being
used to CLASSIFY an error and should have been identity. Here, prose was being
used to REPORT one and should have been data.

The test now asserts both halves: the real byte counts are present (not merely
the word "maximum"), and the strings "update links:" and "store:" are ABSENT.
Mutation-verified — splicing err.Error() back in fails on both leaked prefixes.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* docs(documents): correct eight claims the prose made that the code does not (BUG-2798)

Codex round 6, aimed at the comments rather than the code. Eight findings, all
mine, all real, no behaviour changed. This is the failure mode my own trail
keeps naming — code right, prose broader than the sweep, always in the same
direction — so they are corrected individually rather than smoothed over.

Stale after the round-1 rename:

- models cited store.MaxRenameCascadeProjectedBytes, which no longer exists.
- the constant's own hostile figure read 110,729,522; it is 110,729,520.
- the HTTP test said each body is ~135 KB; the formula produces 264,790 bytes.

Claims wider than what is true:

- The round-trip test's header stated a biconditional over the whole
  validator. False: a 300-rune title round-trips perfectly and is still
  refused, for the unrelated reason that it is an amplification factor. The
  property is about the SYNTAX rule, over titles inside the length bound, and
  now says so.
- The mirrored grammar/unescaper comment claimed that a TypeScript change
  would make this test start disagreeing. It cannot — they are static copies,
  and nothing in the repository fails when the two drift. Replaced with what
  the duplication actually buys and what it does not.
- The cap's receipt used `items` measurements to conclude the guard "cannot
  fire on honest use" for DOCUMENTS, having itself noted that instance's
  documents table is empty. The proxy is reasonable and it is an assumption,
  not a measurement of the guarded path; the inference is now named, with the
  narrower claim that survives without it.
- The 413's comment cited the image_too_large precedent as a bound on OUTPUT
  while this guard bounds retained read-plus-write. What carries across is the
  shape — a small request refused for what handling it would cost — not the
  quantity.
- TestRenameCascade_RefusesTheSingleDocumentAttack described the 2 MiB /
  110,729,520-byte shape it does not build; it sizes from the cap
  (1,271,000 bytes retaining 67,108,800). The full-strength shape is exercised
  by the allocation test, and the comment now points there instead of
  describing a body that is not in the function.

One figure was replaced by measurement rather than corrected by arithmetic:
the allocation test claimed a "~20x margin". Both sides are now measured and
stated separately — refusing allocates 2,114,624 bytes, ~24.8x under the
52,428,800 ceiling; building the rewrite first allocates 110,748,144, ~2.1x
OVER it, taken from running the test against the mutation rather than
computed. The smaller margin is the binding one, since it is the gap a
regression must cross to be caught, and saying "~20x" hid that.

Gates: `go test` on the three touched packages under Postgres 17 EXIT=0;
gofmt clean; `make lint` 0 issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): stop charging the cascade budget for rows the rewriter cannot touch (BUG-2798)

Codex round 7. Third instance of one class, and the one I stopped short of when
I extended the previous two.

The SELECT that finds candidate linkers is a LIKE; the thing that rewrites them
is links.ReplaceTitle. They do not agree on what counts as a match, so the
SELECT returns a SUPERSET — and every row in the difference was charged to the
caller's retained-byte budget and then handed a no-op UPDATE.

Instances one and two were `%` and `_`, wildcards on both dialects, closed by
the ESCAPE clause. This is the case half, and it splits the OTHER way from the
backslash bug: SQLite's LIKE is ASCII case-insensitive by default while
Postgres's is case-sensitive, so renaming `Alpha` scans every body holding
`[[alpha]]` on SQLite only. ReplaceTitle is case-sensitive on both and will
never touch them, so enough case-variant content could push an otherwise valid
rename to a 413 — on one dialect, for content that was never in scope.

The fix is to skip a row with no case-sensitive occurrence outright, which
closes both halves: no budget is spent, and no pointless UPDATE is issued for a
body the cascade was never going to change. The authority on what is a linker
is the rewriter's own count, not the pattern that proposed the candidate.

The test runs on BOTH dialects deliberately, unlike its Postgres-only backslash
sibling: on Postgres it asserts the behaviour was already correct, which is
what makes it a regression test rather than a SQLite quirk shim. It also
asserts the case variants are left byte-identical — `[[alpha]]` is a different
link, not a missed one. Mutation-verified: restoring the charge fails it with
33,554,790 bytes against the 33,554,432 cap.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues. An earlier attempt at that gate died on host disk exhaustion, not on
this diff — 373 stale go-tmp directories from crashed runs, cleared, re-run
green.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): report the whole operation's size when a retry is refused (BUG-2798)

Codex round 8, on concurrency and the rest of the rename transaction. The
advisory-lock lifetime, the CAS loop's termination, the ordering against
attachment stamps and version writes, and the new skip's effect on the
transaction's invariants all came back clean. One P2 stood.

The retry path bounded correctly and REPORTED wrongly. It compared the re-read
body against the headroom — right — and then named only that body in the
error. Everything the scan counted is still held, so the operation's real size
is the scan total plus the re-read, and a refusal could therefore say it would
hold 16,777,200 bytes against a limit of 33,554,432: a refusal whose own
figures do not justify it, which reads as a server bug rather than as advice
you can act on.

The compare-and-set is now handed the scan TOTAL instead of a pre-computed
budget, so the same number both bounds and explains: refuse when
scanTotal + grown exceeds the cap, and report scanTotal + grown.

The test now asserts the refusal justifies itself — the figure reported must
exceed the cap it cites — via the typed error added in round 5, which is what
makes that property checkable at all rather than a string comparison.
Mutation-verified: reporting the re-read alone fails it with exactly the
16,777,200-against-33,554,432 shape.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): refuse titles whose links the cascade cannot find; count retry buffers (BUG-2798)

Codex round 9, asking what is MISSING rather than what is wrong. Three
findings: two fixed, one already filed.

## Titles validated against the wrong layer

The validator ACCEPTED `Alpha|Beta` and `Alpha\Beta`, and I defended that
choice with a test. Both round-trip perfectly — the renderer reads each back
as exactly the title it started from — and the first version of this fix banned
them on vibes, so being shown that was a real correction.

It was still the wrong call, for a reason that test could not see. A link to
such a title can be STORED escaped, as `[[Alpha\|Beta]]`, and the rename
cascade searches for the raw `[[Alpha|Beta]]` only. It does not find those
links, so the rename succeeds and leaves them pointing at a title that no
longer exists — silently, which is BUG-2796's defect wearing different
syntax.

So the property a title has to satisfy is stricter than the one I tested: not
"the renderer reads it back", but "the renderer reads it back AND the cascade
can find its links". Validating against the layer that DISPLAYS a title while
the layer that MAINTAINS it disagrees is the same mistake as the unescaped
LIKE, met from the other side — twice in one unit, which is the part worth
noticing.

`[` stays accepted: the cascade's search term matches it literally, so it
passes the stricter property too.

The two characters get their own test rather than a row in the round-trip
table, because the table asserts a biconditional and these are refused for a
reason that predicate deliberately does not model. That test asserts its own
premise — each title must still round-trip — so if that ever stops being true
it fails rather than passing for a new reason.

## Retry buffers were not counted

rewriteLinkerCAS bounded each retry against the scan total plus THAT attempt.
Earlier attempts' buffers become unreachable when expected/next are reassigned,
but unreachable is not reclaimed, so a run of failures could hold several
copies while the arithmetic counted one. Now accumulated across attempts, which
is conservative — it counts garbage as if live — and errs toward refusing,
which is the safe direction for a memory bound. The loop is capped at
cascadeRewriteAttempts, so it cannot grow without end.

## Already filed

Item renames remaining unbounded, and item titles lacking this validation, are
real and out of this unit's scope: BUG-2804 and BUG-2805, filed after round 2
with the code verified rather than taken on report.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798, BUG-2796

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): validate a title only when the rename actually changes it (BUG-2798)

Codex round 10, on back-compatibility. This one is a regression THIS fix
introduced, not one it inherited, and it falsified a promise the fix makes
about itself in three places.

"Enforced at write time; existing titles stay valid until their next rename"
is Dave's ruling, and it is repeated in the constant's doc comment and in two
commit messages. Validating every SUPPLIED title broke it for the most
ordinary shape of an edit there is: a client that PATCHes the whole object,
title included, to change the content. Under that, a document with a legacy
title became uneditable rather than merely un-renameable — the opposite of
grandfathering.

Grandfathering is not something you get by validating at write time. It is
something you get by not validating a write that is not a rename. The check
now fires only when the supplied title DIFFERS from the stored one, which is
also exactly the test the store already applies before cascading, so the
validation and the work it guards now agree on what counts as a rename.

The regression test seeds its legacy document through the store, because the
title it needs can no longer be created through the API — which is precisely
the population the grandfathering clause exists for. Three legs: the
echoed-title content edit succeeds, a title-less content PATCH succeeds, and
renaming to another invalid title is still refused. The last is the control;
without it, deleting the validation entirely would pass.

Filed rather than folded: BUG-2806, existing documents whose links are stored
in escaped form are still orphaned by a rename. Round 9 stopped NEW titles of
that shape being created; it did not repair the ones already stored, and the
asymmetry — the product refusing to create a shape it still mishandles —
belongs in the record rather than in this PR, which is nine commits deep on a
bound it has already outgrown.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* fix(documents): validate the rename under the lock, not against a pre-lock read (BUG-2798)

Codex round 11. Round 10 moved title validation behind "only when the title
actually changes", which is the right rule and was applied at the wrong place.

The handler compares the supplied title against a document it read BEFORE the
rename lock. UpdateDocument re-reads under the lock. Those can disagree: echo a
legacy title back on a content edit while another request renames the document,
and the handler sees "unchanged, skip validation" while the store sees a
genuine rename — and writes the legacy title through with nothing having
checked it.

The rule is unchanged; the enforcement point moved to where the rename is
actually decided. The store validates inside the transaction, on the same
branch that triggers the cascade, and returns a typed
InvalidDocumentTitleError carrying the reason. The handler keeps its pre-lock
check, which is still worth having — it gives the common case a fast 400
without opening a transaction — and gains an arm that surfaces the store's
refusal for the case its own check could not see.

Grandfathering survives intact, because the store's check sits on the
title-actually-changed branch, which is the same condition the handler uses.

The test calls the store directly rather than reproducing the race: driving the
interleaving would test the scheduler, while the property worth pinning is that
the store refuses regardless of what a caller did. Its control leg is the
grandfathering case — a content edit echoing the unchanged legacy title must
still succeed — so validating everything here would fail it, which is round
10's regression restated as a guard.

Mutation-verified: removing the store-side check leaves the handler tests green
and fails this one with a nil error.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* perf(documents): stop counting every linker's occurrences twice (BUG-2798)

Codex round 13, on what the guard costs the SUCCESS path rather than what it
blocks on the failure path.

The cascade counts occurrences because the size guard needs that number before
it is willing to build anything. It then handed the same content to
links.ReplaceTitle, whose strings.Replace with n < 0 counts it again. Every
ordinary rename therefore paid a second full pass over every linking document
for a number it already had.

links.ReplaceTitleN takes the count the caller already computed. It is a
separate function rather than an optional parameter because the obligation is
real and silent when broken: passing a number that is too small does not
error, it leaves later occurrences unrewritten, which on this path means links
left pointing at a title that no longer exists. A name at the call site is
cheaper than a comment nobody reads.

NO measured speedup is claimed, and the doc comment says so. This removes one
linear pass from a path that also allocates a full copy of the same content and
issues a write per linker, so the saving is real but not obviously
significant. It is here because doing the same work twice needs a reason and
there was not one — not because a benchmark asked for it.

The test asserts equivalence with ReplaceTitle across several shapes, including
the new-title-embeds-old case, and its counterfactual leg asserts that an
under-count visibly DIVERGES — if it did not, the caller's obligation would be
imaginary and the API misleading.

Round 13 also confirmed two things worth recording: no quadratic scan across
linkers, and the ESCAPE clause does not materially change the query plan
because the leading `%` already forced a content scan.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* docs(store): record the consequence the cascade cap necessarily has (BUG-2798)

Codex round 14, on authorization and abuse. The refusal paths came back
authorization-clean: reachable only after the workspace-access and `editor`
checks, with viewers, guests, non-members and cross-workspace document IDs
stopped first, and the reported byte figure exposing nothing an editor cannot
already read.

One consequence stands, and it is a property of having a cap at all rather
than a defect in this one: once a workspace's documents linking a title exceed
32 MiB, that title can no longer be renamed, and any editor can put it in that
state.

Recorded in the constant's doc comment rather than fixed here, because the
comparison that matters is with what it replaces. The same input previously
took the server down for everyone; it now denies one operation to a role that
can already delete every document in the workspace. Trading an unbounded OOM
for a bounded, legible refusal is the point of the guard, not a gap in it.

What IS missing is that the state has no exit but manual cleanup, with nothing
telling an operator which documents to clean. That is a quota-and-recovery
question rather than a cascade question, and it is filed as IDEA-2807 with
three candidate shapes and an argument for the cheapest one — a state you can
get out of is a different severity from one you cannot. The filing also says
what is NOT established: no real workspace is known to approach the cap, and
this fix's own receipt suggests none does, so it is a trap that exists rather
than one anyone has fallen into.

The adjacent concern the review raised — repeated near-cap renames contending
for the rename lock and the connection pool — is noted there too, with the
observation that the pre-existing behaviour was strictly worse, since each
attempt was unbounded.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN

* revert(links): remove ReplaceTitleN — it optimised nothing (BUG-2798)

Codex round 15 caught a claim of mine that was simply false. Reverting the
functional half of c00606b0.

I added ReplaceTitleN so the cascade could hand strings.Replace the occurrence
count it had already computed, and wrote that this "removes one linear pass
from a path that also allocates a full copy". It does not. strings.Replace
calls Count UNCONDITIONALLY, before it looks at n:

	func Replace(s, old, new string, n int) string {
		if old == new || n == 0 {
			return s
		}
		// Compute number of replacements.
		if m := Count(s, old); m == 0 {
			return s
		} else if n < 0 || m < n {
			n = m
		}

Read from this machine's GOROOT this turn, rather than recalled. Passing n
constrains how many replacements are APPLIED; it does not skip the count.

So the function bought nothing and cost something: a second way to do the same
thing, carrying an obligation that fails SILENTLY when broken — an under-count
leaves later occurrences unrewritten, which on this path means links pointing
at a title that no longer exists. API surface with a silent failure mode and no
payoff is worse than no API, so it goes rather than getting a corrected
comment.

The failure is the one my own trail keeps naming: I asserted a mechanism
without reading it. What makes this instance worse than the earlier ones is
that I wrote a careful hedge — "no measured speedup is claimed" — which reads
as rigour while the sentence beside it stated the mechanism as fact. Declining
to measure a claim is not the same as checking it, and the hedge made the
unchecked claim look examined.

The occurrence count stays where it is: the guard genuinely needs it before it
will build anything, and computing it there is not redundant with anything the
guard can avoid.

Also declined this round, as already filed: renaming a legacy title with `|`,
`\` or `]` leaves escaped links stale — that is BUG-2806, filed at round 10
with the mechanism verified in code.

Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0
issues.

BUG-2798

Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN
2026-08-27 22:45:27 -04:00

1169 lines
49 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/PerpetualSoftware/pad/internal/diff"
"github.com/PerpetualSoftware/pad/internal/links"
"github.com/PerpetualSoftware/pad/internal/models"
)
func (s *Store) ListDocuments(workspaceID string, params models.DocumentListParams) ([]models.Document, error) {
query := `
SELECT id, workspace_id, title, slug, content, doc_type, status, tags,
pinned, sort_order, created_by, last_modified_by, source,
created_at, updated_at
FROM documents
WHERE workspace_id = ? AND deleted_at IS NULL
`
args := []interface{}{workspaceID}
if params.Type != "" {
query += " AND doc_type = ?"
args = append(args, params.Type)
}
if params.Status != "" {
query += " AND status = ?"
args = append(args, params.Status)
}
if params.Tag != "" {
tagExpr, tagArg := s.dialect.JSONArrayContains("tags", params.Tag)
query += " AND " + tagExpr
args = append(args, tagArg)
}
if params.Pinned != nil {
if *params.Pinned {
query += " AND pinned = TRUE"
} else {
query += " AND pinned = FALSE"
}
}
// Whitespace-only queries collapse to empty after FTS sanitization, and
// SQLite FTS5 errors on `MATCH ''`. Treat them as "no search filter" to
// match the !="" semantics callers expect. See BUG-818.
hasSearch := strings.TrimSpace(params.Query) != ""
if hasSearch {
// Use FTS for search
if s.dialect.Driver() == DriverSQLite {
ftsMatch := s.dialect.FTSMatch("documents_fts", "search_vector")
query = fmt.Sprintf(`
SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags,
d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source,
d.created_at, d.updated_at
FROM documents d
JOIN documents_fts fts ON d.rowid = fts.rowid
WHERE d.workspace_id = ? AND d.deleted_at IS NULL
AND %s
`, ftsMatch)
// Sanitize so FTS5 specials (hyphens, AND/OR/NOT, parens) are
// treated as literals rather than boolean operators — see BUG-818.
args = []interface{}{workspaceID, sanitizeFTSQuery(params.Query)}
} else {
// PostgreSQL: search_vector lives on the documents table (aliased as "d").
// PG FTSMatch consumes TWO args (raw + hyphen-sanitized) for the
// OR-combined plainto_tsquery — see dialect.go and BUG-842.
ftsMatch := s.dialect.FTSMatch("d", "search_vector")
query = fmt.Sprintf(`
SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags,
d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source,
d.created_at, d.updated_at
FROM documents d
WHERE d.workspace_id = ? AND d.deleted_at IS NULL
AND %s
`, ftsMatch)
args = []interface{}{workspaceID, params.Query, sanitizePGFTSQuery(params.Query)}
}
if params.Type != "" {
query += " AND d.doc_type = ?"
args = append(args, params.Type)
}
if params.Status != "" {
query += " AND d.status = ?"
args = append(args, params.Status)
}
// Tag and Pinned filters were silently dropped by the FTS branch
// before this fix — see BUG-820 (documents analog of BUG-812).
if params.Tag != "" {
tagExpr, tagArg := s.dialect.JSONArrayContains("d.tags", params.Tag)
query += " AND " + tagExpr
args = append(args, tagArg)
}
if params.Pinned != nil {
if *params.Pinned {
query += " AND d.pinned = TRUE"
} else {
query += " AND d.pinned = FALSE"
}
}
}
// Sort
sortCol := "updated_at"
if params.Sort != "" {
switch params.Sort {
case "title":
sortCol = "title"
case "created_at":
sortCol = "created_at"
case "updated_at":
sortCol = "updated_at"
case "sort_order":
sortCol = "sort_order"
}
}
order := "DESC"
if params.Order == "asc" {
order = "ASC"
}
if hasSearch {
if s.dialect.Driver() == DriverPostgres {
// PostgreSQL ts_rank(): higher = more relevant → DESC.
// PG FTSRank consumes TWO args (raw + sanitized) — BUG-842.
ftsRank := s.dialect.FTSRank("d", "search_vector")
query += fmt.Sprintf(" ORDER BY %s DESC, d.%s %s", ftsRank, sortCol, order)
args = append(args, params.Query, sanitizePGFTSQuery(params.Query))
} else {
// SQLite FTS5: rank is a hidden column on the FTS JOIN (ascending = better)
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
}
} else {
query += fmt.Sprintf(" ORDER BY pinned DESC, %s %s", sortCol, order)
}
rows, err := s.db.Query(s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("list documents: %w", err)
}
defer rows.Close()
return scanDocuments(rows)
}
func (s *Store) CreateDocument(workspaceID string, input models.DocumentCreate) (*models.Document, error) {
id := newID()
ts := now()
docType := input.DocType
if docType == "" {
docType = "notes"
}
status := input.Status
if status == "" {
status = "draft"
}
tags := input.Tags
if tags == "" {
tags = "[]"
}
createdBy := input.CreatedBy
if createdBy == "" {
createdBy = "user"
}
source := input.Source
if source == "" {
source = "web"
}
baseSlug := slugify(input.Title)
if baseSlug == "" {
baseSlug = "untitled"
}
slug, err := s.uniqueSlug("documents", "workspace_id", workspaceID, baseSlug)
if err != nil {
return nil, err
}
// Transactional so the attachment-reference stamp commits atomically with
// the content that carries the reference (BUG-2415's protocol, extended to
// documents by BUG-2614). Without the transaction the stamp and the insert
// could not serialize against a concurrent orphan-GC claim, which is the
// entire point of stamping.
tx, err := s.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin create document: %w", err)
}
defer tx.Rollback()
// BEFORE the insert, per stampAttachmentRefsTx's ORDERING note: on
// Postgres the stamp row-locks the attachment rows for the rest of the
// transaction, so a concurrent claim blocks and then re-evaluates against
// the fresh stamp. Stamping after the write would leave a gap.
if err := stampAttachmentRefsTx(tx, s, workspaceID, input.Content); err != nil {
return nil, err
}
_, err = tx.Exec(s.q(`
INSERT INTO documents (id, workspace_id, title, slug, content, doc_type, status, tags,
pinned, sort_order, created_by, last_modified_by, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
`), id, workspaceID, input.Title, slug, input.Content, docType, status, tags,
s.dialect.BoolToInt(input.Pinned), createdBy, createdBy, source, ts, ts)
if err != nil {
return nil, fmt.Errorf("insert document: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create document: %w", err)
}
return s.GetDocument(id)
}
func (s *Store) GetDocument(id string) (*models.Document, error) {
return s.getDocumentQ(s.db, id)
}
// getDocumentTx reads a document through an OPEN TRANSACTION, so the caller
// sees the row as its own transaction sees it and needs no second pool
// connection while the first is held (BUG-2778). Same SELECT and hydration
// as GetDocument.
//
// Deliberately NOT `FOR UPDATE`. An earlier version locked the row here to
// stop a concurrent soft-delete committing before the write at the end of the
// rename — but the rows-affected guard on that write already makes the
// outcome atomic (the whole transaction, cascade included, rolls back), so
// the lock changed only WHICH writer wins, not whether the result is
// consistent. A mutation removing it survived the suite, which is the honest
// signal that it was a second mechanism for a window one mechanism already
// covers; the remaining guard has its own justification and its own test.
func (s *Store) getDocumentTx(tx *sql.Tx, id string) (*models.Document, error) {
return s.getDocumentQ(tx, id)
}
// getDocumentQ is the one document-row read behind GetDocument and
// getDocumentTx, differing only in executor.
func (s *Store) getDocumentQ(q rowQueryer, id string) (*models.Document, error) {
var d models.Document
var createdAt, updatedAt string
var deletedAt *string
var pinned bool
err := q.QueryRow(s.q(`
SELECT id, workspace_id, title, slug, content, doc_type, status, tags,
pinned, sort_order, created_by, last_modified_by, source,
created_at, updated_at, deleted_at
FROM documents
WHERE id = ? AND deleted_at IS NULL
`), id).Scan(
&d.ID, &d.WorkspaceID, &d.Title, &d.Slug, &d.Content, &d.DocType, &d.Status, &d.Tags,
&pinned, &d.SortOrder, &d.CreatedBy, &d.LastModifiedBy, &d.Source,
&createdAt, &updatedAt, &deletedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
d.Pinned = pinned
d.CreatedAt = parseTime(createdAt)
d.UpdatedAt = parseTime(updatedAt)
d.DeletedAt = parseTimePtr(deletedAt)
return &d, nil
}
func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.Document, error) {
existing, err := s.GetDocument(id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, nil
}
// Test seam (BUG-2778): the read above is stale by exactly this window,
// which is why the rename decision and the cascade's old title come from
// the re-read below. Nil in production.
if s.afterDocumentPreLockRead != nil {
s.afterDocumentPreLockRead(id)
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// BUG-2778: serialize TITLE RENAMES per workspace on Postgres, before
// this transaction takes any row lock.
//
// A rename locks rows in two stages: updateLinksInTx writes every OTHER
// document whose content links the old title, and the UPDATE at the end
// of this function writes THIS document. Two concurrent renames of
// documents that link to each other therefore take the same two row locks
// in opposite orders — tx1 locks B then wants A, tx2 locks A then wants B
// — and Postgres aborts one with SQLSTATE 40P01. Measured, not argued:
// before this lock, a probe that renamed two mutually-linking documents
// concurrently deadlocked on 12 of 12 rounds.
//
// WHY NOT `ORDER BY id` ON THE CASCADE, which is what BUG-2778 proposed
// when it was filed from reading rather than from a repro: the cycle does
// not come from the ORDER of the cascade's own rows. Each transaction's
// cascade set here is a single row, and the cycle is cascade-then-self.
// Ordering the cascade leaves it exactly as reachable; only a rule that
// covers BOTH stages removes it.
//
// A workspace-scoped advisory lock rather than a sorted lock batch,
// because the two stages touch different tables and different row sets
// (versions, the slug-uniqueness scan) and a sorted batch would have to
// predict all of them. It is the same instrument BUG-2074 uses for
// parent-edge writes, on its own namespaced key so renames contend only
// with renames. No-op on SQLite, whose single writer cannot produce the
// cycle at all (the probe found zero deadlocks there).
//
// It fires whenever a title is SUPPLIED, not only when the supplied title
// differs from what we read before the lock — deciding that from the
// pre-lock value is precisely the staleness this block exists to remove,
// and a same-title PATCH is a cheap uncontended lock acquisition.
if input.Title != nil {
if err := s.acquireWorkspaceDocumentRenameLock(tx, existing.WorkspaceID); err != nil {
return nil, err
}
// Re-read UNDER the lock and decide from that row, not from the
// pre-transaction read above (codex round 1). `existing` was loaded
// before this transaction and before this lock, so a rename that
// committed in between is invisible to it — and every decision below
// is made from it: whether to cascade at all, and which OLD TITLE the
// cascade rewrites. Two concurrent renames of the SAME document could
// therefore leave backlinks pointing at a title nothing carries any
// more, or skip the cascade entirely because the stale row's title
// happens to equal the requested one. This is the same defect family
// as BUG-2776 one layer down: a decision made from a snapshot taken
// before the lock that protects it.
//
// The lock is taken whenever a title is SUPPLIED rather than only
// when it differs from the stale read — deciding that from the stale
// value is exactly what this block exists to stop.
fresh, ferr := s.getDocumentTx(tx, id)
if ferr != nil {
return nil, fmt.Errorf("re-read document under rename lock: %w", ferr)
}
if fresh == nil {
// Deleted between the pre-tx read and the lock; the caller's 404
// path handles a nil document.
return nil, nil
}
existing = fresh
}
// Stamp the incoming content's attachment references first (BUG-2614,
// same protocol and ordering as items and comments). Only when content is
// actually being written — a metadata-only PATCH neither adds nor keeps a
// reference, and stamping on one would refresh rows this write has no
// opinion about.
if input.Content != nil {
if err := stampAttachmentRefsTx(tx, s, existing.WorkspaceID, *input.Content); err != nil {
return nil, err
}
}
ts := now()
// Create version if content is changing (throttled to avoid bloat from auto-save)
if input.Content != nil && *input.Content != existing.Content {
createdBy := input.LastModifiedBy
if createdBy == "" {
createdBy = "user"
}
source := input.Source
if source == "" {
source = "web"
}
// Title changes always get a version; content-only changes are throttled
forceVersion := input.Title != nil && *input.Title != existing.Title
shouldVersion := forceVersion
if !shouldVersion {
// Through the transaction, not the pool (BUG-2778): this runs
// with the transaction open and, for a rename, with the rename
// lock held.
shouldVersion, err = s.shouldCreateVersionQ(tx, id, createdBy, source)
if err != nil {
return nil, fmt.Errorf("check version throttle: %w", err)
}
}
if shouldVersion {
vid := newID()
// Store a reverse diff (patch from new → old) instead of full content.
// Falls back to full content if the diff isn't meaningfully smaller.
versionContent := existing.Content
isDiff := false
patch := diff.CreateReversePatch(existing.Content, *input.Content)
if diff.IsDiffSmaller(patch, existing.Content) {
versionContent = patch
isDiff = true
}
_, err = tx.Exec(s.q(`
INSERT INTO versions (id, document_id, content, change_summary, created_by, source, is_diff, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), vid, id, versionContent, input.ChangeSummary, createdBy, source, s.dialect.BoolToInt(isDiff), ts)
if err != nil {
return nil, fmt.Errorf("create version: %w", err)
}
}
}
// Update [[link]] references if title is changing.
//
// KNOWN GAP, filed as BUG-2629, deliberately not fixed here: this cascade
// rewrites OTHER documents' bodies without stamping the attachment
// references in the text it writes, and wiki_links.go::cascadeTitleRename
// does the same on the items side. Uniform across both surfaces, so fixing
// only this one would leave the larger hole open. It is also the weakest
// member of that family — the cascade rewrites link text in content whose
// references were already stamped when written and are still visible to
// the scan, so a NEW reference requires a title that literally contains a
// `pad-attachment:` token.
if input.Title != nil && *input.Title != existing.Title {
// Validated HERE, not only at the handler, because here is where the
// rename is decided — under the lock, against the title this
// transaction re-read (codex round 11).
//
// The handler's check compares against a document it read BEFORE the
// lock. That is fine for giving a caller a fast, friendly 400, but it
// is a time-of-check that a concurrent rename can invalidate: echo a
// legacy title back while another request renames the document, and
// the handler sees "unchanged, skip validation" while this branch sees
// a genuine rename and would write the legacy title through. Same
// grandfathering rule, applied where the decision actually happens.
if msg := models.ValidateDocumentTitle(*input.Title); msg != "" {
return nil, &InvalidDocumentTitleError{Reason: msg}
}
err = s.updateLinksInTx(tx, existing.WorkspaceID, existing.Title, *input.Title)
if err != nil {
return nil, fmt.Errorf("update links: %w", err)
}
}
// Build update query
sets := []string{"updated_at = ?"}
args := []interface{}{ts}
if input.Title != nil {
sets = append(sets, "title = ?")
args = append(args, *input.Title)
// Update slug too, ensuring uniqueness within workspace
baseSlug := slugify(*input.Title)
if baseSlug == "" {
baseSlug = "untitled"
}
newSlug, err := s.uniqueSlugExcluding(tx, "documents", "workspace_id", existing.WorkspaceID, baseSlug, id)
if err != nil {
return nil, fmt.Errorf("unique slug: %w", err)
}
sets = append(sets, "slug = ?")
args = append(args, newSlug)
}
if input.Content != nil {
sets = append(sets, "content = ?")
args = append(args, *input.Content)
}
if input.DocType != nil {
sets = append(sets, "doc_type = ?")
args = append(args, *input.DocType)
}
if input.Status != nil {
sets = append(sets, "status = ?")
args = append(args, *input.Status)
}
if input.Tags != nil {
sets = append(sets, "tags = ?")
args = append(args, *input.Tags)
}
if input.Pinned != nil {
sets = append(sets, "pinned = ?")
args = append(args, s.dialect.BoolToInt(*input.Pinned))
}
if input.SortOrder != nil {
sets = append(sets, "sort_order = ?")
args = append(args, *input.SortOrder)
}
if input.LastModifiedBy != "" {
sets = append(sets, "last_modified_by = ?")
args = append(args, input.LastModifiedBy)
}
if input.Source != "" {
sets = append(sets, "source = ?")
args = append(args, input.Source)
}
if s.afterDocumentPreWrite != nil {
s.afterDocumentPreWrite(id)
}
args = append(args, id)
// deleted_at IS NULL, and the row count is CHECKED (BUG-2778): without
// it, a document soft-deleted since this transaction began is written
// anyway — and on the rename path the backlink cascade above has already
// rewritten every linker, so the caller is told not-found (GetDocument
// filters archived rows) while those rewrites stay behind. Returning
// early here leaves the deferred Rollback to undo the cascade with it, so
// the rename and its cascade land together or not at all.
//
// This is the ONLY thing standing between a concurrent soft-delete and a
// write to the archived row — on every path, rename or not. A
// content-only PATCH takes no rename lock and holds no row lock until
// this statement, so the delete can land at any point before it.
query := fmt.Sprintf("UPDATE documents SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(sets, ", "))
res, err := tx.Exec(s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("update document: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("update document: rows affected: %w", err)
}
if affected == 0 {
return nil, nil
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetDocument(id)
}
// acquireWorkspaceDocumentRenameLock serializes document TITLE RENAMES within
// a workspace on Postgres (BUG-2778). Its key is namespaced so it contends
// only with other renames — reusing acquireWorkspaceSeqLock's bare workspace
// key would have made every rename wait behind every item-number and seq
// write in the workspace, and vice versa, for no benefit: the cycle this
// closes is rename-against-rename (codex round 2). Same shape and no-op
// dialect gate as acquireWorkspaceParentLinkLock.
func (s *Store) acquireWorkspaceDocumentRenameLock(tx *sql.Tx, workspaceID string) error {
if s.dialect.Driver() != DriverPostgres {
return nil
}
// BOUNDED WAIT (codex round 5). The transaction has already taken a pool
// connection by the time it waits here, so an unbounded wait converts
// lock contention into pool exhaustion: a burst of renames in ONE
// workspace can pin connections and stall unrelated work, and a client
// disconnecting does not cancel the wait (the HTTP context is not
// threaded into this call).
//
// WHAT THE 5s DOES AND DOES NOT COVER, because a bound with no receipt
// invites more trust than it earns: it bounds each LOCK WAIT inside this
// transaction once the connection is already held. It does NOT bound the
// transaction's total duration, and it does NOT bound the wait for a pool
// connection BEFORE Begin() — a saturated pool still queues callers ahead
// of this code. The number is a safety cap chosen to be far above any
// plausible uncontended acquisition, NOT a tuned value: no rename-duration
// or cascade-size percentile has been measured, and this comment should
// not be read as if one had. Measure before treating 5s as meaningful.
//
// SET LOCAL, so it dies with the transaction rather than leaking back to
// the pool. It also bounds the row-lock waits later in this transaction,
// which is intended by the same argument.
if _, err := tx.Exec("SET LOCAL lock_timeout = '5s'"); err != nil {
return fmt.Errorf("set rename lock timeout: %w", err)
}
// An operator seeing contention here sees it as wait_event_type='Lock',
// wait_event='advisory' in pg_stat_activity, with this query text — the
// 'pad:document-rename:' literal is what identifies the class. The
// workspace id is a bound parameter and will not appear in the text;
// pg_blocking_pids() on the waiter finds the holder.
if _, err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext('pad:document-rename:' || $1))", workspaceID); err != nil {
return fmt.Errorf("acquire workspace document-rename lock: %w", err)
}
return nil
}
// escapeLikePattern escapes the three characters that carry meaning inside a
// LIKE pattern, for use with an explicit `ESCAPE '\'` clause.
//
// Without this the cascade's own search term is interpreted as a pattern, and
// a document TITLE decides how (BUG-2798, codex round 1 P2 — plus the rest of
// the class it was an instance of):
//
// - `_` and `%` are wildcards in BOTH dialects, so a title containing them
// selects documents that do not link it. Those extra rows rewrite to
// themselves, so the damage is not corruption — it is that the guard below
// is computed from this result set, so an over-broad pattern spends a
// caller's budget on rows that were never going to change.
// - `\` is where the two dialects DISAGREE, which is the dangerous half.
// Postgres LIKE treats backslash as the default escape character; SQLite
// LIKE has no default escape character at all. So `[[Alpha\Beta]]` is
// searched for as the literal it is on SQLite and as `[[AlphaBeta]]` on
// Postgres — the linking documents are simply not found, the cascade
// rewrites nothing, and the rename succeeds leaving every link stale. A
// silent, dialect-dependent data defect.
//
// The explicit ESCAPE clause makes both dialects agree, rather than leaving
// SQLite correct by accident and Postgres wrong by default.
func escapeLikePattern(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle string) error {
// Find all documents in the workspace that contain [[oldTitle]]
searchTerm := "[[" + oldTitle + "]]"
rows, err := tx.Query(s.q(`
SELECT id, content FROM documents
WHERE workspace_id = ? AND deleted_at IS NULL AND content LIKE ? ESCAPE '\'
`), workspaceID, "%"+escapeLikePattern(searchTerm)+"%")
if err != nil {
return err
}
defer rows.Close()
type docUpdate struct {
id string
// read is the body this cascade rewrote FROM, kept verbatim: it is
// the compare-and-set token below, so it must be the exact string
// the column handed us and never a normalized form of it.
read string
rewritten string
// retained is what this row contributed to the running total, kept so
// the compare-and-set below can be given ITS share of the budget when
// a concurrent edit forces it to re-read and re-rewrite.
retained int64
}
var updates []docUpdate
var retained int64
for rows.Next() {
var du docUpdate
if err := rows.Scan(&du.id, &du.read); err != nil {
return err
}
// Project what this linker will make the cascade HOLD, before building
// it, and refuse on the running TOTAL across the linking set
// (BUG-2798).
//
// The total is the right thing to bound, and a per-document cap would
// not be. Measured: with the title bound in place, one linker holding
// the largest body a 2 MiB request can carry projects 108,632,370
// bytes of output — 51.8x — and the loop below holds EVERY rewritten
// body in `updates` before it writes any of them, so k linkers hold k
// times that (measured linear at k = 1/2/4). A per-document cap of C
// still admits k * C, which is the same unbounded shape one level up.
//
// RETAINED bytes, not output bytes. An earlier version of this guard
// summed only the projected output, which bounds nothing when the new
// title is SHORTER than the old one: renaming a 255-character title to
// a one-character title makes each 2 MiB linker project about 40 KiB
// while the cascade still retains its 2 MiB read for the
// compare-and-set, so hundreds of linkers exhaust memory while the
// counter reports well under the cap (codex round 1 P1). Both strings
// are alive at once, so both are counted.
//
// Refusing here rather than after the loop is what makes the bound
// real: at the moment of refusal the process holds the linkers already
// counted (under the cap by construction) plus this one row's body,
// and none of the amplified output.
// The SELECT is a LIKE, and LIKE is not the rewriter. On SQLite it is
// ASCII case-INSENSITIVE by default (Postgres's is not), so renaming
// `Alpha` scans every body containing `[[alpha]]` — which
// links.ReplaceTitle, being case-sensitive, will not touch. Charging
// those bodies to the budget lets case-variant content that can never
// be rewritten push a legitimate rename over the cap, on one dialect
// only (codex round 7).
//
// Skipping them is the fix for both halves: no budget is spent, and no
// no-op UPDATE is issued for a row whose content the cascade was never
// going to change. Same class as the `%`/`_` over-matching the ESCAPE
// clause closed — the pattern selects a superset of the linkers, and
// the authority on what is actually a linker is the rewriter's own
// case-sensitive count.
occurrences := int64(strings.Count(du.read, searchTerm))
if occurrences == 0 {
continue
}
du.retained = cascadeRetainedBytes(du.read, occurrences, oldTitle, newTitle)
retained += du.retained
if retained > MaxRenameCascadeRetainedBytes {
return newRenameCascadeTooLargeError(newTitle, retained)
}
du.rewritten = links.ReplaceTitle(du.read, oldTitle, newTitle)
updates = append(updates, du)
}
if err := rows.Err(); err != nil {
return err
}
// Test seam (BUG-2785): the last moment at which a concurrent content
// edit to a linker can still commit ahead of the writes below, which is
// the whole window this function's compare-and-set exists to survive.
// Nil in production. No existing seam reaches here — afterDocumentPreLockRead
// fires before the transaction and afterDocumentPreWrite fires before the
// renamed document's OWN update, neither of which is this gap.
if s.afterLinkCascadeRead != nil {
s.afterLinkCascadeRead(workspaceID)
}
for _, du := range updates {
// The compare-and-set is handed the scan's TOTAL, not a pre-computed
// budget, so it can both bound and REPORT correctly: a retry must fit
// in what remains under the cap, and a refusal must name the whole
// operation's size rather than the re-read alone (codex rounds 3, 8).
//
// Everything the scan counted is still held — `updates` references the
// original read and rewritten bodies for every linker, this one
// included — so a re-read is allocated ON TOP of them. An earlier
// version credited this document's share back, on the reasoning that
// the retry replaces it; it does not, and the bound could be exceeded
// by up to one document's share while the arithmetic reported it
// satisfied.
if err := s.rewriteLinkerCAS(tx, du.id, du.read, du.rewritten, oldTitle, newTitle, searchTerm, retained); err != nil {
return err
}
}
return nil
}
// cascadeRetainedBytes is what one linking document makes the cascade hold:
// the body it read (kept verbatim as the compare-and-set token) plus the body
// it will write.
//
// Exact rather than an estimate. strings.Replace substitutes every
// non-overlapping occurrence, so the rewritten length is
// len(read) + occurrences * (len(new) - len(old)) to the byte, and this
// function is the only place that arithmetic lives — the scan and the retry
// path must not be allowed to drift apart on it.
func cascadeRetainedBytes(read string, occurrences int64, oldTitle, newTitle string) int64 {
if occurrences == 0 {
// No second string exists to charge for: strings.Replace returns its
// input unchanged when there is nothing to replace, so ReplaceTitle
// allocates nothing and `rewritten` aliases `read`.
//
// This is not a micro-optimisation, it is a correctness case on the
// retry path (codex round 3 P2): a concurrent edit that REMOVES the
// link leaves a body with no occurrences, and charging it twice could
// refuse an otherwise valid rename for memory the cascade never
// allocates.
return int64(len(read))
}
rewritten := int64(len(read)) + occurrences*int64(len(newTitle)-len(oldTitle))
return int64(len(read)) + rewritten
}
// RenameCascadeTooLargeError carries the refusal's NUMBERS as typed fields, so
// a caller-facing layer can compose its own sentence instead of splicing this
// error's text into a response.
//
// The distinction matters (codex round 5): the HTTP handler used to append
// err.Error() verbatim, which meant every wrapper any caller added on the way
// up — "update links: " today, anything at all tomorrow — was published to the
// client as part of a public message. The two figures ARE meant to reach the
// caller (Dave's day-63 ruling: the refusal states what it would hold and what
// the cap is, so "split the rename" is actionable advice rather than a shrug);
// the internal call path is not.
//
// This is the round-3 lesson applied in the other direction: there, prose was
// being used to CLASSIFY an error and should have been identity; here, prose
// was being used to REPORT one and should have been data.
type RenameCascadeTooLargeError struct {
// NewTitle is the caller's own requested title. Echoed back deliberately:
// it is theirs, and it is what they need to see to understand which
// rename was refused.
NewTitle string
// Retained is the lower bound on bytes the cascade would have held. A
// lower bound, not a total: the scan stops at the first row that crosses
// the cap, so the true figure is larger.
Retained int64
// Max is the cap in force.
Max int64
}
func (e *RenameCascadeTooLargeError) Error() string {
return fmt.Sprintf("%s: renaming to %q would hold at least %d bytes of linked-document content, maximum %d",
ErrRenameCascadeTooLarge.Error(), e.NewTitle, e.Retained, e.Max)
}
// Unwrap makes errors.Is(err, ErrRenameCascadeTooLarge) hold, so every existing
// sentinel check keeps working.
func (e *RenameCascadeTooLargeError) Unwrap() error { return ErrRenameCascadeTooLarge }
func newRenameCascadeTooLargeError(newTitle string, retained int64) error {
return &RenameCascadeTooLargeError{
NewTitle: newTitle,
Retained: retained,
Max: MaxRenameCascadeRetainedBytes,
}
}
// ErrLinkCascadeContention reports that a rename's link cascade lost its
// compare-and-set on the same linking document too many times in a row.
//
// It is a CONTENTION signal, not a fault: the rename rolled back cleanly and
// retrying it can succeed. Exported so the HTTP layer can answer 503 with a
// Retry-After instead of the 500 that "an internal error occurred" implies —
// a caller told the request will never succeed will not retry, which is the
// opposite of the truth here (codex round 2 on BUG-2785).
var ErrLinkCascadeContention = errors.New("store: link cascade lost the compare-and-set")
// ErrInvalidDocumentTitle reports a rename to a title the wiki-link machinery
// cannot carry. See InvalidDocumentTitleError for why the store enforces this
// rather than trusting its callers to have done so.
var ErrInvalidDocumentTitle = errors.New("store: invalid document title")
// InvalidDocumentTitleError carries the human-readable reason a title was
// refused, so the HTTP layer can return it without re-deriving the rule or
// splicing an internal error's text into a response.
type InvalidDocumentTitleError struct{ Reason string }
func (e *InvalidDocumentTitleError) Error() string {
return ErrInvalidDocumentTitle.Error() + ": " + e.Reason
}
func (e *InvalidDocumentTitleError) Unwrap() error { return ErrInvalidDocumentTitle }
// ErrRenameCascadeTooLarge reports that a rename was refused because the
// linked-document content it would hold exceeds MaxRenameCascadeRetainedBytes.
//
// Deliberately NOT in ErrLinkCascadeContention's family, and the distinction
// is the caller-visible one: contention means "someone else got there first,
// try again"; this means "this rename cannot be performed as asked, and
// retrying it unchanged will fail identically until the workspace's content
// changes." One wants 503 + Retry-After, the other a permanent 4xx carrying
// the projection so the caller can see what it asked for. Blurring the two
// vocabularies would tell a client to retry forever (BUG-2798, lead ruling
// day-63).
var ErrRenameCascadeTooLarge = errors.New("store: rename cascade exceeds the retained-content bound")
// MaxRenameCascadeRetainedBytes bounds the TOTAL linked-document content a
// single rename may hold in memory: for every linking document, the body read
// plus the body written.
//
// RETAINED rather than merely projected-output, because output alone is not
// the resource. A rename to a SHORTER title projects less output than its
// input while still holding every read body for the compare-and-set — so an
// output-only counter reports ~40 KiB per 2 MiB linker and bounds nothing in
// that direction (codex round 1). Counting both strings makes the cap a
// statement about resident memory, which is what actually runs out.
//
// 32 MiB, and both bounds of the gap are measured rather than picked:
//
// - Legitimate ceiling. In this development instance's database — a mature
// workspace set, 206 MB on disk — the ENTIRE corpus of wiki-linking
// content is 2,949 items totalling 10,077,476 bytes (largest single body
// 86,147 bytes). A cascade over all of it would retain read + rewritten,
// so ~20,154,952 bytes. That is the absolute ceiling on any conceivable
// single cascade over that corpus: it assumes every wiki-linking document
// links the one title being renamed, which no real workspace does. 32 MiB
// is ~1.6x that impossible worst case.
//
// The INFERENCE is worth naming rather than hiding, because the guard it
// justifies is on documents and the measurement is not (codex round 6):
// that instance's `documents` table is EMPTY, so there is no direct
// figure to take. `items` is used as the proxy on the grounds that the
// two hold the same kind of prose and cascade the same way — a reasonable
// assumption, not a measurement of the guarded path. What can be said
// without the proxy is narrower and still useful: a workspace whose
// documents linking one title total more than ~16 MB of content will meet
// this cap. If real document corpora ever get that large, this number is
// the thing to re-measure.
//
// - Hostile floor. A single linking document holding the largest body a
// 2 MiB request can carry retains 110,729,520 bytes once the title bound
// is in place — 3.3x this cap — so the attack is refused at k = 1 and
// every k above it, rather than at some threshold count of documents.
//
// The gap is deliberate and wide: a cap has to be far enough above real use
// that nobody meets it by accident, and far enough below the hazard that
// meeting it costs nothing.
//
// The consequence a cap necessarily has, stated because it is user-visible
// and was chosen rather than overlooked: once a workspace's documents linking
// one title exceed this, that title can no longer be renamed, and any EDITOR
// can put it in that state by creating enough linking content. That is a
// denial of one operation by a trusted role — an editor can already delete
// every document in the workspace — and it replaces the previous behaviour,
// where the same input took the server down for everybody. Trading an
// unbounded OOM for a bounded, legible refusal is the whole point of the
// guard, not a gap in it.
//
// Bounding the WORKSPACE's linking content, so the state cannot be reached at
// all, is a quota question rather than a cascade question and is filed
// separately.
//
// What it does NOT cover, stated so the next reader does not over-read it:
// this bounds ONE rename's linked-document content, not concurrent renames (N
// of them may each hold up to this), and not the base cost of a workspace
// whose linking documents are legitimately large — a cascade under the cap
// still allocates whatever it holds.
const MaxRenameCascadeRetainedBytes = 32 << 20
// cascadeRewriteAttempts bounds rewriteLinkerCAS's retry loop.
//
// Three, matching debounceMergeAttempts' reasoning rather than copying its
// number by coincidence: one attempt to lose, one to win against the writer
// that beat it, and one of headroom for a second writer arriving mid-retry.
// Exhausting it needs three consecutive commits to the SAME linker inside one
// cascade, which is pathological contention rather than ordinary editing.
//
// It is a LIVENESS bound, not a correctness one: correctness comes from the
// compare-and-set predicate, which cannot write a stale body however many
// attempts it is given.
// A var rather than a const — deliberately diverging from its sibling
// debounceMergeAttempts, which is a const — so a test can lower it to 1 and
// drive exhaustion deterministically. Forcing exhaustion at 3 would need a
// concurrent commit between each attempt, which needs a per-attempt hook in
// production code; lowering the bound tests the same disposition with less
// test-only surface.
//
// Never written outside tests, and a test that writes it must not run under
// t.Parallel() alongside anything that renames a DOCUMENT — same constraint the
// Store's test seams carry, for the same reason: it is process-global state
// with no synchronization. internal/store does contain parallel tests
// (items_empty_assignment, item_mutation_signal, watches), and none of them
// reaches this cascade today — but that is a fact about the current test
// corpus, not an invariant, which is why the constraint is written here rather
// than inferred from the absence of a collision.
var cascadeRewriteAttempts = 3
// rewriteLinkerCAS applies a title rewrite to one linking document, retrying
// on a lost compare-and-set.
//
// WHY A CAS AT ALL (BUG-2785). The cascade is a read-modify-write across two
// statements: the SELECT above reads a linker's content, Go rewrites the
// string, and this UPDATE writes the result. A content edit committing between
// those two statements is silently erased by an unconditional UPDATE — the
// same lost-update shape as BUG-2770's activity-metadata merge, one table over.
//
// DIALECT SCOPE, because it decides whether any test here means anything.
// The lost update is reachable on POSTGRES only. The SQLite DSN sets
// `_txlock=immediate` (see store.go), so UpdateDocument's db.Begin() takes the
// write lock at BEGIN and holds it across this whole window; a concurrent
// content edit cannot commit inside it and serializes on busy_timeout instead.
// On Postgres under READ COMMITTED each statement takes a fresh snapshot, so
// the edit commits between the two statements and the stale body wins. The CAS
// is therefore a no-op on SQLite by construction — the predicate always matches,
// because nobody else can have written — and load-bearing on Postgres.
//
// WHY THE ZERO-ROW RESULT NEEDS A PROBE. The UPDATE carries two predicates
// that can each refuse it, and RowsAffected cannot say which did:
//
// - deleted_at IS NULL — the linker was soft-deleted after the SELECT. That
// is the NORMAL outcome of a documented race (the guard predates this
// change): the linker is gone, so there is no link left to keep consistent,
// and the right response is to move on.
//
// That guard used to carry a note saying it was UNTESTED and kept anyway,
// because reaching its window needed a seam no test could schedule "and
// which would cost a fifth one". This change adds that fifth seam for its
// own reasons, so the note is no longer true and has been removed rather
// than left to mislead: TestUpdateDocument_CascadeTreatsSoftDeletedLinkerAsDone
// now drives exactly that window, and a mutation removing the probe arm
// below dies to it. Recorded here because deleting a previous unit's
// deliberate "this is untestable" finding is a claim in its own right,
// and the next reader deserves to know it was closed rather than lost.
//
// - content = ? — a concurrent edit landed. The right response is the
// opposite: re-read, re-apply the rewrite to the NEW body, and try again.
//
// Treating the two alike would either retry forever against a deleted row or
// discard a live linker's rewrite. So a refusal is followed by a probe that
// distinguishes them, the same shape BUG-2770 needed for the same reason.
// The probe reads through tx, never the pool: a pool read from inside a
// transaction that holds row locks is BUG-2409's deadlock.
//
// WHAT THIS DOES NOT FIX, named because "the cascade is now safe" is the claim
// that would rot silently (codex round 2 enumerated both):
//
// - THE MIRROR DIRECTION. This stops the cascade losing a content writer's
// edit. It does not stop a content writer losing the CASCADE's rewrite: a
// writer that read the body BEFORE this transaction, then blocked on the
// row lock, commits its own unconditional UPDATE afterwards and reinstates
// the old title. Fixing that means giving ordinary content writes a
// compare-and-set too, which is a much wider change than a rename cascade
// and belongs to whoever takes it on.
// - DELETE-THEN-RESTORE. The ErrNoRows arm returns without taking a row
// lock, so a linker soft-deleted during the cascade and RESTORED before
// this transaction commits comes back holding the old title. RestoreDocument
// takes no rename lock, so nothing serializes it against this.
//
// Both are pre-existing and neither is made worse here. They are recorded
// because the next reader's question is "is the cascade correct now", and the
// honest answer is "for the direction this bug named".
func (s *Store) rewriteLinkerCAS(tx *sql.Tx, id, read, rewritten, oldTitle, newTitle, searchTerm string, scanTotal int64) error {
expected := read
next := rewritten
// Total charged by retries so far — see the accumulation below.
var retriesSpent int64
for attempt := 0; attempt < cascadeRewriteAttempts; attempt++ {
res, err := tx.Exec(s.q(`
UPDATE documents SET content = ?
WHERE id = ? AND deleted_at IS NULL AND content = ?
`), next, id, expected)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n > 0 {
return nil
}
var current string
err = tx.QueryRow(s.q(`
SELECT content FROM documents WHERE id = ? AND deleted_at IS NULL
`), id).Scan(&current)
if err == sql.ErrNoRows {
// Soft-deleted between the cascade's SELECT and now. Documented
// normal outcome, not an error.
return nil
}
if err != nil {
return err
}
// The re-read body is a NEW input, supplied by whoever won the race,
// and it is bounded by nothing this cascade has already checked. Its
// budget is the cap less what the other linkers are holding, so the
// aggregate bound holds across retries too — without this, an editor
// could grow a linker between the scan and the retry and walk the
// rename straight back into the amplification it was refused for
// (BUG-2798, codex round 1 P1).
grownOccurrences := int64(strings.Count(current, searchTerm))
// ACCUMULATED across attempts, not just this one. Each retry's
// buffers become unreachable when `expected`/`next` are reassigned
// below, but unreachable is not the same as reclaimed — the runtime
// may not have collected them yet, so a run of failures can hold
// several copies at once (codex round 9). Counting every attempt is
// the conservative reading, and it errs toward refusing, which is the
// safe direction for a memory bound. The loop is capped at
// cascadeRewriteAttempts, so this cannot accumulate without end.
retriesSpent += cascadeRetainedBytes(current, grownOccurrences, oldTitle, newTitle)
grown := retriesSpent
if scanTotal+grown > MaxRenameCascadeRetainedBytes {
// Report the AGGREGATE, not this body alone. The bodies the scan
// counted are still held, so the operation's real size is their
// total plus the re-read — and reporting only `grown` produced a
// refusal that contradicted itself, telling the caller it would
// hold 16 MiB against a 32 MiB limit (codex round 8). A refusal
// whose own numbers do not justify it reads as a bug in the
// server, which is the opposite of what an actionable error does.
return newRenameCascadeTooLargeError(newTitle, scanTotal+grown)
}
// A concurrent edit won. Rewrite ITS body rather than ours: replaying
// the original rewrite would reintroduce the very content this bug is
// about losing. If that edit already removed the link, ReplaceTitle is
// a no-op and the next attempt writes an identical body — which still
// affects one row and terminates.
expected = current
next = links.ReplaceTitle(current, oldTitle, newTitle)
}
// Exhaustion fails the RENAME, rather than leaving this one linker holding
// a title that no longer exists.
//
// The alternative — log and continue — was considered and rejected: it
// trades a loud, retryable failure for a silent inconsistency, and a
// rename is atomic in intent (either the title moves and its links follow,
// or neither). The user can retry a failed rename; nobody goes looking for
// a stale wiki-link.
//
// Wrapped around ErrLinkCascadeContention so the HTTP layer can tell this
// from a genuine fault. It is CONTENTION, not a bug: retrying can succeed,
// and a 500 would tell the caller the opposite (codex round 2).
return fmt.Errorf("%w: document %s after %d attempts", ErrLinkCascadeContention, id, cascadeRewriteAttempts)
}
func (s *Store) DeleteDocument(id string) error {
ts := now()
result, err := s.db.Exec(s.q(`
UPDATE documents SET deleted_at = ?, updated_at = ?, status = 'archived'
WHERE id = ? AND deleted_at IS NULL
`), ts, ts, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) RestoreDocument(id string) (*models.Document, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// Read the soft-deleted document's content + workspace inside the tx so
// we can re-stamp its attachment references before it becomes live again.
// GetDocument filters deleted_at IS NULL, so it can't see this row yet.
var content, workspaceID string
if err := tx.QueryRow(s.q(`
SELECT content, workspace_id FROM documents WHERE id = ? AND deleted_at IS NOT NULL
`), id).Scan(&content, &workspaceID); err != nil {
if err == sql.ErrNoRows {
return nil, sql.ErrNoRows
}
return nil, err
}
// BUG-2629: re-assert the document's attachment references at the moment
// it becomes live again. While archived, the live AttachmentReferenced
// scan can't see this document's refs (BUG-2614 only scans live docs), so
// the orphan GC may have let their last_referenced_at go stale; a claim
// racing this restore keys on that stamp, not the live scan. Stamp BEFORE
// the deleted_at clear, in this tx, per stampAttachmentRefsTx's ORDERING
// note: the stamp's row-lock makes a concurrent claim block until commit
// and re-evaluate against the fresh stamp — refusing. Document refs are
// necessarily never-attached (no document_id column on attachments), so
// this is the leg with nothing else standing between it and the claim.
// Prevention only: an already-reclaimed blob is gone (stamp matches zero
// rows).
if err := stampAttachmentRefsTx(tx, s, workspaceID, content); err != nil {
return nil, err
}
ts := now()
result, err := tx.Exec(s.q(`
UPDATE documents SET deleted_at = NULL, updated_at = ?, status = 'draft'
WHERE id = ? AND deleted_at IS NOT NULL
`), ts, id)
if err != nil {
return nil, err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return nil, sql.ErrNoRows
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetDocument(id)
}
func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
var docs []models.Document
for rows.Next() {
var d models.Document
var createdAt, updatedAt string
var pinned bool
if err := rows.Scan(
&d.ID, &d.WorkspaceID, &d.Title, &d.Slug, &d.Content, &d.DocType, &d.Status, &d.Tags,
&pinned, &d.SortOrder, &d.CreatedBy, &d.LastModifiedBy, &d.Source,
&createdAt, &updatedAt,
); err != nil {
return nil, err
}
d.Pinned = pinned
d.CreatedAt = parseTime(createdAt)
d.UpdatedAt = parseTime(updatedAt)
docs = append(docs, d)
}
return docs, rows.Err()
}