Compare commits
8 Commits
a44edf3917
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 307a898dbd | |||
| b22f50ca7b | |||
| 4fdeca8269 | |||
| 9607d4b307 | |||
| 38182df651 | |||
| 2702c699d1 | |||
| a5b0971b9d | |||
| b3e7183478 |
139
cmd/projax-remap-views/main.go
Normal file
139
cmd/projax-remap-views/main.go
Normal 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
349
cmd/projax-snapshot/main.go
Normal 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)
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
434
docs/plans/mbrian-backend-migration.md
Normal file
434
docs/plans/mbrian-backend-migration.md
Normal 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.
|
||||
228
docs/plans/slice-b-adapter-contract.md
Normal file
228
docs/plans/slice-b-adapter-contract.md
Normal 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`).
|
||||
103
docs/plans/slice-b-views-projectid-gap.md
Normal file
103
docs/plans/slice-b-views-projectid-gap.md
Normal 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
52
store/adapter.go
Normal 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
990
store/mbrian.go
Normal 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
251
store/mbrian_parity_test.go
Normal 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
|
||||
}
|
||||
10
web/bulk.go
10
web/bulk.go
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user