4 Commits

Author SHA1 Message Date
mAi
307a898dbd Merge branch 'mai/kahn/phase-6-sliceB' (Phase 6 Slice B: mBrian-backed read path behind PROJAX_BACKEND switch, parity-green) 2026-05-31 22:22:40 +02:00
mAi
b22f50ca7b feat(adapter): Phase 6 Slice B — mBrian-backed read path live
Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live
on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This
commit makes the projax read path source from there behind an env
switch.

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

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

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

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

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

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

OUT OF SCOPE FOR SLICE B (deferred):
- MCP read tools migration to ItemReader (separate diff, low risk).
- Aggregator's LinkLister wired to ItemReader (currently consumes
  *Store directly through Server.Aggregator()).
- views.filter_json.project_id remap RUN — tool ships here, run waits
  on the head's relay of the audit-map path.
- Slice C write-path. Slice D mai-bridge worker. Slice E drop.
2026-05-31 22:20:38 +02:00
mAi
4fdeca8269 Merge branch 'mai/kahn/phase-6-sliceB-prep' (Phase 6: slice-B adapter interface contract + skeleton, no impl) 2026-05-29 15:18:15 +02:00
mAi
9607d4b307 docs+skeleton: Phase 6 Slice B prep — read-path adapter interface contract
Per head's parallel-prep brief while m/mBrian#73 (migration script +
[schema] node) is being built mBrian-side. NO mBrian-MCP-backed
implementation yet — the migration worker may refine the landed
node/edge shape and building the impl now risks rework.

Built ONLY the parts stable regardless of mBrian internals:

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

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

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

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

Stays parked after this. Slice B IMPL (mBrian-MCP client wiring + per-
method bodies + handler rename from s.Store.X to s.Items.X) waits on
the migration completing and uuid map landing.
2026-05-29 15:17:24 +02:00
18 changed files with 1837 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

52
store/adapter.go Normal file
View File

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

990
store/mbrian.go Normal file
View File

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

251
store/mbrian_parity_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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