8 Commits

Author SHA1 Message Date
mAi
307a898dbd Merge branch 'mai/kahn/phase-6-sliceB' (Phase 6 Slice B: mBrian-backed read path behind PROJAX_BACKEND switch, parity-green) 2026-05-31 22:22:40 +02:00
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
mAi
4fdeca8269 Merge branch 'mai/kahn/phase-6-sliceB-prep' (Phase 6: slice-B adapter interface contract + skeleton, no impl) 2026-05-29 15:18:15 +02:00
mAi
9607d4b307 docs+skeleton: Phase 6 Slice B prep — read-path adapter interface contract
Per head's parallel-prep brief while m/mBrian#73 (migration script +
[schema] node) is being built mBrian-side. NO mBrian-MCP-backed
implementation yet — the migration worker may refine the landed
node/edge shape and building the impl now risks rework.

Built ONLY the parts stable regardless of mBrian internals:

1. CONSUMER INVENTORY (docs/plans/slice-b-adapter-contract.md §1)
   - Every *store.Store read method (15 methods) with signature + semantics
   - Every call site across web/, internal/aggregate/, mcp/ — table form
   - Item / ItemLink field-by-field shape contract: which fields come
     direct from node columns, which from edge-walk, which from
     metadata-unpack
   - Direct pgxpool access flagged out-of-scope (admin counts, bulk
     tx, links event-date update — slice C reworks those)
   - Views (5j) explicitly NOT in scope per m's Q5=(a)

2. INTERFACE CONTRACT (store/adapter.go)
   - ItemReader Go interface — 15 methods, pure projax-shaped structs
     in/out, zero mBrian type leakage
   - var _ ItemReader = (*Store)(nil) compile-time assertion proving the
     existing pgx-backed *Store satisfies the contract today

3. SKELETON (store/adapter.go MBrianReader)
   - Empty struct (mBrian client choice deferred to slice B impl)
   - All 15 methods stubbed, return errNotImplementedSliceB
   - var _ ItemReader = (*MBrianReader)(nil) keeps the stubs in lockstep
     with the interface as slice B grows
   - Each stub carries a one-line comment naming the §3 gap(s) it
     resolves at impl time
   - `go build ./...` green; `go vet ./store/` green

