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:
@@ -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
|
||||
}
|
||||
|
||||
79
store/mbrian_reader_coalesce_test.go
Normal file
79
store/mbrian_reader_coalesce_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user