Files
pad/internal/store/graph.go
T
xarmian 623652c711 fix(store): a link row counts only when both endpoints live in its workspace (BUG-3776) (#2208)
* fix(store): a link row counts only when both endpoints live in its workspace (BUG-3776)

The readers that enrich or count item_links rows scoped the row by its own
workspace_id, or by one endpoint's id, and joined the endpoint items without
asking where they live. No door writes a cross-workspace row (the link
handler refuses a target in another workspace), but a legacy or malformed
one surfaced another workspace's item title in lineage, the graph, blocks
edges and an item's links, and was counted in progress and has_children.

Every endpoint join in the population now requires the endpoint's
workspace_id to equal the link row's: ListWorkspaceGraphLinks, GetParentMap,
GetBlocksEdges, GetItemLinks, GetParentForItem, GetAllItemProgress (and its
children-done filter), PopulateHasChildren, and the list --parent filter,
which takes a raw id. getItemLinkQ (lookup by link id) is left as it is: it
feeds the delete door, which checks the row's workspace and both endpoints'
visibility, and scoping it would make a malformed row undeletable.

The store test plants the rows directly, on SQLite and Postgres, with a
legitimate row in every reader as a control.

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

* test(store): BUG-3776 also plants a link row filed in another workspace

Both endpoints live in workspace A and the row is filed in B. The readers
reached by an item id or a parent id (GetItemLinks, GetParentForItem,
progress, has_children, the list --parent filter) must not see it. Red on
main for all five, green at the fix on SQLite and Postgres.

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

* fix(store): child lists, item progress and backlink suppression honour the link-row workspace (BUG-3776)

Review r1 (Codex M1/M2, hunter M1/M2): the first pass enumerated one join
shape, items joined ON items.id = link endpoint, and missed the reversed
one, item_links joined ON il.source_id = i.id. getChildItems (GetChildItems
and GetChildItemsTx), ListChildItems, GetChildItemsForParents,
GetItemProgress and childrenDoneFiltersForParent now require the child in
the row's workspace and join the parent to check it too. The backlink
child-suppression clauses in GetBacklinks and CountBacklinks ignore a row
filed in another workspace (Codex L1, hunter L3).

The test gains both shapes for every one of them, plus a backlink hidden by
a row filed in B. 11/11 subtests red on main, green at the fix on SQLite
and Postgres.

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

* fix(store): backlink suppression requires the link's other endpoint in its workspace (BUG-3776)

A parent row filed in workspace A whose other endpoint lives in B no
longer hides a real backlink. Adds the target-in-B backlink shape and a
search-term leg of the --parent filter, which goes through listItemsFTS.

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

* test(store): BUG-3776 asserts A4 is in the progress list; fix a comment the change falsified

The GetAllItemProgress subtest read A4's total from a map, which is also
0 when A4 is missing from the result. It now requires A4 present first.
The copy preflight's comment said item_links carries no workspace
predicate on the far end; GetItemLinks now drops such rows.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 06:13:11 -04:00

132 lines
4.8 KiB
Go

package store
import (
"fmt"
"github.com/PerpetualSoftware/pad/internal/models"
)
// GraphLink is one typed edge in the workspace graph
// (GET /workspaces/{ws}/graph — PLAN-1730 / TASK-1731). Source and
// target are item IDs; the handler maps them to refs and applies
// visibility filtering before they leave the server.
type GraphLink struct {
SourceID string
TargetID string
Type string // hyphenated graph edge type — see graphEdgeType
}
// graphEdgeType maps a stored item_links.link_type to the hyphenated
// edge vocabulary the graph API advertises. Stored values are first
// canonicalized through models.NormalizeItemLinkType (handles aliases
// and casing); anything it rejects — possible via the import path,
// which inserts link types without a DB CHECK — degrades to 'related'
// so the advertised enum stays closed while the edge still renders as
// a generic relationship. Synthesized wiki-link edges from
// item_wiki_links use the same 'wiki-link' spelling as stored
// wiki_link rows so the client sees one type.
func graphEdgeType(linkType string) string {
canonical, err := models.NormalizeItemLinkType(linkType)
if err != nil {
return models.ItemLinkTypeRelated
}
switch canonical {
case models.ItemLinkTypeWikiLink:
return "wiki-link"
case models.ItemLinkTypeSplitFrom:
return "split-from"
default:
return canonical
}
}
// ListWorkspaceGraphLinks returns every edge for the workspace graph view:
// all item_links rows (parent / blocks / implements / related) plus
// resolved same-workspace wiki-links from the item_wiki_links reverse
// index (PLAN-1593).
//
// Both queries exclude edges whose source or target item is soft-deleted,
// matching GetParentMap's posture (BUG-734) — a dangling edge to an
// archived item would render as a node the items query never returned.
// For the same reason they exclude an endpoint whose COLLECTION is
// soft-deleted, since ListItems leaves those items out (BUG-3425).
//
// Wiki-link rows are deduplicated per (source, target) pair — an item
// that mentions [[TASK-5]] three times is still one edge — and
// self-links are dropped (an item linking to itself adds noise, not
// structure). Unresolved rows (target_item_id IS NULL) and
// cross-workspace rows are excluded: the graph renders one workspace.
func (s *Store) ListWorkspaceGraphLinks(workspaceID string) ([]GraphLink, error) {
links := []GraphLink{}
rows, err := s.db.Query(s.q(`
SELECT il.source_id, il.target_id, il.link_type
FROM item_links il
JOIN items src ON src.id = il.source_id AND src.deleted_at IS NULL AND src.workspace_id = il.workspace_id
JOIN items tgt ON tgt.id = il.target_id AND tgt.deleted_at IS NULL AND tgt.workspace_id = il.workspace_id
JOIN collections sc ON sc.id = src.collection_id AND sc.deleted_at IS NULL
JOIN collections tc ON tc.id = tgt.collection_id AND tc.deleted_at IS NULL
WHERE il.workspace_id = ?
`), workspaceID)
if err != nil {
return nil, fmt.Errorf("list graph item links: %w", err)
}
defer rows.Close()
for rows.Next() {
var l GraphLink
if err := rows.Scan(&l.SourceID, &l.TargetID, &l.Type); err != nil {
return nil, fmt.Errorf("scan graph item link: %w", err)
}
l.Type = graphEdgeType(l.Type)
links = append(links, l)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Wiki-link edges. Scoped by the SOURCE item's workspace —
// item_wiki_links has no workspace_id column of its own, and
// target_workspace_id is only set on cross-workspace rows (which
// are excluded here anyway).
wikiRows, err := s.db.Query(s.q(`
SELECT DISTINCT wl.source_item_id, wl.target_item_id
FROM item_wiki_links wl
JOIN items src ON src.id = wl.source_item_id AND src.deleted_at IS NULL
JOIN items tgt ON tgt.id = wl.target_item_id AND tgt.deleted_at IS NULL
JOIN collections sc ON sc.id = src.collection_id AND sc.deleted_at IS NULL
JOIN collections tc ON tc.id = tgt.collection_id AND tc.deleted_at IS NULL
WHERE src.workspace_id = ?
AND wl.target_item_id IS NOT NULL
AND wl.target_workspace_id IS NULL
AND wl.source_item_id != wl.target_item_id
`), workspaceID)
if err != nil {
return nil, fmt.Errorf("list graph wiki links: %w", err)
}
defer wikiRows.Close()
for wikiRows.Next() {
l := GraphLink{Type: "wiki-link"}
if err := wikiRows.Scan(&l.SourceID, &l.TargetID); err != nil {
return nil, fmt.Errorf("scan graph wiki link: %w", err)
}
links = append(links, l)
}
if err := wikiRows.Err(); err != nil {
return nil, err
}
// Dedupe (source, target, type) — a stored wiki_link item_links row
// and a parsed [[...]] mention of the same pair both normalize to
// 'wiki-link' and would otherwise emit twice.
seen := make(map[GraphLink]bool, len(links))
deduped := links[:0]
for _, l := range links {
if seen[l] {
continue
}
seen[l] = true
deduped = append(deduped, l)
}
return deduped, nil
}