4. GAP FLAGS (docs/plans/slice-b-adapter-contract.md §3)
   - item_links.rel free-form annotation → mBrian edge.note (add to
     m/mBrian#73 §1 for the migration script)
   - ItemLink.RefID per-rel-type extraction rule (caldav URL vs gitea
     owner/repo vs mai project uuid)
   - paths[] recomputation cost (per-request memoisation)
   - AllTags aggregation (full-scan ok at m's scale; tag-graph deferred
     per m's Q8)
   - Roots / MaiOrphans "no outbound child_of edge" predicate
   - ItemsCreatedInRange scoped to projax_origin marker
   - Item.Source / SourceRefID constant + mai-edge-derived fields
   - ItemLinkWithItem join shape (two queries + in-memory join vs bulk
     MCP helper)
   - Admin counts — recommend adding Counts(ctx) to ItemReader for cohesion

Stays parked after this. Slice B IMPL (mBrian-MCP client wiring + per-
method bodies + handler rename from s.Store.X to s.Items.X) waits on
the migration completing and uuid map landing.
2026-05-29 15:17:24 +02:00
mAi
38182df651 Merge branch 'mai/kahn/phase-6a-mbrian-design' (Phase 6: mBrian-backend migration design + slice 0 snapshot helper) 2026-05-29 14:03:27 +02:00
mAi
2702c699d1 feat(snapshot): Phase 6 slice 0 — projax_snapshot.json export helper
Read-only export of projax.items + projax.item_links to a JSON file the
mBrian-side migration script (m/mBrian#73) consumes. First implementation
slice of the Phase 6 mBrian-backend migration.

Tool:
- cmd/projax-snapshot/main.go: standalone binary, takes --out flag
  (default ./projax_snapshot.json). Reads PROJAX_DB_URL or
  SUPABASE_DATABASE_URL like the main projax binary.
- Pure read-only: SELECT FROM projax.items WHERE deleted_at IS NULL
  + SELECT FROM projax.item_links. No writes, no schema changes.
- Re-runnable: each invocation produces a fresh deterministic file;
  no state, no DB side effects.

Output shape (Snapshot struct):
- version: "1" — bumped on shape changes for downstream version-pinning.
- generated_at: timestamp.
- items: every live projax.items row with all columns mapped 1:1 to
  JSON-friendly types (uuid → string, jsonb → map, timestamptz →
  RFC3339). Empty slices coerced to [] so the mBrian-side script doesn't
  see null-array surprises.
- links: every projax.item_links row, ordered by item_id + ref_type
  for stable diffs across runs.
- spot_checks: the 5 representative items the mBrian-side script
  verifies post-migration per m/mBrian#73 §3. Selected at runtime by
  characteristic (root area, single-parent, multi-parent, caldav-linked,
  public-listing-populated) so the picks self-update as the dataset
  evolves.

Smoke-tested against the live msupabase dataset:
  wrote /tmp/projax_snapshot.json — 65 items, 81 links, 5 spot-checks

Selected spot-checks (live):
  dev      — root area
  paliad   — single-parent project
  services — multi-parent (2 parents)
  mhome    — caldav-list-linked
  fdbck    — public-listing populated

Out of scope (slices B+ pick up):
- The mBrian-side script itself lives in m/mBrian per "mbrian must own
  the migration" (Q4=(a)).
- projax-side adapter rewriting waits on the mBrian-side migration run.
- No tests yet: this is a one-off helper against live data; smoke run
  above is the validation surface. A go-test suite can land if the
  snapshot shape needs evolution before mBrian-side consumes it.
2026-05-29 14:02:16 +02:00
mAi
a5b0971b9d docs: Phase 6 plan re-baseline against live mBrian schema + m's answers
m answered all 11 §10 questions; every inventor pick confirmed.
m's overriding directive: "keep the database simple so it remains
easily modifiable."

Head verified the live mBrian schema after m's answers — original §3
was built off stale db/001_initial_schema.sql. Three of the six asks
turned out already-satisfied:

- MB-A (edges.metadata jsonb) — already added in db/010, GIN-indexed,
  used by migs 039/040. Drop the ask.
- MB-C (project type) — already in live schema, mig 033 confirms.
  Drop the ask.
- MB-D (per-user slug uniqueness) — already enforced by idx_nodes_slug
  in db/001. Drop the ask.

Plus 'area' as a separate mBrian type is killed per m's "keep it
simple": areas reuse type=['project'] with metadata.projax.kind='area'.
Zero DDL.

Remaining mBrian-side artifact compresses to ONE [schema] convention
node under a new [topic] projax-integration hub, plus mBrian-side
ownership of the one-shot data-migration script (per m's "mbrian must
own the migration").

Re-sequenced §8: six slices.
  0 (projax snapshot helper) → A (mBrian [schema] node + script run)
  → B (projax read-path adapter) → C (projax write-path)
  → D (mai bridge worker) → E (drop projax tables).

CalDAV/Gitea integrations stay where they are (m's Q3=(a)). No slice
F needed in the original sense.

§2 + §2.1 + §7 + §9 + §10 + §14 updated. §3 fully rewritten.

No code changes; this branch ships docs only. Slice 0 is the smallest
first projax-side step but waits for head's greenlight after the
m/mBrian issue is filed.
2026-05-29 13:56:50 +02:00
mAi
b3e7183478 docs: Phase 6 mBrian-as-backend migration design plan
m's decision on issue m/projax#5 (2026-05-29): Option A — full backend
migration to mBrian. mBrian becomes the canonical store for projax
data; projax UI surfaces stay (Tiles dashboard, calendar grid,
timeline spine, the just-shipped 5j /views routes) but read+write
goes through mBrian instead of projax.items.

The plan covers:
- §1 diagnosis: closing the parallel-knowledge-surface gap
- §2 column-by-column schema mapping (projax.items → mBrian nodes +
  metadata, projax.item_links → mBrian edges + new edges.metadata)
- §3 mBrian-side requirements: schema fragments to add (edges.metadata
  column, projax edge relations + types schema-nodes)
- §4 read-path replacement: store adapter over mBrian, UI shape stable
- §5 write-path replacement: every handler + MCP write rewired
- §6 integrations disposition: CalDAV/Gitea stay projax-handled at
  consumption; mai.projects sync moves to a handler-layer bridge
- §7 migration mechanics: hard-cut script per m's loss tolerance
- §8 six-slice plan: A (mBrian schema) → B (data migration) →
  C (read-path) → D (write-path) → E (drop projax tables) → F
  (integrations)
- §9 cross-repo coordination protocol via otto/head (no mBrian/head
  worker exists today)
- §10 eleven open questions for m, batched for head delegation
- §11 risk register
- §12 test plan headlines

Slice A is mBrian-side and is the hard gate — projax B–F cannot start
until mBrian's schema fragments land. Cross-repo coordination request
filed alongside the m delegation.

No code changes; this branch ships docs only. Coder shifts wait on
m's sign-off on §10 + mBrian-side slice A.
2026-05-29 12:49:48 +02:00
20 changed files with 2620 additions and 45 deletions

View File

@@ -0,0 +1,139 @@
// projax-remap-views rewrites projax.views.filter_json.project_id from
// the OLD projax.items uuid to the new mBrian node uuid using the audit
// map mBrian dropped after the migration. Dry-run by default; pass
// --apply to commit.
//
// Phase 6 Slice B gap remediation — see
// docs/plans/slice-b-views-projectid-gap.md for the surrounding context.
//
// Usage:
//
// projax-remap-views --map /path/to/uuid-map.json # dry-run
// projax-remap-views --map /path/to/uuid-map.json --apply # commit
//
// Map shape: {"<old-uuid>": "<new-uuid>", ...}
//
// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
mapPath := flag.String("map", "", "JSON file with {old-uuid: new-uuid} map")
apply := flag.Bool("apply", false, "commit changes (default: dry-run)")
flag.Parse()
if *mapPath == "" {
die("--map is required")
}
mp, err := loadMap(*mapPath)
if err != nil {
die("load map: %v", err)
}
fmt.Fprintf(os.Stderr, "loaded %d uuid mappings\n", len(mp))
dbURL := os.Getenv("PROJAX_DB_URL")
if dbURL == "" {
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
}
if dbURL == "" {
die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
die("pool: %v", err)
}
defer pool.Close()
rows, err := pool.Query(ctx,
`SELECT slug, filter_json::text FROM projax.views WHERE filter_json ? 'project_id'`)
if err != nil {
die("query: %v", err)
}
defer rows.Close()
type view struct {
slug string
filter map[string]any
}
var pending []view
for rows.Next() {
var slug, raw string
if err := rows.Scan(&slug, &raw); err != nil {
die("scan: %v", err)
}
var fj map[string]any
if err := json.Unmarshal([]byte(raw), &fj); err != nil {
fmt.Fprintf(os.Stderr, "skip %s: invalid JSON: %v\n", slug, err)
continue
}
oldID, _ := fj["project_id"].(string)
newID, ok := mp[oldID]
if !ok {
fmt.Fprintf(os.Stderr, "skip %s: project_id %q not in map (possibly already remapped)\n", slug, oldID)
continue
}
fj["project_id"] = newID
pending = append(pending, view{slug: slug, filter: fj})
fmt.Fprintf(os.Stderr, " %s: %s → %s\n", slug, oldID, newID)
}
if len(pending) == 0 {
fmt.Fprintln(os.Stderr, "nothing to remap")
return
}
if !*apply {
fmt.Fprintf(os.Stderr, "DRY RUN — %d view(s) would be remapped; pass --apply to commit\n", len(pending))
return
}
tx, err := pool.Begin(ctx)
if err != nil {
die("begin: %v", err)
}
defer tx.Rollback(ctx)
for _, v := range pending {
payload, err := json.Marshal(v.filter)
if err != nil {
die("marshal %s: %v", v.slug, err)
}
if _, err := tx.Exec(ctx,
`UPDATE projax.views SET filter_json = $1::jsonb WHERE slug = $2`,
string(payload), v.slug); err != nil {
die("update %s: %v", v.slug, err)
}
}
if err := tx.Commit(ctx); err != nil {
die("commit: %v", err)
}
fmt.Fprintf(os.Stderr, "committed %d remap(s)\n", len(pending))
}
func loadMap(path string) (map[string]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
out := map[string]string{}
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
return out, nil
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

349
cmd/projax-snapshot/main.go Normal file
View File

@@ -0,0 +1,349 @@
// projax-snapshot dumps the current projax.items + projax.item_links state
// to a JSON file so the mBrian-side migration script (m/mBrian#73) can
// consume it. Read-only; no schema changes; idempotent across runs.
//
// Phase 6 Slice 0 — first projax-side step in the mBrian-backend migration.
// See docs/plans/mbrian-backend-migration.md §7 + §8 for the surrounding
// context. The file shape is documented in the m/mBrian#73 issue body
// (the two-pass node-then-edge layout the migration script expects).
//
// Usage:
//
// projax-snapshot # write ./projax_snapshot.json
// projax-snapshot --out path/to/file.json # custom output path
//
// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL into
// msupabase (same conventions as the main projax binary).
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Snapshot is the top-level JSON shape mBrian-side consumes.
type Snapshot struct {
Version string `json:"version"` // doc-evolution marker; bump on shape changes
GeneratedAt time.Time `json:"generated_at"`
GitCommit string `json:"git_commit,omitempty"` // optional build-time injection
Items []Item `json:"items"`
Links []ItemLink `json:"links"`
SpotChecks []SpotCheck `json:"spot_checks"` // 5 representative items per m/mBrian#73 §3
}
// Item mirrors every column on projax.items as of this commit. Field
// order matches the SQL projection; types are JSON-friendly (uuid →
// string, jsonb → map). Anything nullable surfaces as omitempty / *T.
type Item struct {
ID string `json:"id"`
Kind []string `json:"kind"`
Title string `json:"title"`
Slug string `json:"slug"`
Paths []string `json:"paths"`
ParentIDs []string `json:"parent_ids"`
ContentMD string `json:"content_md"`
Aliases []string `json:"aliases"`
Metadata map[string]any `json:"metadata"`
Status string `json:"status"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
Tags []string `json:"tags"`
Management []string `json:"management"`
Public bool `json:"public"`
PublicDescription string `json:"public_description,omitempty"`
PublicLiveURL string `json:"public_live_url,omitempty"`
PublicSourceURL string `json:"public_source_url,omitempty"`
PublicScreenshots []string `json:"public_screenshots,omitempty"`
TimelineExclude []string `json:"timeline_exclude,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ItemLink mirrors projax.item_links. ref_type values become projax-*
// edge rel names on the mBrian side; the payload lands in edges.metadata
// per the issue body §1.
type ItemLink struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
RefType string `json:"ref_type"`
RefID string `json:"ref_id"`
Rel string `json:"rel"`
Note *string `json:"note,omitempty"`
Metadata map[string]any `json:"metadata"`
EventDate *time.Time `json:"event_date,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// SpotCheck names one of the 5 representative items the mBrian-side
// script verifies post-migration. The reason text is mirrored from
// m/mBrian#73 §3 so future readers don't need to cross-reference.
type SpotCheck struct {
ItemID string `json:"item_id"`
Slug string `json:"slug"`
Title string `json:"title"`
Reason string `json:"reason"`
}
func main() {
out := flag.String("out", "projax_snapshot.json", "output JSON path")
flag.Parse()
dbURL := os.Getenv("PROJAX_DB_URL")
if dbURL == "" {
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
}
if dbURL == "" {
die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
die("pool: %v", err)
}
defer pool.Close()
items, err := loadItems(ctx, pool)
if err != nil {
die("load items: %v", err)
}
links, err := loadLinks(ctx, pool)
if err != nil {
die("load links: %v", err)
}
spots := pickSpotChecks(items, links)
snap := Snapshot{
Version: "1",
GeneratedAt: time.Now().UTC(),
Items: items,
Links: links,
SpotChecks: spots,
}
buf, err := json.MarshalIndent(snap, "", " ")
if err != nil {
die("marshal: %v", err)
}
if err := os.WriteFile(*out, buf, 0644); err != nil {
die("write %s: %v", *out, err)
}
fmt.Fprintf(os.Stderr,
"wrote %s — %d items, %d links, %d spot-checks\n",
*out, len(items), len(links), len(spots))
}
func loadItems(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) {
rows, err := pool.Query(ctx, `
SELECT id, kind, title, slug, paths, parent_ids, content_md, aliases,
metadata, status, pinned, archived, start_time, end_time,
tags, management,
public, coalesce(public_description, ''),
coalesce(public_live_url, ''),
coalesce(public_source_url, ''),
public_screenshots,
timeline_exclude,
created_at, updated_at
FROM projax.items
WHERE deleted_at IS NULL
ORDER BY paths NULLS FIRST, slug`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Item{}
for rows.Next() {
var it Item
if err := rows.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs,
&it.ContentMD, &it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Tags, &it.Management,
&it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL,
&it.PublicScreenshots, &it.TimelineExclude, &it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
// Normalise empty slices: pgx hands back nil for empty array
// columns, which renders as `null` in JSON. Coerce to [] for
// downstream-script ergonomics.
if it.Kind == nil {
it.Kind = []string{}
}
if it.Paths == nil {
it.Paths = []string{}
}
if it.ParentIDs == nil {
it.ParentIDs = []string{}
}
if it.Aliases == nil {
it.Aliases = []string{}
}
if it.Tags == nil {
it.Tags = []string{}
}
if it.Management == nil {
it.Management = []string{}
}
if it.PublicScreenshots == nil {
it.PublicScreenshots = []string{}
}
if it.TimelineExclude == nil {
it.TimelineExclude = []string{}
}
if it.Metadata == nil {
it.Metadata = map[string]any{}
}
out = append(out, it)
}
return out, rows.Err()
}
func loadLinks(ctx context.Context, pool *pgxpool.Pool) ([]ItemLink, error) {
rows, err := pool.Query(ctx, `
SELECT id, item_id, ref_type, ref_id, rel, note, metadata,
event_date, created_at
FROM projax.item_links
ORDER BY item_id, ref_type, created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ItemLink{}
for rows.Next() {
var l ItemLink
if err := rows.Scan(
&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note,
&l.Metadata, &l.EventDate, &l.CreatedAt,
); err != nil {
return nil, err
}
if l.Metadata == nil {
l.Metadata = map[string]any{}
}
out = append(out, l)
}
return out, rows.Err()
}
// pickSpotChecks selects the 5 representative items the mBrian-side
// migration script verifies post-migration, per m/mBrian#73 §3:
//
// 1. A simple root area (dev).
// 2. A single-parent project (dev.paliad — or whichever single-parent
// project we can find).
// 3. A multi-parent project (any item with >1 parent_id).
// 4. A project with a caldav-list link.
// 5. A project with public=true and public_description / public_live_url
// populated.
//
// Failures to find any one of the 5 are non-fatal — the SpotChecks slice
// just shrinks. mBrian-side script logs whatever's missing.
func pickSpotChecks(items []Item, links []ItemLink) []SpotCheck {
byID := map[string]*Item{}
for i := range items {
byID[items[i].ID] = &items[i]
}
caldavItems := map[string]bool{}
for _, l := range links {
if l.RefType == "caldav-list" {
caldavItems[l.ItemID] = true
}
}
out := []SpotCheck{}
// 1. Root area "dev" if present.
for _, it := range items {
if it.Slug == "dev" && len(it.ParentIDs) == 0 {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "root area (dev) — verify type=['project'] + metadata.projax.kind='area' round-trip",
})
break
}
}
// 2. Single-parent project — prefer dev.paliad if present, else any.
added2 := false
for _, it := range items {
if it.Slug == "paliad" && len(it.ParentIDs) == 1 {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "single-parent project (dev.paliad) — verify one child_of edge",
})
added2 = true
break
}
}
if !added2 {
for _, it := range items {
if len(it.ParentIDs) == 1 && !containsString(it.Kind, "mai-managed") {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "single-parent project — verify one child_of edge",
})
break
}
}
}
// 3. Multi-parent project — any item with cardinality(parent_ids) > 1.
for _, it := range items {
if len(it.ParentIDs) > 1 {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: fmt.Sprintf("multi-parent project (%d parents) — verify all child_of edges land", len(it.ParentIDs)),
})
break
}
}
// 4. Project with a caldav-list link.
for _, it := range items {
if caldavItems[it.ID] {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "caldav-list-linked project — verify edges.metadata.url payload round-trip",
})
break
}
}
// 5. Project with public=true + public_description populated.
for _, it := range items {
if it.Public && it.PublicDescription != "" {
out = append(out, SpotCheck{
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
Reason: "public-listing project — verify metadata.projax.public.* bundle preserved for flexsiebels renderer",
})
break
}
}
// Stable order for deterministic output.
sort.SliceStable(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
return out
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -65,12 +66,31 @@ func main() {
logger.Info("migrations applied")
}
srv, err := web.New(store.New(pool), logger)
st := store.New(pool)
srv, err := web.New(st, logger)
if err != nil {
logger.Error("server init", "err", err)
os.Exit(1)
}
srv.Version = gitCommit
// Phase 6 Slice B — backend selector. PROJAX_BACKEND=mbrian flips the
// read path to the mBrian-backed adapter; default keeps the legacy
// pgx-against-projax.items path so production rollback is one env
// flip. Writes still flow through srv.Store either way.
backend := strings.ToLower(strings.TrimSpace(os.Getenv("PROJAX_BACKEND")))
switch backend {
case "mbrian":
srv.Items = store.NewMBrianReader(pool)
logger.Info("backend=mbrian (read path via store.MBrianReader)")
case "", "store":
// Default — srv.Items is the *Store from web.New.
logger.Info("backend=store (read path via legacy *store.Store)")
default:
logger.Error("unknown PROJAX_BACKEND value", "value", backend)
os.Exit(1)
}
logger.Info("startup", "version", gitCommit)
if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" {

View File

@@ -0,0 +1,434 @@
# mBrian-as-backend migration — Phase 6 design
**Status**: Phase A design — re-baselined against live mBrian schema (2026-05-29).
**Branch**: `mai/kahn/phase-6a-mbrian-design`.
**Author**: kahn (inventor), 2026-05-29.
**Source decision** (m, issue m/projax#5, 12:43 2026-05-29): Option A — full backend migration. *"I think we need the project-management element inside of mBrian for it to be the complete 2nd Brain experience. The data itself is not too important yet."*
**m's overriding directive** (2026-05-29 via head): *"keep the database simple so it remains easily modifiable."*
**Constraint**: data-loss tolerant on the 47 current `projax.items`.
**m's answers on §10 (2026-05-29)**: every inventor pick confirmed.
> Q1=reuse 'project' / Q2=(b) handler bridge / Q3=(a) clients projax-side / Q4=(a) file Gitea on m/mBrian via otto/head — m: *"mbrian must own the migration"* / Q5=(a) views stay projax-resident / Q6=(a) per-user slug / Q7=(a) hard-cut / Q8=(a) tags in metadata / Q9=(a) projax-side cycle detection / Q10=(a) keep projax MCP via adapter / Q11=keep `projax_origin` audit metadata.
**Re-baseline note**: §3's original ask was built off a stale `db/001_initial_schema.sql` read. Head verified the live mBrian schema after m's answers. Three of the six asks (MB-A, MB-C, MB-D) turned out already-satisfied — `edges.metadata` exists since `db/010_flexsiebels_compat.sql`, `'project'` type exists since `db/033`, the per-user slug unique index ships in `db/001`. The remaining mBrian-side artifact is small. §3 + §8 now reflect that. The big shift: **mBrian owns the one-shot data-migration script** — that's what "mbrian must own the migration" means — while projax owns the read+write rewiring on its own side afterward.
---
## §1 — Diagnosis
projax today stores its own structured data in `projax.items` + `projax.item_links` (msupabase, schema `projax`). It's a parallel knowledge surface to mBrian's main graph — both store nodes-with-content-and-edges, both speak SQL+jsonb, both ship MCP. The duplication has cost: project context (held by projax) is invisible to mBrian's reasoning paths; mBrian's relationship graph (held by mbrian) is invisible to projax's tile / timeline aggregations.
m's call closes the gap by making mBrian canonical. Projax keeps its UI — the /views routes, the Tiles dashboard, the calendar grid, the timeline spine, the /tree forest, the just-shipped /views/{slug} family, and the system-view chrome — but every read and write goes through mBrian instead of `projax.items`. Same surface, single source.
End-state contract:
- One node graph. Every project, task-context, area, link bundle lives in `mbrian.nodes` + `mbrian.edges`.
- projax's UI is a structured editor + aggregation surface over that graph (think paliad-shape views, mBrian-shape data).
- mBrian's existing surfaces (the web editor, the trackers, the synthesis filings) keep working unchanged — projax data appears alongside everything else.
- CalDAV / Gitea / mai.projects integrations stay projax-handled at the consumption layer; the items they hang off of live in mBrian.
- The 47-item migration is one-shot. Anything lossy gets logged + flagged for manual repair; we don't preserve at all costs.
---
## §2 — Schema mapping (the load-bearing section)
### Per-column map: `projax.items` → mBrian shape
| projax column | mBrian destination | notes |
|---|---|---|
| `id` (uuid) | `nodes.id` | new uuids on migration; legacy ids never round-trip |
| `kind` (text[]) | `nodes.type` | direct shape match; projax `'project'` becomes mBrian `'project'` (already in live schema, mig 033). **Areas keep `type=['project']` + `metadata.projax.kind='area'`** — per m's "keep the database simple" directive, no new mBrian type. Zero DDL. |
| `title` | `nodes.title` | 1:1 |
| `slug` | `nodes.slug` | mBrian = unique per user; projax = unique per parent — see §2.1 |
| `paths` (text[]) | derived from `child_of` edges + `nodes.path` cache | DAG resolution via edge walk; see §2.2 |
| `parent_ids` (uuid[]) | edges `(source=this, rel='child_of', target=parent)` | one edge per parent; preserves multi-parent |
| `content_md` | `nodes.content_md` | 1:1 |
| `aliases` (text[]) | `nodes.aliases` | 1:1 |
| `metadata` (jsonb) | `nodes.metadata` | merge; projax metadata keeps its existing shape under a `projax` sub-key to avoid colliding with mBrian's metadata schema |
| `status` (text) | `nodes.metadata.projax.status` | active/done/archived; mBrian's `archived` bool covers part of it but loses the active/done split |
| `pinned` (bool) | `nodes.pinned` | 1:1 |
| `archived` (bool) | `nodes.archived` | 1:1; status='archived' implies this too |
| `start_time`, `end_time` (timestamptz) | `nodes.metadata.projax.start_time` / `end_time` | mBrian has no first-class start/end |
| `tags` (text[]) | `nodes.metadata.projax.tags` | mBrian convention puts tags as separate `[tag]` nodes joined via `tagged` edges; we keep tags in metadata for the migration window then optionally re-shape — see Q8 |
| `management` (text[]) | `nodes.metadata.projax.management` | mai/self/external/unmanaged — projax-specific concept; stays in metadata |
| `public`, `public_description`, `public_live_url`, `public_source_url`, `public_screenshots` | `nodes.metadata.projax.public.{...}` | mBrian's `visibility` is a different model (personal/public/...); we keep projax's bundle in metadata so the flexsiebels portfolio renderer keeps working |
| `timeline_exclude` (text[]) | `nodes.metadata.projax.timeline_exclude` | projax-only concept |
| `created_at` | `nodes.created_at` | 1:1 |
| `updated_at` | `nodes.updated_at` | trigger-maintained on both sides |
| `deleted_at` | `nodes.deleted_at` | 1:1 |
### §2.1 — Slug uniqueness (settled)
projax today enforces slug uniqueness **per parent**. mBrian's live schema has `CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` — uniqueness **per user**. Per m's Q6=(a), projax adopts mBrian's model: one `paliad` node total, connected to both `dev` and `work` via two `child_of` edges. The DAG-as-multiple-paths view is a render-time concept; the storage is one node.
projax handlers' itemwrite validator (Phase 5c) loses its per-parent slug rule, gains a per-user check (against the projax-managed subset of nodes). This is **stricter** — m can't have two different "paliad" projects under different roots. Settled per m's answer.
**Pre-migration dedup**: the 47-item migration script (which lives mBrian-side, see §3+§7) scans for slug collisions across the projax dataset and folds collisions into one node with multiple `child_of` edges. Skip-with-log on anything weirder.
### §2.2 — paths array vs single path
projax's `paths text[]` is computed from `parent_ids` (one path per ancestor lineage). mBrian's `path text` is a single denormalized cache; the canonical structure is `child_of` edges.
For projax UI to keep showing multi-paths ("Also at: work.paliad"), the store-adapter layer (§4) re-derives `paths[]` from the edge graph on each fetch. Cheap at m's scale (≤200 nodes); cache lightly if profiling bites.
### `projax.item_links` → mBrian edges
Each `item_links` row becomes a mBrian edge with a typed `rel`. The `ref_id` semantics differ:
| projax ref_type | mBrian shape | notes |
|---|---|---|
| `caldav-list` | edge `rel='projax-caldav-list'`, `metadata.url=...` | external URL — no target node exists; edge carries the URL in `note` or `metadata` |
| `gitea-repo` | edge `rel='projax-gitea-repo'`, metadata={owner, repo} | same shape |
| `gitea-issue` | edge `rel='projax-gitea-issue'`, metadata={owner, repo, number} | same |
| `mai-project` | edge `rel='projax-mai-project'`, metadata={mai_project_id} | bridge for the Phase 1.5 bidirectional sync |
| `mbrian-node` | edge `(source=this, rel='related_to', target=<mbrian uuid>)` | already mBrian — this becomes a regular node-to-node edge |
| `url` | edge `rel='projax-url'`, metadata={url} | unstructured link |
| `document`, `note` | edge `rel='projax-doc'`, metadata={...} | PER day-granular dated artifacts |
mBrian edges support `note text` plus an `auto bool` flag. Both used by projax: `auto=false` for human-added links, `note` carries human annotation. The structured payload (URL, repo info, etc.) lands in a metadata jsonb that we add via a new `edges.metadata` column — see §3.
### Open question on edge payloads
mBrian's `edges` table today has no `metadata jsonb` column — only `rel`, `note`, `sort_order`, `node_id`, `auto`. For projax's typed external-ref payloads (caldav URLs, gitea repo names), we need either:
- (a) Add `metadata jsonb` to `mbrian.edges` (mBrian-side schema work, see §3 Q-A).
- (b) Use the `node_id` "complex edge" feature: the edge points at a third node that holds the metadata. Heavier per-link cost; one node per external ref.
- (c) Stash structured payload inside `note text` as JSON. Hacky; loses index-ability.
**Inventor pick: (a)** — adds one nullable column to `edges`, indexes optionally, keeps the simple shape and matches projax's existing item_links model.
---
## §3 — mBrian-side requirements (re-baselined against live schema)
Head verified the live mBrian schema after m's answers. Three of the original six asks turned out already-satisfied. What's actually needed reduces to one [schema] convention node + ownership of the one-shot data-migration script. Per m's Q4=(a), this lands as a Gitea issue on `m/mBrian` with the "blocks projax phase 6" tag; head files it.
### Already satisfied (no DDL needed)
| original ask | live-schema status |
|---|---|
| MB-A — `edges.metadata jsonb` column | **Already exists** — added in `db/010_flexsiebels_compat.sql`: `ALTER TABLE mbrian.edges ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'` plus GIN `idx_edges_metadata`. Already used by mig 039/040. projax link payloads land here directly. |
| MB-C — `'project'` type registration | **Already exists** — confirmed in `db/033` + inbox tests. m's Q1=(a) reuses it. |
| MB-C — `'area'` type registration | **NOT needed** — per m's "keep the database simple," areas reuse `type=['project']` with `metadata.projax.kind='area'`. Zero DDL. |
| MB-D — per-user slug uniqueness | **Already enforced**`CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` in `db/001`. Handles the bulk migration as-is, modulo the pre-write dedup pass in the script (§7). |
| MB-E — read MCP coverage | **Confirmed** by head — type-array filter, edge query by `rel` + source/target, FTS search all present in mBrian's MCP today. Optional bulk "node + outbound edges" endpoint may improve adapter perf, but v1 ships without it. |
| MB-F — write MCP coverage | **Confirmed** by head — create_node, update_node, soft-delete, create_edge, delete_edge all present. |
### Remaining mBrian-side artifact
**MB-B — projax-integration `[schema]` convention node.** One new mBrian node, no DDL. Lives under a new `[topic]` hub `projax-integration`. Documents:
1. The projax edge relations: `child_of` (already in use everywhere), `projax-caldav-list`, `projax-gitea-repo`, `projax-gitea-issue`, `projax-mai-project`, `projax-url`, `projax-doc`. Each entry: rel name + the metadata jsonb shape (e.g. `projax-caldav-list` carries `{url: text}`).
2. The projax type usage: `'project'` for both projects and areas; `metadata.projax.kind` distinguishes (`area` vs default `project`). `'mai-managed'` as a co-type marker for nodes mirroring `mai.projects` rows.
3. The projax metadata shape: `metadata.projax.{status, tags, management, public, timeline_exclude, start_time, end_time, kind}` — the subset of projax columns that don't have a first-class mBrian counterpart.
4. A pointer to `projax_origin` audit metadata (set per migrated node, per m's Q11=keep).
mBrian-side coder writes this node by creating it via mBrian's editor or MCP. No migration file needed.
### mBrian owns the data-migration script
Per m's directive "mbrian must own the migration," the one-shot script that creates the 47 nodes + their edges lives in `m/mBrian` (likely `scripts/migrate-from-projax.ts` or similar — mBrian's stack picks). projax-side provides:
- A frozen snapshot of `projax.items` + `projax.item_links` rows (CSV or JSON dump produced by a projax-side helper).
- The mapping rules from §2 + §2.2 in a form mBrian-side can implement against (this plan doc is the canonical source).
- A spot-check checklist (5 representative items) for post-migration validation.
The script's blast radius lives on mBrian's side; projax-side blocks on its successful run before slice C kicks off.
### Cross-repo coordination shape
One Gitea issue on `m/mBrian` (filed by head), tagged "blocks projax phase 6". The issue body covers MB-B + script ownership + the snapshot-handoff protocol. Body draft delivered to head with this re-baseline (see Phase A workflow §14).
---
## §4 — projax-side read-path replacement
The store package becomes a thin adapter over mBrian. Consumers stay shape-stable: `*store.Item` still exposes Kind / Title / Slug / Paths / ParentIDs / ContentMD / Aliases / Metadata / Status / Pinned / Archived / Tags / Management / Public* / TimelineExclude / etc. Internally those come from mBrian nodes + metadata + edge-walks.
| projax call site | new implementation |
|---|---|
| `store.Store.ListAll(ctx)` | mBrian: `SELECT FROM mbrian.nodes WHERE 'projax' = ANY(metadata.projax_origin) ... ORDER BY title` (or via MCP `list_nodes`). Returns []*Item adapted from each node. |
| `store.Store.GetByPath(ctx, path)` | resolve path → leaf node by walking `child_of` edges from the path's root segment; cache hits during render |
| `store.Store.GetByID(ctx, id)` | direct mBrian fetch |
| `store.Store.LinksByRefType(ctx, t)` | edge query `rel='projax-<t>'` over all projax-managed nodes |
| `store.Store.AllTags(ctx)` | aggregate over `metadata.projax.tags` arrays across projax nodes |
| `store.Store.MaiOrphans(ctx)` | mBrian: find projax-managed nodes with no `child_of` edge + `metadata.projax.management contains 'mai'` |
| `store.Store.DatedLinks(ctx, id)` | edge query `rel IN ('projax-doc', 'projax-url')` for the node, filtered to those with `metadata.event_date` set |
The aggregator (`internal/aggregate/`) doesn't see mBrian — it gets `[]*store.Item` from the adapter. CalDAV + Gitea external fetches stay where they are.
Views (Phase 5j `projax.views` table) decision point — see Q5.
### Adapter layer surface
```go
package store
type Store struct {
mb *mbrian.Client // MCP-style client or direct SQL
}
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) { ... }
// every existing method keeps its signature; bodies rewrite to mBrian calls
```
The Item struct stays unchanged. Tests against the adapter assert "given this mBrian state, ListAll returns these items". Existing aggregator + handler tests stay green because they only see `*Item`.
---
## §5 — projax-side write-path replacement
Every projax write rewires to mBrian.
| projax handler | new behaviour |
|---|---|
| `POST /i/{path}` (detail edit, `handleDetailWrite`) | mBrian update_node + edge re-write for `parent_ids` changes |
| `POST /new` (`handleNewSubmit`) | mBrian create_node + `child_of` edges |
| `POST /i/{path}/reparent` (`handleReparent`) | edge delete + re-create for `child_of` |
| `/admin/bulk` (`handleBulkApply`, `handleBulkChip`) | bulk mBrian updates; one mBrian write per row |
| `/admin/classify` (`handleClassify`) | mBrian update + add `child_of` edge |
| `POST /views/...` (5j editor) | unchanged if views stay in `projax.views`; rewired if they move (Q5) |
| MCP `create_item` / `update_item` / `delete_item` | mBrian MCP create / update / soft_delete |
| MCP `add_link` / `remove_link` | mBrian create_edge / delete_edge |
### Validation (Phase 5c itemwrite package)
The pre-flight validator stays as projax-handler logic — projax UI / MCP still surface friendly errors for `KindInvalidSlugFormat` / `KindSlugCollision` / `KindCycle` / etc. before round-tripping. The DB-level enforcement moves to mBrian's per-user unique index on slug (covers collision) + projax's `paths` recomputation (covers cycle detection). Trigger-level cycle detection on mBrian's edges is a mBrian-side ask (mb-G optional).
### Cycle + slug-collision semantics
Per §2.1: projax loses per-parent slug uniqueness; per-user uniqueness wins. The validator's KindSlugCollision rule needs updating to reject any duplicate slug across the whole projax-managed set, not just under the same parent.
Cycle detection: projax today does it via the path trigger (cycle = self-ancestor). After migration, projax fetches all projax nodes + their child_of edges, walks the closure on every write, rejects cycles. Cheap at m's scale.
---
## §6 — Integrations (CalDAV / Gitea / mai.projects)
### CalDAV + Gitea
The link bundle (per §2.2) moves to mBrian edges with structured metadata. The CalDAV / Gitea **clients** + their caches stay projax-side (the aggregator owns these). The render path queries mBrian for "which items have caldav-list edges + what URLs," then fans out to the existing CalDAV client. Net effect: the fan-out stays where it is; only the source of "what to fan out for" changes.
### mai.projects bidirectional sync (Phase 1.5)
The Phase 1.5 trigger pair (mai.projects ↔ projax.items) is the most fragile piece of the integration today. After Phase 6:
- (a) **Keep the trigger pair**, pointing the mai.projects view at the migrated mBrian nodes. Requires rewriting the trigger functions to read from mBrian; significant complexity because mai.projects expects projax.items columns.
- (b) **Move the bridge to projax handler layer**: a sync worker watches mai.projects changes + writes mBrian; mBrian node changes flow back via a webhook or periodic poll. Slower but decoupled.
- (c) **Drop the bridge entirely**: mai.projects becomes legacy; mai workers consume mBrian directly via MCP. Cleanest, but requires mai-side work to migrate workers/tasks/sessions FKs.
**Inventor pick: (b)** — the bridge stays operational without bleeding mBrian schema details into mai.projects code, and m can sunset it gradually. (c) is the right long-term shape but it's another migration project; out of scope for Phase 6.
This is **Q2** for m.
---
## §7 — Migration mechanics (mBrian-owned)
Per m's Q7=(a) hard-cut + Q4=(a) "mbrian must own the migration": the one-shot script lives in `m/mBrian`. projax-side provides the input snapshot + the rules in this doc; mBrian-side owns the execution.
### projax-side input snapshot
A helper command in `cmd/projax-snapshot/main.go` produces a `projax_snapshot.json` containing every live `projax.items` row + every `projax.item_links` row, shaped for direct consumption by the mBrian-side script. One file, deterministic, round-trippable. Ships in slice 0 (the snapshot handoff, see §8).
### mBrian-side script outline (for the m/mBrian issue body)
1. Load `projax_snapshot.json`.
2. Two-pass: pass 1 creates every node; pass 2 writes every edge (parent edges + item_links → projax-* edges).
3. For each item:
a. New mBrian uuid OR preserve the projax uuid (mBrian-side picks; either works given m's Q11 audit metadata is the durable reference).
b. INSERT into `mbrian.nodes` with `type=['project']` (or `['project']` + co-type per `kind`), `title`, `slug`, `aliases`, `metadata={projax: {...}, projax_origin: <old_id>}`.
c. Where projax had multiple paths (same node under multiple parents), DEDUPE by slug — one node, multiple `child_of` edges.
4. For each parent edge: INSERT `mbrian.edges (source=new_id, target=parent_new_id, rel='child_of')`.
5. For each item_links row: INSERT `mbrian.edges` with `rel='projax-<ref_type>'` and `metadata` carrying the structured payload per §2.2.
6. For projax.views (5j): NOT migrated — per m's Q5=(a), the views table stays projax-resident.
7. Smoke check: count(mbrian.nodes WHERE metadata->>'projax_origin' is not null) == count(items in snapshot).
8. Hand off to projax with the new uuid map (`{old_uuid: new_uuid}`) so projax-side caches can warm.
### Idempotency
Pre-flight: the script checks `metadata.projax_origin` and skips already-migrated origins on re-run. m can re-run safely if the script aborts mid-way.
### Lossy bits (acceptable per m's stance)
- `paths text[]` array is not preserved — projax-side adapter recomputes from edges per §4.
- mai.projects mirror rows: per Q2=(b), a handler-layer bridge worker re-syncs after migration; the Phase 1.5 trigger pair stays disabled.
### Blast-radius containment
mBrian-side runs the script with triggers paused, smoke-checks the count + spot-checks the 5 representative items in projax's checklist, then commits + signals projax-side to start slice C (read-path).
---
## §8 — Implementation slicing (re-baselined)
Six slices. The big shift from the original draft: the mBrian-side ask compresses to one [schema] convention node + one migration script (both mBrian-owned per m's Q4). Slice 0 is a small projax-side helper that ships the snapshot. The hard gate is the migration landing — projax-side B reads it as the trigger to start.
- **0. projax-side snapshot helper** — `cmd/projax-snapshot/main.go`. Dumps live `projax.items` + `projax.item_links` to `projax_snapshot.json`. Ships first; minimal risk; deliverable mBrian needs.
- **A. mBrian-side: [schema] convention node + data-migration script** — m/mBrian owns. The [schema] node lives under a new `[topic]` hub `projax-integration`. The script consumes the snapshot from slice 0 and writes 47ish nodes + their edges per §7. mBrian-side post-flight: smoke-check count + spot-check 5 items per the projax checklist.
- **B. projax-side read-path adapter** — projax-side. `store/` package rewired against mBrian's MCP / SQL surface. The `Item` struct stays; method bodies rewrite. All UI + aggregator tests stay green (they only see Item shape). Per-request snapshot cache to avoid N+1 calls. Reads-only soak before slice C.
- **C. projax-side write-path** — projax-side. Every handler + MCP write rewires through the adapter to mBrian. itemwrite validator updates for the per-user slug rule (Q6). Cycle detection on the in-memory closure (Q9).
- **D. mai.projects bridge worker** — projax-side (Q2=(b)). Disable the Phase 1.5 trigger pair; ship a small worker that observes mai.projects writes + reflects them into mBrian, and vice versa. Decoupled, killable.
- **E. Drop `projax.items` + `projax.item_links`** — projax-side. Migration `0018_drop_projax_items.sql`. Triggers off after one shift's stable read+write soak on mBrian. `projax.views` stays (Q5).
Dependency graph:
```
0 (projax snapshot) ──→ A (mBrian [schema] node + migration script run)
B (projax read-path) ──→ C (projax write-path)
├──→ D (mai bridge worker)
E (drop projax tables)
```
Slice 0 unblocks A. A is mBrian-owned and the hard gate for everything else. B → C can ship together if green; otherwise B-first soak.
CalDAV / Gitea integrations stay where they are (Q3=(a)) — no slice F needed in the original sense.
---
## §9 — Cross-repo coordination (settled)
Per m's Q4=(a) + his words *"mbrian must own the migration"*:
1. **Protocol**: file a Gitea issue on `m/mBrian` with "blocks projax phase 6" tag. Routed via otto/head per global Channel Routing. Head files it; kahn drafts the body.
2. **Ownership split**:
- mBrian-side owns: the `[schema]` convention node (MB-B) + the one-shot data-migration script.
- projax-side owns: the snapshot helper (slice 0), the read-path adapter (slice B), the write-path (slice C), the mai bridge (slice D), the table drop (slice E).
3. **Sequencing**: slice 0 produces the snapshot → mBrian-side A consumes it + runs the migration → mBrian-side signals back → projax-side starts B. The Gitea issue is the durable trace; the delegation reply chain is the real-time signal.
4. **Design-doc sharing**: this plan stays in `m/projax`. The m/mBrian issue body (drafted alongside this re-baseline, delivered to head) excerpts §2 (schema mapping), §3 (the one [schema] node ask), §7 (the script outline), and the spot-check checklist.
---
## §10 — Open questions (all answered 2026-05-29)
All 11 questions resolved. m confirmed every inventor pick. Section retained as the historical record + so a future hand can audit the decision rationale.
The 8 from issue #5 plus what surfaced during this survey.
**Q1 — mBrian node type for projax items**
- (a) Reuse existing `'project'` type, add `'area'` if missing, multi-typed for both. — **inventor pick** (existing type minimises mBrian-side churn).
- (b) New dedicated `'projax-item'` / `'work-item'` type.
**Q2 — mai.projects bidirectional sync disposition** (§6)
- (a) Keep the trigger pair (rewrite to read from mBrian).
- (b) Move to projax handler-layer bridge worker. — **inventor pick** (clean decoupling).
- (c) Drop entirely; migrate mai-side FKs.
**Q3 — CalDAV + Gitea integration ownership** (§6)
- (a) Clients + caches stay projax-side; only the "which items have these links" lookup moves to mBrian. — **inventor pick** (minimal change to aggregator).
- (b) Migrate CalDAV/Gitea ownership to mBrian edges + projax becomes a pure renderer.
**Q4 — mBrian head contact protocol** (§9)
- (a) Through otto/head per Channel Routing (default per global rule). — **inventor pick**.
- (b) Direct to a future mBrian/head worker.
- (c) m himself owns mBrian schema work — file Gitea issue on m/mBrian.
**Q5 — projax.views (5j) disposition**
- (a) Keep as projax-resident table — views are projax-UI state, not graph data. — **inventor pick**.
- (b) Migrate to mBrian nodes with type=`[view]`; one node per saved view.
- (c) Drop the table; user views become a derived shape from mBrian metadata on the items themselves.
**Q6 — Slug uniqueness model**
- (a) Adopt mBrian's per-user unique (loses "two paliads under different roots" case). — **inventor pick** (simpler; m hasn't used the per-parent split in practice).
- (b) Keep projax's per-parent rule via projax-handler validator + mBrian per-user check disabled for projax nodes (requires mBrian-side scoped-uniqueness work).
**Q7 — Migration mechanics** (§7)
- (a) Hard-cut, one script, accept data loss. — **inventor pick** (matches m's stance).
- (b) Phased dual-write + soak.
**Q8 — Tags model**
- (a) Keep tags in `metadata.projax.tags` (projax sees them as before; mBrian doesn't index them). — **inventor pick** for v1.
- (b) Lift each tag to a `[tag]` node + `tagged` edges (mBrian convention).
- (c) Hybrid — keep metadata for projax compatibility AND wire tagged-edges for mBrian visibility.
Q8(c) is the "right" long-term shape but doubles the write surface in slice D. Recommend deferring to a Phase 7 polish.
**Q9 — Cycle detection placement**
- (a) projax-handler-side via in-memory closure walk before write. — **inventor pick** (cheap at m's scale).
- (b) mBrian-side via trigger on `edges` (mb-G ask).
**Q10 — Projax MCP surface**
- (a) Keep projax MCP tools (`mcp__projax__*`); they now route through the adapter. — **inventor pick** (no MCP client change).
- (b) Sunset projax MCP; users call mBrian MCP directly.
**Q11 — `projax_origin` audit metadata** (§7)
Per the migration script, every migrated node carries `metadata.projax_origin = <old uuid>`. Keep indefinitely (audit trail), purge after one shift (cleanup), or never write it (trust). **Inventor pick**: keep indefinitely.
---
## §11 — Risk register
| risk | likelihood | mitigation |
|---|---|---|
| mBrian-side schema work (slice A) blocks projax indefinitely | medium | clear delegation + Gitea issue with "blocks projax phase 6" tag; m can dispatch fast-track |
| 47-item migration script silently drops fields | low | smoke check (item count parity) + spot-check 5 items post-migration before slice C |
| Slug collision on multi-rooted items (e.g. two `paliad`s) | medium | pre-migration script: detect collisions, dedupe to one node with multiple `child_of` edges, log skips |
| mai.projects trigger pair breaks mid-migration | medium | turn off the triggers before migration, rebuild post-migration (Q2 (b) bridge takes over) |
| Adapter introduces N+1 mBrian calls during render | medium | one ListAll + one LinksByRef query per request, cached per-request; profile after slice C |
| Phase 5j views surface breaks | low | views stay projax-resident per inventor pick on Q5; no migration cost |
| flexsiebels.de public-listing renderer breaks | medium | metadata.projax.public.* bundle preserves the shape; spot-test before slice E |
| Cross-repo coordination delay | medium | filed as Gitea issue (durable) + delegation (real-time signal); both paths active |
---
## §12 — Test plan headlines
### Slice B (migration script)
- `TestMigrateScriptSmokes` — 5 hand-crafted projax.items + 3 item_links → mBrian nodes + edges; count parity assertion.
- `TestMigrateScriptIdempotent` — second run = no new nodes.
- `TestMigrateScriptSlugCollision` — two multi-rooted items same slug → one node with two `child_of` edges, log entry.
### Slice C (read-path)
- `TestAdapterListAllReturnsItemsFromMBrian` — seed mBrian nodes with `projax_origin`, ListAll returns matching Items.
- `TestAdapterGetByPathResolvesEdges``dev.paliad` walks `child_of` edges to leaf node.
- `TestAdapterPathsArrayMultiRoot` — node with two `child_of` edges produces 2 entries in `it.Paths`.
### Slice D (write-path)
- `TestHandleDetailWriteUpdatesMBrian` — POST /i/dev.paliad updates the mBrian node's title.
- `TestHandleReparentRewritesChildOf` — POST /i/dev.paliad/reparent deletes old edge + creates new one.
- `TestSlugCollisionRejected` — second create with same slug rejected with KindSlugCollision.
### Slice E (drop)
- migration `0018_drop_projax_items.sql` smoke test: `\dt projax.*` returns only `projax.views` + `projax.schema_migrations`.
### Slice F (integrations)
- per Q2 answer — bridge-worker test (Option b) OR mai-FK migration test (Option c).
---
## §13 — References
- `~/dev/mBrian/db/001_initial_schema.sql` — mBrian schema baseline.
- `~/dev/mBrian/docs/schema.md` — schema doc.
- `~/dev/mBrian/CLAUDE.md` — mBrian conventions + relation to flexsiebels.
- `projax/store/store.go` — current Item struct + projax store API.
- `projax/store/views.go` — Phase 5j views table.
- `projax/docs/design.md` — current PRD.
- `projax/docs/plans/views-redesign.md` — Phase 5j design.
- `m/projax` issue #5 — m's Option A pick.
---
## §14 — Status
- **Phase A (this doc)**: drafted by kahn 2026-05-29, re-baselined same day against live mBrian schema after m's 11 answers landed. All §10 questions resolved.
- **m/mBrian Gitea issue**: body drafted; head files it under "blocks projax phase 6" tag.
- **Phase B (projax-side coder)**: blocked on (1) slice 0 snapshot helper ships + (2) mBrian-side migration runs + signals back. NO coder flip yet.
- **Slice 0 (projax-side snapshot helper)**: scoped, not yet built. Smallest first-step on projax-side; ready when head greenlights.
- **No code changes** in this branch beyond this doc.

View File

@@ -0,0 +1,228 @@
# Phase 6 Slice B — read-path adapter contract
**Status**: prep work (this doc). No implementation.
**Branch**: `mai/kahn/phase-6-sliceB-prep`.
**Author**: kahn (coder, prep mode), 2026-05-29.
**Parent plan**: `docs/plans/mbrian-backend-migration.md` (on `main`).
**Scope boundary**: contract + compile-checking skeleton only. The mBrian-backed implementation waits on m/mBrian#73 landing the migration + handing over the uuid map.
---
## §1 — Consumer inventory
Every read-path call site against `*store.Store` and the projax-shaped `Item` / `ItemLink` types. The interface (§2) is the union of these.
### §1.1 — `*store.Store` read methods (source: `store/store.go`)
| method | signature | semantics |
|---|---|---|
| `ListAll` | `(ctx) ([]*Item, error)` | every live item, ordered by `paths NULLS FIRST, slug` |
| `GetByID` | `(ctx, id) (*Item, error)` | single item by uuid |
| `GetByPath` | `(ctx, path) (*Item, error)` | resolve `dev.paliad` style path to leaf item |
| `GetByPathOrSlug` | `(ctx, key) (*Item, error)` | path first, fall back to bare slug |
| `Roots` | `(ctx) ([]*Item, error)` | items with `cardinality(parent_ids) = 0` |
| `MaiOrphans` | `(ctx) ([]*Item, error)` | mai-managed root items needing classify |
| `ListByFilters` | `(ctx, SearchFilters) ([]*Item, error)` | structured search (status / mgmt / has-link / paths-prefix) |
| `Search` | `(ctx, q, limit) ([]*Item, error)` | trigram + FTS title/content/aliases |
| `AllTags` | `(ctx) ([]string, error)` | union of every item's tags |
| `LinksByType` | `(ctx, itemID, refType) ([]*ItemLink, error)` | one item's links of a given `ref_type` (empty = all) |
| `LinksByRefType` | `(ctx, refType) ([]*ItemLink, error)` | every link of a given ref_type across items |
| `DatedLinks` | `(ctx, itemID) ([]*ItemLink, error)` | one item's links anchored to a date (PER artifacts) |
| `DatedLinksRange` | `(ctx, from, to) ([]*ItemLinkWithItem, error)` | dated links within window, joined with their item |
| `RecentDocuments` | `(ctx, since, limit) ([]*ItemLinkWithItem, error)` | recent dated docs, joined with their item |
| `ItemsCreatedInRange` | `(ctx, from, to) ([]*Item, error)` | items created within window |
### §1.2 — Consumer call sites (by file)
Each row = one read-path call site. Direct Pool access (admin.go counts, bulk.go filter-tx, links.go event-date update) is flagged separately at the bottom — those rework targets are out of slice B's read-path scope.
| consumer | method | use case |
|---|---|---|
| `web/server.go handleTree` | `ListAll`, `AllTags`, `linkKindsByItem` (LinksByRefType ×N) | render /views/tree with chip-counted forest |
| `web/server.go handleDetail` | `GetByPath` ×2 (PER fallback), `LinksByType` (caldav), `DatedLinks` | render /i/{path} detail page |
| `web/server.go parentOptions` | `ListAll` | populate parent <select> on /new + /reparent |
| `web/server.go handleClassify` | `MaiOrphans`, `parentOptions` | render /admin/classify |
| `web/dashboard.go handleDashboard` | `ListAll`, `LinksByRefType` (caldav), `LinksByType` (gitea) ×N, `RecentDocuments` | Tiles + tasks + events + docs cards |
| `web/calendar.go handleCalendar` | `ListAll` | month grid scope |
| `web/timeline.go handleTimeline` + `buildTimeline` | `ListAll`, `linkKindsByItem` | chronological spine |
| `web/graph.go handleGraph` | `ListAll`, `AllTags` | DAG SVG render |
| `web/bulk.go handleBulk` | `ListAll`, `AllTags`, `GetByID` | /admin/bulk filtered checklist |
| `web/caldav.go` (admin + create/unlink) | `ListAll`, `LinksByRefType`, `LinksByType`, `GetByPath` | /admin/caldav surface |
| `web/gitea.go detailIssues` | `LinksByType` (gitea-repo) | /i/{path} issues card |
| `web/gitea_writeback.go` | `GetByPath`, `LinksByType` | issue close/comment/create handlers |
| `web/links.go` (add/remove/list) | `GetByPath`, `DatedLinks` | /i/{path} documents section |
| `web/dashboard_pin.go` | `SetPinned` — WRITE, not slice B | pin toggle (slice C) |
| `web/views.go handleViewRender` | `ListAll`, `AllTags`, `linkKindsByItem` | /views/{slug} render (5j) |
| `web/system_views.go legacyRedirect` | `GetViewByID` — views CRUD (NOT in scope) | legacy 5i uuid → 5j slug redirect |
| `internal/aggregate aggregator.go` | takes `LinkLister` interface (LinksByType + ItemsCreatedInRange) | shared fan-out across tasks/events/issues/docs |
| `mcp/tools.go` (read tools) | `ListByFilters`, `LinksByRefType`, `GetByID`, `GetByPathOrSlug`, `LinksByType`, `ListAll`, `Search`, `RecentDocuments` (via dashboard fan-out reuse) | every read-side MCP tool |
### §1.3 — Direct Pool access (out-of-scope for slice B, flagged for slice C)
These bypass the store API and pull `*pgxpool.Pool` directly. Slice C (write-path) reworks them; flagging here so slice B's interface stays minimal:
- `web/admin.go` — three count queries (`SELECT count(*) FROM projax.items WHERE …`) for the admin index. Either: (a) add `Counts(ctx) (AdminCounts, error)` to the adapter, (b) compute in-handler from `ListAll`. Adapter pick.
- `web/bulk.go handleBulkApply` — multi-row UPDATE inside a tx. Pure write; slice C.
- `web/links.go handleSetEventDate` — single UPDATE on `item_links.event_date`. Pure write; slice C.
### §1.4 — `*Item` + `*ItemLink` shape contract (consumer side)
Adapter MUST return these exact field sets in the result types. Nothing under `metadata.projax.*` in mBrian leaks to consumers; the adapter parses + materialises into the `Item` fields below.
| field | semantics in slice B adapter |
|---|---|
| `Item.ID` | mBrian node uuid (post-migration); preserved old uuid OK per Q11 |
| `Item.Kind` | `[]string{"project", ...}` — mBrian `node.type[]` 1:1 |
| `Item.Title`, `Item.Slug`, `Item.ContentMD`, `Item.Aliases` | mBrian `node.title/slug/content_md/aliases` 1:1 |
| `Item.Paths` | **derived** from `child_of` edge walk + the node's own slug. Adapter computes per-call (cached per-request) |
| `Item.ParentIDs` | **derived** from outbound `child_of` edges |
| `Item.Metadata` | `node.metadata` MINUS the `projax` sub-key (which gets unpacked into the struct fields below) |
| `Item.Status` | `node.metadata.projax.status` (default "active") |
| `Item.Pinned`, `Item.Archived` | `node.pinned`, `node.archived` 1:1 |
| `Item.StartTime`, `Item.EndTime` | `node.metadata.projax.start_time` / `.end_time` (timestamptz strings) |
| `Item.Tags`, `Item.Management`, `Item.TimelineExclude` | `node.metadata.projax.tags` / `.management` / `.timeline_exclude` |
| `Item.Public`, `Item.PublicDescription`, `Item.PublicLiveURL`, `Item.PublicSourceURL`, `Item.PublicScreenshots` | `node.metadata.projax.public.{enabled, description, live_url, source_url, screenshots}` |
| `Item.CreatedAt`, `Item.UpdatedAt` | `node.created_at`, `node.updated_at` 1:1 |
| `Item.Source` | always `"projax"` (legacy field; new adapter sets this to maintain consumer assumption) |
| `Item.SourceRefID` | mai.projects.id from `projax-mai-project` edge metadata when present |
| `ItemLink.ID` | mBrian edge uuid |
| `ItemLink.ItemID` | edge `source_id` (the projax-side end) |
| `ItemLink.RefType` | strip `projax-` prefix from edge `rel` (`projax-caldav-list``caldav-list`) |
| `ItemLink.RefID` | edge `metadata.ref_id` OR derived from rel-specific payload (caldav: `url`; gitea-repo: `owner/repo`; mai-project: `mai_project_id`) — see §3 gaps |
| `ItemLink.Rel` | edge `note` (free-form annotation) OR a constant per rel-type (e.g. `'contains'`) |
| `ItemLink.Metadata` | edge `metadata` MINUS the `ref_id` extraction |
| `ItemLink.EventDate` | edge `metadata.event_date` (date string parsed) |
| `ItemLink.CreatedAt` | edge `created_at` 1:1 |
### §1.5 — Views (Phase 5j) — explicitly NOT in slice B
Per m's Q5=(a), `projax.views` stays projax-resident. All view CRUD methods (`ListViews`, `GetView`, `GetViewByID`, `CreateView`, `UpdateView`, `DeleteView`, `TouchView`, `MostRecentView`, `ReorderViews`) stay on the existing `*Store` and are NOT part of the adapter interface. The `Server` struct uses the adapter for items+links and the existing `Store` for views.
---
## §2 — Adapter interface contract
Defined in `store/adapter.go` (this branch). Pure projax-shaped structs in/out; zero mBrian type leakage. The existing `*store.Store` already satisfies this interface (it's just a subset of its public surface) — the compile-time assertion makes that explicit. Slice B impl ships a second satisfier (`*MBrianReader`) that wraps mBrian access.
```go
// ItemReader is the read-only contract every projax UI handler / aggregator /
// MCP read tool depends on. Slice B implements a second satisfier on top of
// mBrian's MCP/SQL surface.
type ItemReader interface {
// Item lookups
ListAll(ctx context.Context) ([]*Item, error)
GetByID(ctx context.Context, id string) (*Item, error)
GetByPath(ctx context.Context, path string) (*Item, error)
GetByPathOrSlug(ctx context.Context, key string) (*Item, error)
Roots(ctx context.Context) ([]*Item, error)
MaiOrphans(ctx context.Context) ([]*Item, error)
ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error)
Search(ctx context.Context, q string, limit int) ([]*Item, error)
ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error)
AllTags(ctx context.Context) ([]string, error)
// Link lookups
LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error)
LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error)
DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error)
DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error)
RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error)
}
```
### §2.1 — Methods needing edge-walk-derived data
Slice B's mBrian impl must compute these from `child_of` edges + node fields. Cost is one outbound-edges fetch per node OR one bulk edges-by-rel query per request, depending on how the adapter caches.
- `Item.Paths` — every method returning `*Item` or `[]*Item`.
- `Item.ParentIDs` — same.
- `GetByPath` — walks edges to resolve `dev.paliad` to a leaf node.
- `Roots` — filter where no outbound `child_of` edge.
- `MaiOrphans``Roots``metadata.projax.management ⊇ {'mai'}`.
### §2.2 — Methods needing metadata-unpack
Adapter parses `metadata.projax.*` on read; writes (slice C) re-serialise. Affected fields: Status, Tags, Management, TimelineExclude, Public + 4 public_* fields, StartTime, EndTime.
### §2.3 — Methods needing edge.metadata filters
- `LinksByType(itemID, refType)`: WHERE source_id=$1 AND rel = 'projax-' || $2.
- `LinksByRefType(refType)`: WHERE rel = 'projax-' || $1.
- `DatedLinks(itemID)`: source_id=$1 AND metadata ? 'event_date'.
- `DatedLinksRange(from, to)`: metadata->>'event_date' BETWEEN $1 AND $2.
- `RecentDocuments(since, limit)`: dated links since $1 ORDER BY metadata->>'event_date' DESC LIMIT $2.
mBrian's `idx_edges_metadata` GIN index already exists (mig 010); these queries are index-eligible.
---
## §3 — Gap flags
Items the known mBrian schema needs to satisfy cleanly. The migration script handles most; flag here for the slice-B impl + the migration worker as cross-check items.
| gap | shape | status |
|---|---|---|
| **`item_links.rel` (free-form annotation) preservation** | projax has both a typed `ref_type` AND a free-form `rel` text (`"contains"`, `"source"`, etc.) on item_links. mBrian's edge `rel` is the typed name; the free-form annotation maps to `edge.note`. Migration must NOT drop the projax `rel` value. | Add to m/mBrian#73 §1 edge mapping: source `rel` → mBrian `edges.note`. |
| **`ItemLink.RefID` semantics per type** | projax `ref_id` is a typed external pointer (caldav url, gitea `owner/repo`, gitea-issue id, mai project uuid, bare url). mBrian edges carry the payload in metadata. Need a per-rel-type extraction rule. Suggested: `metadata.ref_id` for the canonical reference + leaves structured payload alongside (`url` for caldav, `owner`/`repo` for gitea). | Slice B impl reads back per-rel-type; document in m/mBrian#73 issue for the migration script to write consistently. |
| **`paths text[]` recomputation cost** | Adapter computes paths from `child_of` edge walk per call. For `ListAll` over ~65 items, one bulk edges-by-rel query joined with the node id set is N rows where N = total `child_of` edges. Cheap at m's scale; add per-request memoisation. | Slice B impl. No mBrian-side action. |
| **`AllTags` aggregation** | Union of `metadata.projax.tags[]` across all projax-managed nodes. No mBrian index on metadata-array-element. At m's scale (<200 nodes), full-scan is fine; if we grow, add a derived `[tag]` node graph (m's Q8 deferred to Phase 7). | Slice B impl, no mBrian-side action. |
| **`Roots` / `MaiOrphans` predicate** | "No outbound `child_of` edge" requires a subquery / left-join-where-null pattern. Index-eligible via `idx_edges_source_rel` on `(source_id, rel)`. | Slice B impl. |
| **`ItemsCreatedInRange`** | Direct over `nodes.created_at`; trivial. Scoped to `metadata.projax_origin IS NOT NULL` so non-projax mBrian nodes don't leak into projax surfaces. | Slice B impl + a `metadata GIN` query (already indexed). |
| **`Item.Source` field expectation** | The legacy `Source` field on `Item` reads `"projax"` everywhere consumers check it (some MCP tools branch on it). Adapter sets a constant. | Slice B impl detail, no DB action. |
| **`SourceRefID` for mai bridge** | When a node has a `projax-mai-project` edge, expose its `metadata.mai_project_id` as `Item.SourceRefID`. Slice D (mai bridge worker) writes these edges. | Slice B impl reads existing edges; slice D writes new ones. |
| **`ItemLinkWithItem` join shape** | Used by `DatedLinksRange` and `RecentDocuments`. Adapter does two queries (edges-with-dates + node-by-id batch) + an in-memory join, OR one combined MCP call if mBrian exposes a bulk-edges-with-source-node helper. Both work; pick by perf. | Slice B impl, no mBrian-side change required. |
| **Admin counts (web/admin.go direct Pool)** | Three count(*) queries (total items, total mai-managed, total public). Adapter gains `Counts(ctx) (AdminCounts, error)` small extension. | Add to ItemReader interface in slice B (low-risk; constant-return until impl) OR keep as a separate `AdminReader` interface. Recommend adding to ItemReader for cohesion. |
---
## §4 — Skeleton (this branch)
The Go file `store/adapter.go` ships in this branch with:
1. `ItemReader` interface as in §2.
2. `var _ ItemReader = (*Store)(nil)` compile-time assertion. (Drops in cleanly because `*Store` already exposes every method in the contract.)
3. `MBrianReader` struct with stubbed method bodies that return `errNotImplementedSliceB`. Each stub carries a one-line comment naming the §3 gap it depends on (if any) so slice B's impl-fill knows what to look up.
4. `var _ ItemReader = (*MBrianReader)(nil)` compile-time assertion so the stubs stay aligned with the interface.
`go build ./...` is green with the skeleton in place. No tests, no behaviour, no mBrian client dependency.
The actual mBrian client wiring (whether MCP-over-stdio, direct Postgres against `mbrian.*` schema, or the in-process submodule pattern flexsiebels uses) is the first decision slice-B-impl makes; it stays out of this prep step.
---
## §5 — Wiring shape after slice B impl
For reference of the post-slice-B shape (no code in this slice):
```go
// Server struct keeps two readers: ItemReader (slice-B mBrian-backed) +
// existing *Store (views CRUD only).
type Server struct {
Items ItemReader // slice B: MBrianReader; today: *Store
Store *store.Store // views CRUD only after slice B
// ... rest unchanged
}
```
Every handler that today reads `s.Store.ListAll(...)` becomes `s.Items.ListAll(...)`. Mechanical rename. Slice B impl ships both adapter wiring + the rename across handlers as one diff once the migration completes.
---
## §6 — What's NOT in this prep
- mBrian-MCP client wiring.
- Any test of mBrian-backed behaviour.
- Write-path methods (slice C scope).
- View CRUD migration (Q5=(a) stays projax-resident).
- mai bridge worker (slice D).
- Drop projax tables (slice E).
---
## §7 — References
- `docs/plans/mbrian-backend-migration.md` (on `main`) parent plan.
- `cmd/projax-snapshot/` (slice 0, merged at `38182df`) input for mBrian's migration.
- m/mBrian#73 mBrian-side schema convention node + migration script (in flight).
- `store/store.go` current `*Store` implementation; the interface `*Store` already satisfies.
- `internal/aggregate/aggregator.go` existing `LinkLister` interface precedent (a narrow projection of `*Store`).

View File

@@ -0,0 +1,103 @@
# Slice B gap — `projax.views.filter_json.project_id` uuid-map
**Status**: gap flagged + tooling shipped. Must run remap BEFORE Slice E
drops `projax.items`.
**Branch**: `mai/kahn/phase-6-sliceB`.
**Author**: kahn (coder), 2026-05-31.
**Parent**: `docs/plans/mbrian-backend-migration.md` (§7 lossy bits).
## The gap
Phase 5j saved views (`projax.views.filter_json`) carry `project_id` set
to the **OLD** `projax.items.id` uuid. After Slice B's read path flips
to mBrian, those uuids no longer resolve — a saved view scoped to
`dev.paliad` references an id that mBrian's `paliad` node doesn't have
(the migration script issued a fresh uuid + recorded the old id under
`metadata.projax_origin` per m's Q11).
Symptoms once `PROJAX_BACKEND=mbrian` rolls in production:
- Opening `/views/{slug}` for any saved view that carries
`filter_json.project_id` returns the unfiltered set (the scope filter
silently no-ops).
- The chip rendering still labels the view "scoped to dev.paliad"
because the cached `project_path` is unaffected — the discrepancy
surfaces as "label says scope, content doesn't filter."
This must be fixed before Slice E drops `projax.items` or the old-id
provenance disappears.
## Two viable remediations
### (a) One-shot uuid remap via the migration audit map
mBrian dropped the `{old_projax_uuid → new_mbrian_uuid}` map at a shared
mRiver path (head will relay). One-time SQL:
```sql
UPDATE projax.views
SET filter_json = jsonb_set(
filter_json,
'{project_id}',
to_jsonb(<new>::text)
)
WHERE filter_json->>'project_id' = '<old>';
```
…iterated across the map. Pure data fix, no schema change. Idempotent
(re-running against an already-remapped row is a no-op because the old
uuid no longer matches).
### (b) Resolve by slug instead of id
Change the views resolver to look up `metadata.projax_origin = <id>`
when the new uuid doesn't resolve, OR store the slug in
`filter_json.project_path` (already present) as the canonical pointer.
Heavier change in the views read path; ships in a future slice.
## Recommended path
**Hybrid**: (a) for the existing rows now (idempotent, surgical), and
flag (b) as a follow-up in slice C/D for new view writes (use slug
instead of uuid in the editor — the slug is the durable name post-
migration anyway).
## Tool shipped this slice
`cmd/projax-remap-views/main.go` (this commit). Usage:
```
projax-remap-views --map /path/to/uuid-map.json
```
Map shape:
```json
{
"old-projax-uuid-1": "new-mbrian-uuid-1",
"old-projax-uuid-2": "new-mbrian-uuid-2",
...
}
```
The tool:
- Loads the uuid map from JSON.
- Walks every live `projax.views` row.
- For each row with `filter_json.project_id` matching a map key,
rewrites it to the new uuid.
- Prints a per-row before/after summary; commit only happens on
`--apply`. Default is dry-run.
Idempotent on re-run: rows already pointing at a new uuid don't match
any map key on the second pass, so they pass through untouched.
## When to run
After the mBrian migration completes (already shipped) + after head
relays the uuid-map path. Before any user is on
`PROJAX_BACKEND=mbrian`. The tool runs against msupabase using the same
`SUPABASE_DATABASE_URL` the projax binary uses.
## Validation
Post-remap: pick the spot-check saved views (if any exist), open them
through both `PROJAX_BACKEND=store` and `PROJAX_BACKEND=mbrian`, confirm
the filtered set matches.

52
store/adapter.go Normal file
View File

@@ -0,0 +1,52 @@
package store
import (
"context"
"time"
)
// ItemReader is the read-path contract every projax UI handler, the
// internal/aggregate fan-out engine, and the MCP read tools depend on.
// Pure projax-shaped structs in/out; the slice-B mBrian-backed
// implementation translates mBrian nodes/edges into the same shape
// without leaking mBrian types to consumers.
//
// Phase 6 Slice B (live impl) — see store/mbrian.go for the MBrianReader
// implementation against the migrated mbrian.* schema, and
// docs/plans/slice-b-adapter-contract.md for the consumer inventory +
// per-method semantics.
//
// Two satisfiers ship:
// *Store — pgx-backed against projax.items (today; legacy).
// *MBrianReader — pgx-backed against mbrian.{nodes,edges} (slice B).
//
// Selection between them is wired at Server-construction time via
// PROJAX_BACKEND=store|mbrian (defaults to "store" until slice B is
// rolled to production).
type ItemReader interface {
// --- item lookups ---
ListAll(ctx context.Context) ([]*Item, error)
GetByID(ctx context.Context, id string) (*Item, error)
GetByPath(ctx context.Context, path string) (*Item, error)
GetByPathOrSlug(ctx context.Context, key string) (*Item, error)
Roots(ctx context.Context) ([]*Item, error)
MaiOrphans(ctx context.Context) ([]*Item, error)
ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error)
Search(ctx context.Context, q string, limit int) ([]*Item, error)
ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error)
AllTags(ctx context.Context) ([]string, error)
// --- link lookups ---
LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error)
LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error)
DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error)
DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error)
RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error)
}
// Compile-time assertion that the existing pgx-backed *Store satisfies
// ItemReader. Drops in cleanly because every method in the interface is
// already part of *Store's public surface. If a future refactor removes
// or reshapes one of these methods on *Store, the compiler points at
// this line first.
var _ ItemReader = (*Store)(nil)

