mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
63adbebb76
PLAN-2857 unit U5, closes TASK-2997.
A MATERIALISED reverse index for `relation` field values —
`item_relation_links` per the ratified Q3 shape — plus a write hook, a
startup backfill, a viewer-scoped query, an endpoint, and the
"Referenced by N" section.
THE HOOK LIVES AT THE STORE'S FIELD-WRITING SITES, not at the doors the
row named. A door-level hook is a per-door wiring claim over a population
that GROWS; a store-level one cannot be bypassed by a door that does not
exist yet, and it is the shape migration 061 set for the content index.
The sites were enumerated with a grep rather than by eye, and two of them
are not in U1's resolver population at all.
Three placements are not obvious from the call sites: UPDATE re-reads the
blob FROM THE ROW, because a field write is a server-side MERGE and
indexing the submission would drop every key the caller did not mention;
MOVE indexes against the DESTINATION collection, the one write where blob
and schema change together; and the attachment remap calls the hook even
though it cannot change a relation value, because omitting it would be a
standing claim about what that function can touch.
A SCHEMA CHANGE REINDEXES THE COLLECTION (lead ruling), inside the
schema-update transaction and under the workspace lock, gated on the
schema or the values actually moving — an icon edit must not pay it.
Measured: 947ms-1.8s for 10k items, which is why it is inline and not a
job. The backfill batches by workspace in bounded chunks: 10k items in
1.06s across ~20 transactions, where one-per-item would have been 10,000.
THE COUNT IS THE VIEWER'S. ResolveBacklinksVisibility reused verbatim, so
"Referenced by 3" means "by 3 you can see" — a true count leaks the
existence of items the viewer cannot open. Filtering is in SQL so LIMIT
counts visible rows.
EIGHT CODEX ROUNDS, ELEVEN P1s; the first seven each found something
real. The ones worth keeping:
- the backfill's guard was "does the table have any row", and rows
commit per item, so an interrupted pass left it non-empty and
INCOMPLETE and skipped forever. Completion is recorded explicitly,
after the work.
- RESTORE is an eighth hook site. I enumerated writes of one COLUMN;
the index depends on (blob, schema, LIVENESS). That miss produced the
input matrix on TASK-2997's trail, which round 4 then validated row
by row.
- the backfill SKIPPED zero-edge items as an optimisation — precisely
the case that preserves a stale row and then marks the pass complete.
- a source in a SOFT-DELETED COLLECTION was disclosed; its items stay
live and an Unrestricted viewer has no collection filter to catch it.
- rounds 3 and 4 were the same miss at two scopes: I locked what I was
READING and not what whose change invalidates the read. Round 3 also
refuted a claim I had put in a commit message, and the record was
corrected rather than left standing.
- the field-label lookup added in round 6 BECAME round 7's P1: a
per-collection SELECT while the rows cursor was open, which is
BUG-2409's deadlock class. One query now, with a MaxOpenConns(1)
guard that the restored shape kills.
Three times a comment described code that had changed under it,
including one claiming a counterfactual TEST that does not exist — the
counterfactual is a mutation, recorded per round with its verdict.
Q3'S COUNTERFACTUAL, re-run after every rewrite: hook removed, mutant
COMPILES, all legs fail. Per-site too, because "all sites wired" is
otherwise untested.
NOT COVERED, said plainly: the Postgres interleavings are argued from the
isolation level and the lock order, not demonstrated — SQLite serialises
writes and cannot express the race. Two hook sites are uncovered, one
unfalsifiable by construction and one with no caller.
BUG-3017 files the pre-existing attachment-remap lost update this
branch's workspace lock closes, so it is on the record rather than
absorbed here.
Gates: go test ./... 0, vet 0, gofmt clean, lint 0 issues, svelte-check 0
errors, make test-pg 0 with all 17 relation tests confirmed run BY NAME
on Postgres. All 8 CI checks green on 8c0fe775.
Claude-Session: https://claude.ai/code/session_01PRcuviXka4Un1FGrBrX8jc
114 lines
4.7 KiB
Go
114 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// The "Referenced by N" door (PLAN-2857 U5 / TASK-2997).
|
|
//
|
|
// The store tests own the index's correctness. These own the two things only
|
|
// the door can answer: that the endpoint is wired to the index at all, and
|
|
// that the number it reports is the VIEWER'S — which is the ratified
|
|
// visibility decision, and the one with a disclosure consequence.
|
|
|
|
type relationBacklinksResponse struct {
|
|
RelationBacklinks []struct {
|
|
SourceItemID string `json:"source_item_id"`
|
|
SourceRef string `json:"source_ref"`
|
|
SourceTitle string `json:"source_title"`
|
|
FieldKey string `json:"field_key"`
|
|
} `json:"relation_backlinks"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// getRelationBacklinks drives the endpoint as `user` and decodes the payload.
|
|
func (f *doorFixture) getRelationBacklinks(user *models.User, role string, target *models.Item) relationBacklinksResponse {
|
|
f.t.Helper()
|
|
var rr = f.callAs(user, role, f.srv.handleGetItemRelationBacklinks, "GET",
|
|
"/api/v1/workspaces/"+f.ws.Slug+"/items/"+target.Slug+"/relation-backlinks",
|
|
map[string]string{"itemSlug": target.Slug}, nil)
|
|
if rr.Code != http.StatusOK {
|
|
f.t.Fatalf("relation-backlinks: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var out relationBacklinksResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
f.t.Fatalf("decode: %v\nbody: %s", err, rr.Body.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestRelationBacklinksDoor_ReportsTheEdgeWithItsFieldKey is the wiring claim:
|
|
// the endpoint reads the index, and it carries the field key that makes a
|
|
// relation backlink different from a wiki one.
|
|
func TestRelationBacklinksDoor_ReportsTheEdgeWithItsFieldKey(t *testing.T) {
|
|
f := newDoorFixture(t)
|
|
source := f.seed(`{"owner_ref":"` + f.target.ID + `"}`)
|
|
|
|
got := f.getRelationBacklinks(f.owner, "owner", f.target)
|
|
if got.Total != 1 {
|
|
t.Fatalf("total = %d, want 1 — the endpoint is not reading the index", got.Total)
|
|
}
|
|
if len(got.RelationBacklinks) != 1 {
|
|
t.Fatalf("want 1 backlink, got %d", len(got.RelationBacklinks))
|
|
}
|
|
b := got.RelationBacklinks[0]
|
|
if b.SourceItemID != source.ID {
|
|
t.Errorf("source = %s, want %s", b.SourceItemID, source.ID)
|
|
}
|
|
if b.FieldKey != "owner_ref" {
|
|
t.Errorf("field_key = %q, want %q — without it the panel cannot say WHICH field points here, which is the whole reason this is not a wiki backlink", b.FieldKey, "owner_ref")
|
|
}
|
|
if b.SourceTitle == "" || b.SourceRef == "" {
|
|
t.Errorf("the backlink lost the source's identity: %+v", b)
|
|
}
|
|
}
|
|
|
|
// TestRelationBacklinksDoor_TheCountIsTheViewersNotTheTruth is the ratified
|
|
// visibility decision, and the reason it needs a test is that the "wrong"
|
|
// behaviour here looks more correct: reporting the true count is what a
|
|
// naive implementation does, and it leaks the existence of items the viewer
|
|
// cannot see.
|
|
func TestRelationBacklinksDoor_TheCountIsTheViewersNotTheTruth(t *testing.T) {
|
|
f := newDoorFixture(t)
|
|
|
|
// Two sources point at the same target. The restricted viewer is granted
|
|
// exactly one of them.
|
|
visibleSource := f.seed(`{"owner_ref":"` + f.target.ID + `"}`)
|
|
f.seed(`{"owner_ref":"` + f.target.ID + `"}`) // the one they cannot see
|
|
|
|
if got := f.getRelationBacklinks(f.owner, "owner", f.target); got.Total != 2 {
|
|
t.Fatalf("the OWNER should see both (total %d); without that the restricted leg proves nothing", got.Total)
|
|
}
|
|
|
|
blind := mustUser(t, f.srv, "relation-backlinks-blind@example.com", "relbacklinksblind", "")
|
|
if err := f.srv.store.AddWorkspaceMember(f.ws.ID, blind.ID, "editor"); err != nil {
|
|
t.Fatalf("AddWorkspaceMember: %v", err)
|
|
}
|
|
// Can see the target's collection, and among the SOURCES only the one
|
|
// granted below.
|
|
if err := f.srv.store.SetMemberCollectionAccess(f.ws.ID, blind.ID, "specific",
|
|
[]string{f.people.ID}); err != nil {
|
|
t.Fatalf("SetMemberCollectionAccess: %v", err)
|
|
}
|
|
if _, err := f.srv.store.CreateItemGrant(f.ws.ID, visibleSource.ID, blind.ID, "read", f.owner.ID); err != nil {
|
|
t.Fatalf("CreateItemGrant: %v", err)
|
|
}
|
|
|
|
got := f.getRelationBacklinks(blind, "editor", f.target)
|
|
if got.Total != 1 {
|
|
t.Errorf("total = %d, want 1 — the count must be the VIEWER'S; reporting 2 tells them an item they cannot see exists", got.Total)
|
|
}
|
|
if len(got.RelationBacklinks) != 1 || got.RelationBacklinks[0].SourceItemID != visibleSource.ID {
|
|
t.Errorf("the page leaked a source the viewer cannot see: %+v", got.RelationBacklinks)
|
|
}
|
|
// The count and the page must agree. A header promising a number the list
|
|
// cannot produce is the drift CountBacklinks documents for the wiki side.
|
|
if got.Total != len(got.RelationBacklinks) {
|
|
t.Errorf("total %d but %d rows — the count and the page ran under different filters", got.Total, len(got.RelationBacklinks))
|
|
}
|
|
}
|