Compare commits
4 Commits
38182df651
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 307a898dbd | |||
| b22f50ca7b | |||
| 4fdeca8269 | |||
| 9607d4b307 |
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)
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
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