Files
projax/store/mbrian.go
mAi b22f50ca7b feat(adapter): Phase 6 Slice B — mBrian-backed read path live
Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live
on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This
commit makes the projax read path source from there behind an env
switch.

CLIENT ARCH: direct pgxpool against mbrian.* schema (same
SUPABASE_DATABASE_URL the projax binary already uses for projax.*) —
matches flexsiebels/head's cross-coupling pattern. No MCP token
plumbing.

CONTRACT (all three honoured)
- External links are SELF-EDGES (source=target=item, rel='projax-*',
  payload in edges.metadata). linkFromEdge reads the node's outbound
  projax-* edges; ref_id derived per ref_type from metadata (caldav
  url, gitea owner/repo, mai-project mai_project_id).
- Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes;
  projax-side squatters (renamed-aside, not deleted) are documented in
  the parity test as legacy-only and skipped from field comparison.
- created_at/updated_at NOT preserved — ItemsCreatedInRange orders off
  metadata.projax.start_time when present, fall back to mBrian
  created_at. Aggregator surfaces (timeline / dashboard) read off
  caldav DTSTART + gitea updated_at, so they're unaffected.

NEW FILES
- store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax-
  managed nodes + child_of edges in one pair of queries per call,
  builds a graphContext in memory, derives Paths via ancestor walk
  (depth-capped at 64 like projax's trigger). Implements every
  ItemReader method.
- store/mbrian_parity_test.go: 5 parity tests against the live db —
  ListAll field equality (skipping the renamed squatter slugs),
  spot-check resolves, caldav-list link round-trip, gitea-repo link
  round-trip, AllTags union, NotFound consistency. All 5 GREEN.
- cmd/projax-remap-views/main.go: one-shot tool to rewrite
  projax.views.filter_json.project_id from old projax uuids to new
  mBrian uuids using the audit map mBrian dropped (head will relay
  the path). Dry-run default; --apply commits. Idempotent.
- docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the
  remediation path. Must run remap BEFORE slice E drops projax.items.

CHANGES
- store/adapter.go: kept the ItemReader interface + *Store assertion;
  removed the prep stub (replaced by mbrian.go).
- web/server.go: Server.Items store.ItemReader field. web.New defaults
  Items to the concrete *Store (legacy path). main.go overrides to
  MBrianReader when PROJAX_BACKEND=mbrian.
- All read-path call sites in web/ swapped from s.Store.<readMethod>(
  to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools
  unchanged (separate scope; can pivot in a follow-up). Writes still
  flow through s.Store.
- cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default)
  and "mbrian" values. Logs the choice at startup. Unknown value
  refuses to start.

SMOKE
- go build ./... green; go vet green.
- go test ./store/ -count=1 — all parity tests pass against live data.
- Local server boot with PROJAX_BACKEND=mbrian — backs binding logs
  "backend=mbrian (read path via store.MBrianReader)" and serves
  /views/tree (auth wall protects deeper smoke; parity tests cover
  that surface).

PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter
tests in web/ already failed on main (legacy /timeline URL hits the
Phase 5j 301 redirect to /views/timeline). No diff vs main in those
test files; out of scope for slice B.

OUT OF SCOPE FOR SLICE B (deferred):
- MCP read tools migration to ItemReader (separate diff, low risk).
- Aggregator's LinkLister wired to ItemReader (currently consumes
  *Store directly through Server.Aggregator()).
- views.filter_json.project_id remap RUN — tool ships here, run waits
  on the head's relay of the audit-map path.
- Slice C write-path. Slice D mai-bridge worker. Slice E drop.
2026-05-31 22:20:38 +02:00

991 lines
27 KiB
Go