990
store/mbrian.go Normal file
View File

@@ -0,0 +1,990 @@
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)
}

251
store/mbrian_parity_test.go Normal file
View File

@@ -0,0 +1,251 @@
package store_test
// Phase 6 Slice B — parity test between the legacy pgx-against-projax-
// items *Store and the new pgx-against-mbrian *MBrianReader.
//
// Skipped without SUPABASE_DATABASE_URL set. When run against the live
// post-migration database, every comparison should hold: the adapter
// must be a faithful translator of the migrated graph for projax UI
// consumers.
import (
"context"
"errors"
"os"
"sort"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/store"
)
func newPair(t *testing.T) (*store.Store, *store.MBrianReader, *pgxpool.Pool) {
t.Helper()
url := os.Getenv("SUPABASE_DATABASE_URL")
if url == "" {
url = os.Getenv("PROJAX_DB_URL")
}
if url == "" {
t.Skip("set SUPABASE_DATABASE_URL")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatalf("pool: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Skipf("DB unreachable: %v", err)
}
return store.New(pool), store.NewMBrianReader(pool), pool
}
// TestParityListAll: both readers return the same set of items by slug.
// Field-by-field equality is asserted for slug/title/status/tags/management/
// public/parent count/paths — the consumer-facing surface.
func TestParityListAll(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.ListAll(ctx)
if err != nil {
t.Fatalf("store ListAll: %v", err)
}
mb, err := r.ListAll(ctx)
if err != nil {
t.Fatalf("mbrian ListAll: %v", err)
}
if len(leg) != len(mb) {
t.Fatalf("count mismatch: store=%d mbrian=%d", len(leg), len(mb))
}
// Per the migration brief, two projax-side squatter slugs were
// renamed-aside (not deleted) so mBrian could take the canonical
// 'work' (area) + 'dania' (project) slugs. Compare every slug that
// resolves in BOTH sets; the squatters surface as legacy-only.
skip := map[string]bool{"work": true, "dania": true}
legBySlug := bySlug(leg)
mbBySlug := bySlug(mb)
for slug, l := range legBySlug {
if skip[slug] {
continue
}
m, ok := mbBySlug[slug]
if !ok {
t.Errorf("slug %q missing in mBrian set", slug)
continue
}
if l.Title != m.Title {
t.Errorf("%s title: store=%q mbrian=%q", slug, l.Title, m.Title)
}
if l.Status != m.Status {
t.Errorf("%s status: store=%q mbrian=%q", slug, l.Status, m.Status)
}
if !sameSet(l.Tags, m.Tags) {
t.Errorf("%s tags: store=%v mbrian=%v", slug, l.Tags, m.Tags)
}
if !sameSet(l.Management, m.Management) {
t.Errorf("%s management: store=%v mbrian=%v", slug, l.Management, m.Management)
}
if l.Public != m.Public {
t.Errorf("%s public: store=%v mbrian=%v", slug, l.Public, m.Public)
}
if len(l.ParentIDs) != len(m.ParentIDs) {
t.Errorf("%s parent count: store=%d mbrian=%d",
slug, len(l.ParentIDs), len(m.ParentIDs))
}
if !sameSet(l.Paths, m.Paths) {
t.Errorf("%s paths: store=%v mbrian=%v", slug, l.Paths, m.Paths)
}
}
}
// TestParitySpotChecks asserts the 5 spot-check items resolve identically
// through both readers — root area / single-parent / multi-parent /
// caldav-linked / public-listing populated.
func TestParitySpotChecks(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
for _, slug := range []string{"dev", "work", "paliad", "services", "mhome", "fdbck", "dania"} {
l, lerr := s.GetByPathOrSlug(ctx, slug)
m, merr := r.GetByPathOrSlug(ctx, slug)
if lerr != nil && merr != nil {
// Both 404 — consistent.
continue
}
if lerr != nil {
t.Errorf("%s: store err=%v but mbrian found", slug, lerr)
continue
}
if merr != nil {
t.Errorf("%s: mbrian err=%v but store found", slug, merr)
continue
}
if l.Slug != m.Slug || l.Title != m.Title {
t.Errorf("%s: shape mismatch store=%+v mbrian=%+v",
slug, l.Slug+"/"+l.Title, m.Slug+"/"+m.Title)
}
}
}
// TestParityCalDAVLinks: the single caldav-list link must round-trip.
func TestParityCalDAVLinks(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.LinksByRefType(ctx, "caldav-list")
if err != nil {
t.Fatalf("store: %v", err)
}
mb, err := r.LinksByRefType(ctx, "caldav-list")
if err != nil {
t.Fatalf("mbrian: %v", err)
}
if len(leg) != len(mb) {
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
}
// The URLs must round-trip identically. Match by ref_id.
legByRef := map[string]*store.ItemLink{}
for _, l := range leg {
legByRef[l.RefID] = l
}
for _, m := range mb {
l, ok := legByRef[m.RefID]
if !ok {
t.Errorf("mbrian caldav RefID %q not in store set", m.RefID)
continue
}
if l.Rel != m.Rel {
t.Errorf("caldav %s rel: store=%q mbrian=%q", m.RefID, l.Rel, m.Rel)
}
}
}
// TestParityGiteaRepoLinks — same parity check for the 37 gitea-repo edges.
func TestParityGiteaRepoLinks(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.LinksByRefType(ctx, "gitea-repo")
if err != nil {
t.Fatalf("store: %v", err)
}
mb, err := r.LinksByRefType(ctx, "gitea-repo")
if err != nil {
t.Fatalf("mbrian: %v", err)
}
if len(leg) != len(mb) {
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
}
legSeen := map[string]bool{}
for _, l := range leg {
legSeen[l.RefID] = true
}
for _, m := range mb {
if !legSeen[m.RefID] {
t.Errorf("mbrian gitea-repo RefID %q not in store set", m.RefID)
}
}
}
// TestParityAllTags: tag union must match (modulo ordering).
func TestParityAllTags(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
leg, err := s.AllTags(ctx)
if err != nil {
t.Fatalf("store: %v", err)
}
mb, err := r.AllTags(ctx)
if err != nil {
t.Fatalf("mbrian: %v", err)
}
if !sameSet(leg, mb) {
t.Errorf("AllTags mismatch:\n store=%v\n mbrian=%v", leg, mb)
}
}
// TestParityNotFound: an unknown slug must 404 from both.
func TestParityNotFound(t *testing.T) {
s, r, pool := newPair(t)
defer pool.Close()
ctx := context.Background()
_, le := s.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
_, me := r.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
if !errors.Is(le, store.ErrNotFound) {
t.Errorf("store should ErrNotFound, got %v", le)
}
if !errors.Is(me, store.ErrNotFound) {
t.Errorf("mbrian should ErrNotFound, got %v", me)
}
}
// --- helpers ---
func bySlug(items []*store.Item) map[string]*store.Item {
out := map[string]*store.Item{}
for _, it := range items {
out[it.Slug] = it
}
return out
}
func sameSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
ac := append([]string{}, a...)
bc := append([]string{}, b...)
sort.Strings(ac)
sort.Strings(bc)
for i := range ac {
if ac[i] != bc[i] {
return false
}
}
return true
}

