fix(store): Phase 6 Slice C — reader reads pinned/archived + note from metadata (G3, G2)

Two pre-cutover reader tweaks head approved (projax-local, no mBrian
change) so writes through the scoped HTTP API round-trip cleanly:

- G3 (required): itemFromNode reads pinned/archived from metadata.projax
  with a fallback to the nodes.pinned/archived columns —
  COALESCE(metadata.projax.X, column). MBrianWriter writes them to
  metadata.projax (PATCH can't set the columns); this makes the dashboard
  star + archive toggle round-trip while preserving any pin state the
  migration wrote to the columns.
- G2: linkFromEdge reads ItemLink.Note as COALESCE(edge.note,
  metadata.note) — migrated notes are in the edge.note column, post-cutover
  AddLinkDated notes land in metadata.note; both now surface. note is also
  dropped from consumer metadata to match *Store (whose note lives in the
  column, never metadata).

Both parity-safe for current data (migrated nodes carry no
metadata.projax.pinned; 0 note-bearing edges) — store parity tests stay
green. Added pure-function unit tests for both COALESCE behaviours.

G1 (multi-link same ref_type) deferred per head — 0 cases, safe-refuse
behaviour stands.
This commit is contained in:
mAi
2026-06-01 12:38:54 +02:00
parent 663f21bdb0
commit e133f51706
2 changed files with 106 additions and 3 deletions

View File

@@ -134,6 +134,20 @@ func itemFromNode(r *nodeRow) *Item {
if v, ok := projaxMeta["status"].(string); ok && v != "" {
it.Status = v
}
// Phase 6 Slice C (G3): pinned/archived are written into metadata.projax
// by MBrianWriter (the scoped HTTP PATCH surface can't set the
// nodes.pinned/archived columns). Read them from metadata.projax with a
// fallback to the node columns — COALESCE(metadata.projax.X, column) —
// so a post-cutover star/archive toggle round-trips AND any pin state the
// migration wrote to the columns is preserved. Defaults above (r.Pinned /
// r.Archived) are the column values; override only when metadata carries
// an explicit bool.
if v, ok := projaxMeta["pinned"].(bool); ok {
it.Pinned = v
}
if v, ok := projaxMeta["archived"].(bool); ok {
it.Archived = v
}
if v, ok := projaxMeta["tags"]; ok {
it.Tags = anyToStringSlice(v)
}
@@ -786,11 +800,13 @@ func linkFromEdge(r *edgeRow) *ItemLink {
l.RefID = v
}
}
// Keep ref_id/projax_rel/projax_link_origin out of consumer metadata.
// Keep internal keys out of consumer metadata. `note` is promoted to
// l.Note below (G2), so it's dropped here too — matches *Store, whose
// item_links.note lives in the column, never in metadata.
for k, v := range r.Metadata {
switch k {
case "projax_rel", "projax_link_origin", "ref_id":
// internal — drop
case "projax_rel", "projax_link_origin", "ref_id", "note":
// internal / promoted — drop
default:
l.Metadata[k] = v
}
@@ -798,8 +814,16 @@ func linkFromEdge(r *edgeRow) *ItemLink {
// EventDate parsing for PER-dated edges (none today, but the
// migration may add).
l.EventDate = parseTimeAny(r.Metadata["event_date"])
// Phase 6 Slice C (G2): the scoped edge API has no note field, so
// MBrianWriter stows AddLinkDated's note in edge metadata. Read
// COALESCE(edge.note, metadata.note) — migrated notes are in the
// edge.note column, post-cutover notes land in metadata.note; both
// surface as ItemLink.Note.
if r.Note != nil && *r.Note != "" {
l.Note = r.Note
} else if v, ok := r.Metadata["note"].(string); ok && v != "" {
n := v
l.Note = &n
}
return l
}

View File

@@ -0,0 +1,79 @@
package store
import "testing"
// Phase 6 Slice C reader fixes (G3 + G2): the reader reads pinned/archived
// and link note from metadata (where MBrianWriter stows them, since the
// scoped HTTP API can't set those columns/fields) with a fallback to the
// node column / edge.note. These exercise itemFromNode + linkFromEdge as
// pure functions — no DB.
func TestItemFromNodePinnedArchivedCoalesce(t *testing.T) {
// metadata.projax wins when present.
r := &nodeRow{
ID: "n1", Slug: "x", Title: "X",
Pinned: false, Archived: false, // column defaults
Metadata: map[string]any{
"projax": map[string]any{"pinned": true, "archived": true},
},
}
it := itemFromNode(r)
if !it.Pinned || !it.Archived {
t.Errorf("metadata.projax pinned/archived should override columns: pinned=%v archived=%v", it.Pinned, it.Archived)
}
// Absent in metadata → fall back to the column values (migrated data).
r2 := &nodeRow{
ID: "n2", Slug: "y", Title: "Y",
Pinned: true, Archived: true, // column values from migration
Metadata: map[string]any{"projax": map[string]any{}},
}
it2 := itemFromNode(r2)
if !it2.Pinned || !it2.Archived {
t.Errorf("column pinned/archived should survive when metadata is silent: pinned=%v archived=%v", it2.Pinned, it2.Archived)
}
// metadata false explicitly overrides a true column.
r3 := &nodeRow{
ID: "n3", Slug: "z", Title: "Z",
Pinned: true,
Metadata: map[string]any{"projax": map[string]any{"pinned": false}},
}
if itemFromNode(r3).Pinned {
t.Error("explicit metadata.projax.pinned=false should override column pinned=true")
}
}
func TestLinkFromEdgeNoteCoalesce(t *testing.T) {
// edge.note column wins.
col := "from column"
er := &edgeRow{
ID: "e1", SourceID: "i1", Rel: "projax-doc",
Note: &col,
Metadata: map[string]any{"note": "from metadata", "url": "/d.pdf"},
}
l := linkFromEdge(er)
if l.Note == nil || *l.Note != "from column" {
t.Errorf("edge.note column should win: %v", l.Note)
}
if _, dup := l.Metadata["note"]; dup {
t.Error("note must not also appear in consumer metadata (parity with *Store)")
}
// No column note → fall back to metadata.note (post-cutover writes).
er2 := &edgeRow{
ID: "e2", SourceID: "i1", Rel: "projax-doc",
Note: nil,
Metadata: map[string]any{"note": "filed brief", "url": "/d.pdf"},
}
l2 := linkFromEdge(er2)
if l2.Note == nil || *l2.Note != "filed brief" {
t.Errorf("metadata.note should surface when edge.note is empty: %v", l2.Note)
}
// Neither → nil note, no panic.
er3 := &edgeRow{ID: "e3", SourceID: "i1", Rel: "projax-url", Metadata: map[string]any{"url": "https://x"}}
if l3 := linkFromEdge(er3); l3.Note != nil {
t.Errorf("no note anywhere → nil, got %v", *l3.Note)
}
}