mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
93220845a0
* feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731)
GET /api/v1/workspaces/{ws}/graph returns the whole workspace as
{nodes, edges} in one call, feeding the 3D graph view (PLAN-1730).
Nodes carry ref/title/collection/status/is_terminal/child_count/
updated_at; edges are typed (parent | blocks | implements | related |
wiki-link), with wiki-link edges sourced from the PLAN-1593 reverse
index, deduped per pair, self-links dropped.
Default response is active items only; ?include_terminal=true returns
the full history. Visibility follows the dashboard model (collection
visibility + guest item-level grants), and edges are filtered to the
visible node set so hidden items can't be inferred from dangling
endpoints.
Parent: PLAN-1730.
* fix(server): normalize graph edge types to advertised vocabulary per Codex review (round 1)
item_links can carry split_from / supersedes / wiki_link beyond the
documented enum. Map stored types to the hyphenated graph vocabulary
(wiki_link → wiki-link, split_from → split-from), dedupe (source,
target, type) so a stored wiki_link row and a parsed [[...]] mention
of the same pair emit once, and document the full edge enum. Unknown
future link types pass through rather than being dropped.
* fix(store): close graph edge enum against unknown link types per Codex review (round 2)
Route stored link types through models.NormalizeItemLinkType; values
it rejects (possible via the import path — no DB CHECK on
item_links.link_type) degrade to 'related' instead of leaking
undocumented edge types past the advertised vocabulary.
126 lines
4.3 KiB
Go
126 lines
4.3 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.
|
|
//
|
|
// 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
|
|
JOIN items tgt ON tgt.id = il.target_id AND tgt.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
|
|
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
|
|
}
|