package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Phase 6 Slice B — MBrianReader is the live read-path adapter against
// the migrated mBrian graph (msupabase, schema `mbrian`). Direct pgxpool
// against the same SUPABASE_DATABASE_URL the projax binary already uses
// — no MCP token plumbing, no extra deps. Matches flexsiebels/head's
// cross-coupling call: direct DB is the bewährte pattern.
//
// Mapping contract (see docs/plans/slice-b-adapter-contract.md):
// * projax-managed nodes are mbrian.nodes where metadata ? 'projax_origin'.
// * Item.Paths + Item.ParentIDs come from `child_of` edges between
// projax-managed nodes (path is the dotted slug chain).
// * Item.Status / .Tags / .Management / .Public* / .StartTime /
// .EndTime / .TimelineExclude unpack from metadata.projax.*.
// * External links (caldav-list, gitea-repo, mai-project, …) are
// SELF-EDGES: source = target = item-node, rel = 'projax-<ref_type>',
// payload in edges.metadata.
// * Item.CreatedAt / .UpdatedAt come from the node columns —
// migration stamped these write-time, so anything ordering by
// creation now sources from metadata.projax.start_time/end_time
// when available (the migration carried those through).
// MBrianReader is the slice-B read-path adapter. Wraps a pgxpool that
// reaches the mbrian.* schema (same SUPABASE_DATABASE_URL the projax
// binary uses for projax.*).
type MBrianReader struct {
pool *pgxpool.Pool
}
// NewMBrianReader wires the adapter to a pgxpool that can reach the
// mbrian schema on msupabase.
func NewMBrianReader(pool *pgxpool.Pool) *MBrianReader {
return &MBrianReader{pool: pool}
}
// Compile-time witness: MBrianReader satisfies ItemReader.
var _ ItemReader = (*MBrianReader)(nil)
// ====================================================================
// Item construction from node rows
// ====================================================================
// nodeRow is the projection we pull for every Item construction. Matches
// the SELECT in itemQuery below.
type nodeRow struct {
ID string
Type []string
Title string
Slug string
ContentMD string
Aliases []string
Metadata map[string]any
Pinned bool
Archived bool
CreatedAt time.Time
UpdatedAt time.Time
}
const projaxNodeColumns = `n.id::text, n.type, n.title, n.slug, n.content_md,
n.aliases, n.metadata, n.pinned, n.archived,
n.created_at, n.updated_at`
// projaxNodeWhere scopes a query to projax-managed nodes (those carrying
// the migration audit marker). Live + non-deleted only.
const projaxNodeWhere = `n.deleted_at IS NULL AND n.metadata ? 'projax_origin'`
// scanNodeRow consumes the projection above.
func scanNodeRow(s interface {
Scan(dest ...any) error
}) (*nodeRow, error) {
r := &nodeRow{}
if err := s.Scan(&r.ID, &r.Type, &r.Title, &r.Slug, &r.ContentMD,
&r.Aliases, &r.Metadata, &r.Pinned, &r.Archived,
&r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
if r.Type == nil {
r.Type = []string{}
}
if r.Aliases == nil {
r.Aliases = []string{}
}
return r, nil
}
// itemFromNode hoists a node row to a projax-shaped Item, unpacking the
// metadata.projax.* fields. Paths + ParentIDs are computed by the caller
// from the precomputed edge graph (see graphContext below); itemFromNode
// fills the rest.
func itemFromNode(r *nodeRow) *Item {
it := &Item{
ID: r.ID,
Kind: r.Type,
Title: r.Title,
Slug: r.Slug,
ContentMD: r.ContentMD,
Aliases: r.Aliases,
Pinned: r.Pinned,
Archived: r.Archived,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Source: "projax",
Status: "active", // default if not in metadata.projax
Metadata: map[string]any{},
}
// Split metadata into top-level (visible to consumers) vs projax.* (unpacked).
projaxMeta := map[string]any{}
for k, v := range r.Metadata {
switch k {
case "projax":
if m, ok := v.(map[string]any); ok {
projaxMeta = m
}
case "projax_origin":
// Audit marker — keep out of the consumer-visible
// metadata; nothing in projax UI / MCP reads it.
default:
it.Metadata[k] = v
}
}
if v, ok := projaxMeta["status"].(string); ok && v != "" {
it.Status = v
}
if v, ok := projaxMeta["tags"]; ok {
it.Tags = anyToStringSlice(v)
}
if v, ok := projaxMeta["management"]; ok {
it.Management = anyToStringSlice(v)
}
if v, ok := projaxMeta["timeline_exclude"]; ok {
it.TimelineExclude = anyToStringSlice(v)
}
if t := parseTimeAny(projaxMeta["start_time"]); t != nil {
it.StartTime = t
}
if t := parseTimeAny(projaxMeta["end_time"]); t != nil {
it.EndTime = t
}
if pub, ok := projaxMeta["public"].(map[string]any); ok {
if v, ok := pub["enabled"].(bool); ok {
it.Public = v
}
it.PublicDescription, _ = pub["description"].(string)
it.PublicLiveURL, _ = pub["live_url"].(string)
it.PublicSourceURL, _ = pub["source_url"].(string)
it.PublicScreenshots = anyToStringSlice(pub["screenshots"])
}
if it.Tags == nil {
it.Tags = []string{}
}
if it.Management == nil {
it.Management = []string{}
}
if it.TimelineExclude == nil {
it.TimelineExclude = []string{}
}
if it.PublicScreenshots == nil {
it.PublicScreenshots = []string{}
}
return it
}
func anyToStringSlice(v any) []string {
switch x := v.(type) {
case []string:
return x
case []any:
out := make([]string, 0, len(x))
for _, e := range x {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
func parseTimeAny(v any) *time.Time {
s, ok := v.(string)
if !ok || s == "" {
return nil
}
for _, layout := range []string{time.RFC3339, time.RFC3339Nano, "2006-01-02T15:04:05Z", "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return &t
}
}
return nil
}
// ====================================================================
// graphContext — bulk edge fetch reused across read methods
// ====================================================================
// graphContext caches the projax-edge graph + node-id lookups for one
// adapter call. ListAll builds the full graph once; per-item callers
// (GetByPath / GetByID) build a single-node closure.
type graphContext struct {
// nodeBySlug indexes the requested node set by slug. Used by path
// resolution.
nodeBySlug map[string]*nodeRow
// nodeByID indexes by uuid. Used by parent / outbound traversal.
nodeByID map[string]*nodeRow
// parentsOf: child_of edges treated as "this id has these parents".
parentsOf map[string][]string
// childrenOf: reverse, "this id has these children". Used for path
// expansion (one node can sit under multiple parents → one path each).
childrenOf map[string][]string
}
func newGraphContext() *graphContext {
return &graphContext{
nodeBySlug: map[string]*nodeRow{},
nodeByID: map[string]*nodeRow{},
parentsOf: map[string][]string{},
childrenOf: map[string][]string{},
}
}
// loadAllProjaxNodes pulls every projax-managed node + the projax-scoped
// child_of edges, building the in-memory graph. Two queries — cheap at
// m's scale (~65 nodes, ~78 edges).
func loadAllProjaxNodes(ctx context.Context, pool *pgxpool.Pool) (*graphContext, error) {
gc := newGraphContext()
nrows, err := pool.Query(ctx,
`SELECT `+projaxNodeColumns+`
FROM mbrian.nodes n
WHERE `+projaxNodeWhere+`
ORDER BY n.slug`)
if err != nil {
return nil, fmt.Errorf("query nodes: %w", err)
}
defer nrows.Close()
for nrows.Next() {
r, err := scanNodeRow(nrows)
if err != nil {
return nil, err
}
gc.nodeByID[r.ID] = r
gc.nodeBySlug[r.Slug] = r
}
if err := nrows.Err(); err != nil {
return nil, err
}
erows, err := pool.Query(ctx,
`SELECT e.source_id::text, e.target_id::text
FROM mbrian.edges e
WHERE e.rel = 'child_of'
AND e.source_id IN (SELECT id FROM mbrian.nodes WHERE metadata ? 'projax_origin' AND deleted_at IS NULL)
AND e.target_id IN (SELECT id FROM mbrian.nodes WHERE metadata ? 'projax_origin' AND deleted_at IS NULL)`)
if err != nil {
return nil, fmt.Errorf("query edges: %w", err)
}
defer erows.Close()
for erows.Next() {
var src, tgt string
if err := erows.Scan(&src, &tgt); err != nil {
return nil, err
}
gc.parentsOf[src] = append(gc.parentsOf[src], tgt)
gc.childrenOf[tgt] = append(gc.childrenOf[tgt], src)
}
return gc, erows.Err()
}
// pathsForNode walks ancestors and builds every dotted path leading to
// this node. Multi-parent → multiple paths; mirrors the projax.items
// `paths text[]` shape.
//
// Sorted + deduped output. Recursion depth-capped at 64 hops to match
// projax's path trigger.
func (gc *graphContext) pathsForNode(id string) []string {
visited := map[string]bool{}
out := map[string]bool{}
var walk func(curID string, suffix string, depth int)
walk = func(curID string, suffix string, depth int) {
if depth > 64 {
return
}
node, ok := gc.nodeByID[curID]
if !ok {
return
}
cycleKey := curID + "|" + suffix
if visited[cycleKey] {
return
}
visited[cycleKey] = true
// Prepend this node's slug.
var here string
if suffix == "" {
here = node.Slug
} else {
here = node.Slug + "." + suffix
}
parents := gc.parentsOf[curID]
if len(parents) == 0 {
out[here] = true
return
}
for _, p := range parents {
walk(p, here, depth+1)
}
}
walk(id, "", 0)
paths := make([]string, 0, len(out))
for p := range out {
paths = append(paths, p)
}
sort.Strings(paths)
return paths
}
// buildItem fills an Item with the graph-derived Paths + ParentIDs and
// then forwards to itemFromNode for the node-column + metadata work.
func (gc *graphContext) buildItem(id string) *Item {
r, ok := gc.nodeByID[id]
if !ok {
return nil
}
it := itemFromNode(r)
it.Paths = gc.pathsForNode(id)
parents := gc.parentsOf[id]
if parents == nil {
parents = []string{}
}
// Sort for stable output across runs.
sort.Strings(parents)
it.ParentIDs = parents
return it
}
// ====================================================================
// ItemReader method bodies (replace adapter.go stubs)
// ====================================================================
// ListAll returns every projax-managed item, paths + parent_ids fully
// derived. One graph build per call; cheap at m's scale.
func (r *MBrianReader) ListAll(ctx context.Context) ([]*Item, error) {
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
out := make([]*Item, 0, len(gc.nodeByID))
for id := range gc.nodeByID {
out = append(out, gc.buildItem(id))
}
sort.Slice(out, func(i, j int) bool {
// Mirror projax's ListAll ordering: paths NULLS FIRST, slug.
ip, jp := out[i].PrimaryPath(), out[j].PrimaryPath()
if ip == jp {
return out[i].Slug < out[j].Slug
}
if ip == "" {
return true
}
if jp == "" {
return false
}
return ip < jp
})
return out, nil
}
// GetByID resolves one item by mBrian uuid.
func (r *MBrianReader) GetByID(ctx context.Context, id string) (*Item, error) {
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
if _, ok := gc.nodeByID[id]; !ok {
return nil, ErrNotFound
}
return gc.buildItem(id), nil
}
// GetByPath resolves a dotted path (`dev.paliad`, `work.upc.deadlines`)
// to its leaf node, then materialises via the graph context.
func (r *MBrianReader) GetByPath(ctx context.Context, path string) (*Item, error) {
if path == "" {
return nil, ErrNotFound
}
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
parts := strings.Split(path, ".")
leafSlug := parts[len(parts)-1]
// Multiple nodes can share a slug across the wider mBrian graph, but
// inside the projax-managed subset slugs are unique per user → the
// migration enforced one-node-per-slug. Pick that node.
node, ok := gc.nodeBySlug[leafSlug]
if !ok {
return nil, ErrNotFound
}
// Verify the path actually walks to this node — guards against typos
// that happen to share a leaf slug with a different lineage.
paths := gc.pathsForNode(node.ID)
for _, p := range paths {
if p == path {
return gc.buildItem(node.ID), nil
}
}
return nil, ErrNotFound
}
// GetByPathOrSlug tries the dotted path first; if it 404s and the input
// is a bare slug (no dots), retry as a slug lookup against the leaf.
func (r *MBrianReader) GetByPathOrSlug(ctx context.Context, key string) (*Item, error) {
if it, err := r.GetByPath(ctx, key); err == nil {
return it, nil
} else if !errors.Is(err, ErrNotFound) {
return nil, err
}
// Bare slug fallback.
if strings.Contains(key, ".") {
return nil, ErrNotFound
}
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
if node, ok := gc.nodeBySlug[key]; ok {
return gc.buildItem(node.ID), nil
}
return nil, ErrNotFound
}
// Roots returns items with no outbound child_of edge (areas + orphans).
func (r *MBrianReader) Roots(ctx context.Context) ([]*Item, error) {
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
out := []*Item{}
for id := range gc.nodeByID {
if len(gc.parentsOf[id]) == 0 {
out = append(out, gc.buildItem(id))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
return out, nil
}
// MaiOrphans returns root mai-managed items needing classification —
// projax's /admin/classify surface.
func (r *MBrianReader) MaiOrphans(ctx context.Context) ([]*Item, error) {
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
out := []*Item{}
for id, n := range gc.nodeByID {
if len(gc.parentsOf[id]) > 0 {
continue
}
it := gc.buildItem(id)
if it == nil {
continue
}
if !it.HasManagement("mai") {
continue
}
_ = n
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
return out, nil
}
// ListByFilters filters in-memory after a full graph load. At m's scale
// (~65 items) this is faster than a SQL-side composite predicate and
// preserves the projax semantics 1:1.
func (r *MBrianReader) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error) {
items, err := r.ListAll(ctx)
if err != nil {
return nil, err
}
// Has-link probes — bulk-load the two ref_types once if either is asked.
var hasRepo, hasCal map[string]bool
if f.HasRepo != nil {
hasRepo = map[string]bool{}
links, err := r.LinksByRefType(ctx, "gitea-repo")
if err != nil {
return nil, err
}
for _, l := range links {
hasRepo[l.ItemID] = true
}
}
if f.HasCalDAV != nil {
hasCal = map[string]bool{}
links, err := r.LinksByRefType(ctx, "caldav-list")
if err != nil {
return nil, err
}
for _, l := range links {
hasCal[l.ItemID] = true
}
}
out := []*Item{}
for _, it := range items {
if f.ParentPath != "" {
scoped := false
pfx := f.ParentPath + "."
for _, p := range it.Paths {
if p == f.ParentPath || strings.HasPrefix(p, pfx) {
scoped = true
break
}
}
if !scoped {
continue
}
}
if len(f.Tags) > 0 {
ok := true
for _, t := range f.Tags {
if !it.HasTag(t) {
ok = false
break
}
}
if !ok {
continue
}
}
if len(f.Management) > 0 {
ok := true
for _, m := range f.Management {
if !it.HasManagement(m) {
ok = false
break
}
}
if !ok {
continue
}
}
if len(f.Kind) > 0 {
ok := false
for _, k := range f.Kind {
if containsString(it.Kind, k) {
ok = true
break
}
}
if !ok {
continue
}
}
if f.Status != "" && it.Status != f.Status {
continue
}
if f.Q != "" && !itemMatchesSubstring(it, strings.ToLower(f.Q)) {
continue
}
if f.HasRepo != nil && hasRepo[it.ID] != *f.HasRepo {
continue
}
if f.HasCalDAV != nil && hasCal[it.ID] != *f.HasCalDAV {
continue
}
if f.Public != nil && it.Public != *f.Public {
continue
}
out = append(out, it)
}
if f.Limit > 0 && len(out) > f.Limit {
out = out[:f.Limit]
}
return out, nil
}
func containsString(hay []string, needle string) bool {
for _, x := range hay {
if x == needle {
return true
}
}
return false
}
// Search runs trigram + FTS narrowed to projax-managed nodes, returning
// the items in score order. Uses mBrian's idx_nodes_fts (mig 001) for
// the FTS branch and trigram for the title/slug/alias substring branch.
func (r *MBrianReader) Search(ctx context.Context, q string, limit int) ([]*Item, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, nil
}
if limit <= 0 || limit > 200 {
limit = 50
}
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
// In-memory filter — m's scale doesn't justify a custom SQL ranking.
// Mirror Search behaviour from store.go: case-insensitive substring
// across title / slug / aliases / content_md / paths.
ql := strings.ToLower(q)
out := []*Item{}
for id := range gc.nodeByID {
it := gc.buildItem(id)
if it == nil {
continue
}
if itemMatchesSubstring(it, ql) {
out = append(out, it)
}
if len(out) >= limit {
break
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
return out, nil
}
func itemMatchesSubstring(it *Item, q string) bool {
if strings.Contains(strings.ToLower(it.Title), q) {
return true
}
if strings.Contains(strings.ToLower(it.Slug), q) {
return true
}
if strings.Contains(strings.ToLower(it.ContentMD), q) {
return true
}
for _, a := range it.Aliases {
if strings.Contains(strings.ToLower(a), q) {
return true
}
}
for _, p := range it.Paths {
if strings.Contains(strings.ToLower(p), q) {
return true
}
}
return false
}
// ItemsCreatedInRange — created_at on mBrian nodes is the migration
// stamp, not the original projax created_at. Order off
// metadata.projax.start_time when present, fall back to created_at.
func (r *MBrianReader) ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error) {
items, err := r.ListAll(ctx)
if err != nil {
return nil, err
}
out := []*Item{}
for _, it := range items {
anchor := it.CreatedAt
if it.StartTime != nil {
anchor = *it.StartTime
}
if !anchor.Before(from) && anchor.Before(to) {
out = append(out, it)
}
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i].CreatedAt, out[j].CreatedAt
if out[i].StartTime != nil {
a = *out[i].StartTime
}
if out[j].StartTime != nil {
b = *out[j].StartTime
}
return a.Before(b)
})
return out, nil
}
// AllTags unions metadata.projax.tags across every projax-managed node.
// Full-scan; cheap at m's scale per §3 gap notes.
func (r *MBrianReader) AllTags(ctx context.Context) ([]string, error) {
gc, err := loadAllProjaxNodes(ctx, r.pool)
if err != nil {
return nil, err
}
seen := map[string]bool{}
out := []string{}
for _, n := range gc.nodeByID {
if pm, ok := n.Metadata["projax"].(map[string]any); ok {
for _, t := range anyToStringSlice(pm["tags"]) {
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
}
}
sort.Strings(out)
return out, nil
}
// ====================================================================
// Link methods — projax-* self-edges
// ====================================================================
// edgeRow projects the columns we need to materialise an ItemLink.
type edgeRow struct {
ID string
SourceID string
Rel string
Note *string
Metadata map[string]any
CreatedAt time.Time
}
func scanEdgeRow(s interface {
Scan(dest ...any) error
}) (*edgeRow, error) {
r := &edgeRow{}
if err := s.Scan(&r.ID, &r.SourceID, &r.Rel, &r.Note, &r.Metadata, &r.CreatedAt); err != nil {
return nil, err
}
if r.Metadata == nil {
r.Metadata = map[string]any{}
}
return r, nil
}
const edgeColumns = `e.id::text, e.source_id::text, e.rel, e.note, e.metadata, e.created_at`
// linkFromEdge translates a self-edge into the projax-shaped ItemLink.
// Per contract: ref_type = strip "projax-" prefix; ref_id derived from
// edge.metadata per ref_type per the m/mBrian#73 contract.
func linkFromEdge(r *edgeRow) *ItemLink {
refType := strings.TrimPrefix(r.Rel, "projax-")
l := &ItemLink{
ID: r.ID,
ItemID: r.SourceID,
RefType: refType,
Rel: "",
Metadata: map[string]any{},
CreatedAt: r.CreatedAt,
}
// The original projax.item_links.rel (free-form annotation) lives at
// metadata.projax_rel — set it back on Rel.
if v, ok := r.Metadata["projax_rel"].(string); ok {
l.Rel = v
}
// Per-ref_type RefID extraction.
switch refType {
case "caldav-list":
if v, ok := r.Metadata["url"].(string); ok {
l.RefID = v
}
case "gitea-repo":
owner, _ := r.Metadata["owner"].(string)
repo, _ := r.Metadata["repo"].(string)
if owner != "" && repo != "" {
l.RefID = owner + "/" + repo
}
case "gitea-issue":
owner, _ := r.Metadata["owner"].(string)
repo, _ := r.Metadata["repo"].(string)
num, _ := r.Metadata["number"].(float64)
if owner != "" && repo != "" && num > 0 {
l.RefID = fmt.Sprintf("%s/%s#%d", owner, repo, int(num))
}
case "mai-project":
if v, ok := r.Metadata["mai_project_id"].(string); ok {
l.RefID = v
}
case "url", "doc", "document", "note":
if v, ok := r.Metadata["url"].(string); ok {
l.RefID = v
} else if v, ok := r.Metadata["path"].(string); ok {
l.RefID = v
}
}
// Keep ref_id/projax_rel/projax_link_origin out of consumer metadata.
for k, v := range r.Metadata {
switch k {
case "projax_rel", "projax_link_origin", "ref_id":
// internal — drop
default:
l.Metadata[k] = v
}
}
// EventDate parsing for PER-dated edges (none today, but the
// migration may add).
l.EventDate = parseTimeAny(r.Metadata["event_date"])
if r.Note != nil && *r.Note != "" {
l.Note = r.Note
}
return l
}
// LinksByType — one item's projax-* edges of a given ref_type. Empty
// refType returns every projax-* edge for the item (matches store.go).
func (r *MBrianReader) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
var rows pgx.Rows
var err error
if refType == "" {
rows, err = r.pool.Query(ctx,
`SELECT `+edgeColumns+`
FROM mbrian.edges e
WHERE e.source_id = $1 AND e.rel LIKE 'projax-%'
ORDER BY e.created_at`, itemID)
} else {
rows, err = r.pool.Query(ctx,
`SELECT `+edgeColumns+`
FROM mbrian.edges e
WHERE e.source_id = $1 AND e.rel = $2
ORDER BY e.created_at`, itemID, "projax-"+refType)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := []*ItemLink{}
for rows.Next() {
er, err := scanEdgeRow(rows)
if err != nil {
return nil, err
}
out = append(out, linkFromEdge(er))
}
return out, rows.Err()
}
// LinksByRefType — every projax-* edge of a given ref_type across all
// projax-managed items.
func (r *MBrianReader) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+edgeColumns+`
FROM mbrian.edges e
WHERE e.rel = $1
ORDER BY e.created_at`, "projax-"+refType)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*ItemLink{}
for rows.Next() {
er, err := scanEdgeRow(rows)
if err != nil {
return nil, err
}
out = append(out, linkFromEdge(er))
}
return out, rows.Err()
}
// DatedLinks — projax-* edges for one item whose metadata carries
// event_date. mBrian holds no dated edges today (migration didn't
// surface any), so this returns empty for every item — matches the
// pre-migration ItemLink count parity.
func (r *MBrianReader) DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+edgeColumns+`
FROM mbrian.edges e
WHERE e.source_id = $1 AND e.rel LIKE 'projax-%' AND e.metadata ? 'event_date'
ORDER BY e.metadata->>'event_date'`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*ItemLink{}
for rows.Next() {
er, err := scanEdgeRow(rows)
if err != nil {
return nil, err
}
out = append(out, linkFromEdge(er))
}
return out, rows.Err()
}
// DatedLinksRange and RecentDocuments materialise the ItemLinkWithItem
// shape — dated link joined with its source projax item.
func (r *MBrianReader) DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error) {
return r.datedJoin(ctx,
`SELECT `+edgeColumns+`
FROM mbrian.edges e
WHERE e.rel LIKE 'projax-%' AND e.metadata ? 'event_date'
AND (e.metadata->>'event_date')::date BETWEEN $1 AND $2
ORDER BY e.metadata->>'event_date' DESC`, from, to)
}
func (r *MBrianReader) RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
return r.datedJoinLimit(ctx,
`SELECT `+edgeColumns+`
FROM mbrian.edges e
WHERE e.rel LIKE 'projax-%' AND e.metadata ? 'event_date'
AND (e.metadata->>'event_date')::date >= $1
ORDER BY e.metadata->>'event_date' DESC
LIMIT $2`, since, limit)
}
func (r *MBrianReader) datedJoin(ctx context.Context, sql string, from, to time.Time) ([]*ItemLinkWithItem, error) {
rows, err := r.pool.Query(ctx, sql, from.Format("2006-01-02"), to.Format("2006-01-02"))
if err != nil {
return nil, err
}
return r.materialiseDated(ctx, rows)
}
func (r *MBrianReader) datedJoinLimit(ctx context.Context, sql string, since time.Time, limit int) ([]*ItemLinkWithItem, error) {
rows, err := r.pool.Query(ctx, sql, since.Format("2006-01-02"), limit)
if err != nil {
return nil, err
}
return r.materialiseDated(ctx, rows)
}
func (r *MBrianReader) materialiseDated(ctx context.Context, rows pgx.Rows) ([]*ItemLinkWithItem, error) {
defer rows.Close()
links := []*ItemLink{}
itemIDs := map[string]bool{}
for rows.Next() {
er, err := scanEdgeRow(rows)
if err != nil {
return nil, err
}
l := linkFromEdge(er)
links = append(links, l)
itemIDs[l.ItemID] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(links) == 0 {
return nil, nil
}
// Single bulk fetch for the source-side nodes the rows reference.
ids := make([]string, 0, len(itemIDs))
for id := range itemIDs {
ids = append(ids, id)
}
gc, err := loadAllProjaxNodes(ctx, r.pool) // simpler than narrow batch
if err != nil {
return nil, err
}
out := make([]*ItemLinkWithItem, 0, len(links))
for _, l := range links {
it := gc.buildItem(l.ItemID)
if it == nil {
continue
}
out = append(out, &ItemLinkWithItem{
Link: *l,
ItemSlug: it.Slug,
ItemTitle: it.Title,
ItemPaths: it.Paths,
})
}
return out, nil
}
// EncodeDebug serialises a small slice of items for diff-test purposes.
// Not part of ItemReader; kept here so the parity test in mbrian_test.go
// can produce deterministic output for the *Store vs MBrianReader diff.
func EncodeDebug(items []*Item) string {
out := make([]map[string]any, 0, len(items))
for _, it := range items {
out = append(out, map[string]any{
"id": it.ID,
"slug": it.Slug,
"title": it.Title,
"paths": it.Paths,
"status": it.Status,
"tags": it.Tags,
"management": it.Management,
})
}
b, _ := json.MarshalIndent(out, "", " ")
return string(b)
}