View File

@@ -19,7 +19,7 @@ import (
// matching item, and an action bar that posts to /admin/bulk/apply. The page
// is intentionally desktop-only — m bulk-edits from a keyboard.
func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -29,7 +29,7 @@ func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
allTags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -377,7 +377,7 @@ func normaliseFormStrings(in []string) []string {
// the first. Must use the slice form here or the second+ values silently
// drop on every Apply round-trip.
func (s *Server) renderBulkList(w http.ResponseWriter, r *http.Request, banner string) {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -387,7 +387,7 @@ func (s *Server) renderBulkList(w http.ResponseWriter, r *http.Request, banner s
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
allTags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -445,7 +445,7 @@ func (s *Server) handleBulkChip(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
it, err := s.Store.GetByID(r.Context(), id)
it, err := s.Items.GetByID(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return

View File

@@ -44,11 +44,11 @@ func (s *Server) buildCalDAVOverview(ctx context.Context) (*CalDAVOverview, erro
if err != nil {
return nil, fmt.Errorf("caldav list: %w", err)
}
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
links, err := s.Items.LinksByRefType(ctx, refTypeCalDAV)
if err != nil {
return nil, err
}
@@ -194,7 +194,7 @@ func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -245,7 +245,7 @@ func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -306,7 +306,7 @@ func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarT
if s.CalDAV == nil {
return nil, nil
}
links, err := s.Store.LinksByType(ctx, item.ID, refTypeCalDAV)
links, err := s.Items.LinksByType(ctx, item.ID, refTypeCalDAV)
if err != nil {
return nil, err
}
@@ -372,7 +372,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -388,7 +388,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
}
// Guard: the calendar URL must be linked to this item — otherwise a
// crafted form could route writes to arbitrary calendars.
links, err := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
links, err := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if err != nil {
s.fail(w, r, err)
return
@@ -541,7 +541,7 @@ func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *
// here are non-fatal — degrade to an empty picker.
var available []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
caldavLinks, lerr := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
}

View File

@@ -209,7 +209,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
// lead/trail cells), bins them into per-day cells, and caps each cell at
// calendarMaxRowsPerCell with the overflow count.
func (s *Server) buildCalendar(ctx context.Context, q calendarQuery, now time.Time) (*calendarPayload, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}

View File

@@ -357,7 +357,7 @@ func dashboardTabs(active, filterKey, scope string) []dashboardTab {
// shapes AND the new per-project rollup so the rollup costs zero extra
// DAV/Gitea calls.
func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashboardPayload, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}
@@ -422,7 +422,7 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
// --- Recent documents card ---
since := now.AddDate(0, 0, -30)
docRows, err := s.Store.RecentDocuments(ctx, since, 200)
docRows, err := s.Items.RecentDocuments(ctx, since, 200)
if err != nil {
s.Logger.Warn("dashboard docs", "err", err)
}
@@ -486,7 +486,7 @@ func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTask
if openTasks[it.ID] > 0 || openIssues[it.ID] > 0 {
continue
}
links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo)
links, err := s.Items.LinksByType(ctx, it.ID, refTypeGiteaRepo)
if err != nil || len(links) == 0 {
continue
}
@@ -868,7 +868,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
// pointing at the given URL. Used as the dashboard's write-side ownership
// guard.
func (s *Server) calendarLinked(ctx context.Context, calURL string) (bool, error) {
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
links, err := s.Items.LinksByRefType(ctx, refTypeCalDAV)
if err != nil {
return false, err
}

View File

@@ -162,7 +162,7 @@ func (s *Server) detailIssues(ctx context.Context, item *store.Item) ([]repoIssu
if s.Gitea == nil {
return nil, nil
}
links, err := s.Store.LinksByType(ctx, item.ID, refTypeGiteaRepo)
links, err := s.Items.LinksByType(ctx, item.ID, refTypeGiteaRepo)
if err != nil {
return nil, err
}

View File

@@ -22,7 +22,7 @@ func (s *Server) handleIssueAction(w http.ResponseWriter, r *http.Request, path,
http.Error(w, "gitea not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -135,7 +135,7 @@ func (s *Server) renderIssuesSection(w http.ResponseWriter, r *http.Request, it
// to this item via a gitea-repo item_link. Prevents form-crafted writeback
// against unrelated repos.
func (s *Server) repoLinkedToItem(ctx context.Context, itemID, repoRef string) bool {
links, err := s.Store.LinksByType(ctx, itemID, refTypeGiteaRepo)
links, err := s.Items.LinksByType(ctx, itemID, refTypeGiteaRepo)
if err != nil {
return false
}

View File

@@ -41,7 +41,7 @@ type graphPayload struct {
}
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -51,7 +51,7 @@ func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
allTags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return

View File

@@ -15,7 +15,7 @@ import (
// note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the
// trust model is Tailscale-only + cookie auth.
func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -57,7 +57,7 @@ func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path str
// handleLinksRemove processes POST /i/{path}/links/remove.
func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -106,7 +106,7 @@ func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request,
http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther)
return
}
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
docs, err := s.Items.DatedLinks(r.Context(), it.ID)
if err != nil {
s.fail(w, r, err)
return

View File

@@ -75,7 +75,13 @@ var staticFS embed.FS
// Server bundles handlers, templates, and the store.
type Server struct {
Store *store.Store
Store *store.Store
// Items is the read-path adapter every UI handler / MCP read tool /
// aggregator depends on. Phase 6 Slice B introduces it: today the
// concrete *Store satisfies the ItemReader interface (legacy path);
// after the mBrian backend rollout PROJAX_BACKEND=mbrian wires
// *store.MBrianReader here. Writes still flow through Store.
Items store.ItemReader
pages map[string]*template.Template
Logger *slog.Logger
Auth *AuthConfig // nil → no auth (local dev / tests)
@@ -367,7 +373,10 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
pages["bulk_chip_mgmt"] = bulkChipMgmt
return &Server{
Store: s,
Store: s,
// Default Items satisfier is *Store itself. main.go can override
// post-construction (e.g. PROJAX_BACKEND=mbrian → MBrianReader).
Items: s,
pages: pages,
Logger: logger,
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
@@ -468,12 +477,12 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
// Phase 5j slice C: handleTree is reached at /views/tree (system view)
// only. The legacy / route 301-redirects via legacyRedirect — see
// Routes(). Any 404-on-unknown-path responsibility moved with it.
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
tags, err := s.Store.AllTags(r.Context())
tags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -543,7 +552,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
func (s *Server) linkKindsByItem(ctx context.Context) (map[string]map[string]struct{}, error) {
out := map[string]map[string]struct{}{}
for _, t := range []string{"caldav-list", "gitea-repo"} {
links, err := s.Store.LinksByRefType(ctx, t)
links, err := s.Items.LinksByRefType(ctx, t)
if err != nil {
return nil, err
}
@@ -568,11 +577,11 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
// PER URL resolution: try the full path first; if it 404s and the trailing
// segment looks like YYMMDD, retry against the shorter path and surface
// the date as a render hint to scroll/highlight the matching row.
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
var highlight *time.Time
if errors.Is(err, store.ErrNotFound) {
if base, d := parsePER(path); d != nil {
if it2, err2 := s.Store.GetByPath(r.Context(), base); err2 == nil {
if it2, err2 := s.Items.GetByPath(r.Context(), base); err2 == nil {
it, err, highlight = it2, nil, d
}
}
@@ -596,7 +605,7 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
// are non-fatal — the section falls back to its pre-5j shape.
var availableCalendars []caldav.Calendar
if s.CalDAV != nil {
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
caldavLinks, lerr := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
if lerr != nil {
s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr)
}
@@ -614,7 +623,7 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
for _, ri := range issues {
openTotal += ri.OpenCount
}
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
docs, err := s.Items.DatedLinks(r.Context(), it.ID)
if err != nil {
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
}
@@ -669,7 +678,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
s.handleLinksRemove(w, r, base)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -737,7 +746,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
// a root mai-managed item under a chosen parent without touching other fields.
// HTMX-friendly: returns a fragment when HX-Request is set.
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
@@ -892,7 +901,7 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
parentPath := r.URL.Query().Get("parent")
var parent *store.Item
if parentPath != "" {
p, err := s.Store.GetByPath(r.Context(), parentPath)
p, err := s.Items.GetByPath(r.Context(), parentPath)
if err != nil {
s.fail(w, r, err)
return
@@ -967,7 +976,7 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
orphans, err := s.Store.MaiOrphans(r.Context())
orphans, err := s.Items.MaiOrphans(r.Context())
if err != nil {
s.fail(w, r, err)
return
@@ -993,7 +1002,7 @@ type ParentOption struct {
}
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}
@@ -1065,7 +1074,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
}
}
if needsCount {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err == nil {
linkKinds, _ := s.linkKindsByItem(r.Context())
for _, v := range uv {

View File

@@ -212,7 +212,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
// buildTimeline gathers every dated source, applies the kind/filter narrowing,
// and groups rows by day in the requested order.
func (s *Server) buildTimeline(ctx context.Context, q TimelineQuery, now time.Time) (*TimelinePayload, error) {
items, err := s.Store.ListAll(ctx)
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}

View File

@@ -100,12 +100,12 @@ func (s *Server) handleViewRender(w http.ResponseWriter, r *http.Request) {
// dispatch shape. Calendar / timeline view_types fall back to list in
// slice B; slice D wires their dedicated templates.
func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store.View, filter TreeFilter, viewType, groupBy string) {
items, err := s.Store.ListAll(r.Context())
items, err := s.Items.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
tags, err := s.Store.AllTags(r.Context())
tags, err := s.Items.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return