mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
623652c711
* 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
132 lines
4.8 KiB
Go
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
|
|
}
|