feat(mcp+cmd): Phase 6 Slice C — atomic PROJAX_BACKEND flip across web + MCP
- cmd/projax/main.go: PROJAX_BACKEND=mbrian now sets BOTH srv.Items (reader) AND srv.Writes (writer=MBrianWriter HTTP client, reading PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN). =store sets both to the legacy *Store. The flip is atomic — the slice-B half-flip (reader only) was the production bug. Warns (not exits) if mbrian is selected without the API env vars: reads work direct-DB, writes fail closed legibly. - mcp/tools.go: RegisterProjaxTools split from a single *Store into (reader, writer, legacy *Store, agg). Read tools take the reader, write tools take reader+writer, both flipping with the backend. Leaving MCP reads on projax.items while writes targeted mBrian would recreate the slice-B bug on the MCP surface (read an id from one backend, write it to the other). The timeline tool keeps the legacy *Store + aggregator (out of slice-C scope, consistent with the web dashboard). main.go passes srv.Items/srv.Writes so MCP follows the same flip as the web UI. NOTE: this widens slice C beyond the handover's 'MCP reads deferred' — necessary because migrating MCP writes alone is incoherent with atomicity. Flagged to head. - store/mbrian_writer_test.go: httptest-backed unit tests for request construction + error mapping (401/403/404→ErrNotFound/503/500/400), fail-closed on empty token/URL (no empty Bearer sent), AddLink self-edge + metadata shaping, AddLinkDated date+note, per-ref_type edge metadata, projax bundle defaults + public nesting, uuid v4 format. Pool-backed read-backs (Create/Update round-trip) are covered by head's live cutover test + the reader parity tests. Build + vet green. store/mcp/itemwrite tests pass in isolation.
This commit is contained in:
@@ -74,18 +74,34 @@ func main() {
|
||||
}
|
||||
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.
|
||||
// Phase 6 Slice C — backend selector. PROJAX_BACKEND=mbrian flips BOTH
|
||||
// the read path (srv.Items → MBrianReader, direct DB) AND the write path
|
||||
// (srv.Writes → MBrianWriter, scoped HTTP API) together; default keeps
|
||||
// the legacy pgx-against-projax.items path on both so production
|
||||
// rollback is one env flip.
|
||||
//
|
||||
// The flip MUST be atomic: slice B flipped only the reader, so a
|
||||
// read-then-write round-trip read an mBrian uuid then wrote it against
|
||||
// projax.items and was rejected. Reads and writes now always share a
|
||||
// backend — never one without the other.
|
||||
backend := strings.ToLower(strings.TrimSpace(os.Getenv("PROJAX_BACKEND")))
|
||||
switch backend {
|
||||
case "mbrian":
|
||||
apiURL := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_URL"))
|
||||
apiToken := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_TOKEN"))
|
||||
srv.Items = store.NewMBrianReader(pool)
|
||||
logger.Info("backend=mbrian (read path via store.MBrianReader)")
|
||||
srv.Writes = store.NewMBrianWriter(apiURL, apiToken, pool)
|
||||
if apiURL == "" || apiToken == "" {
|
||||
// Reads work direct-DB without these, but writes fail closed
|
||||
// (clean 503-style error) until both are set. Warn loudly rather
|
||||
// than exit — head sets them in Dokploy at cutover and the writer
|
||||
// surfaces a legible error if a write lands first.
|
||||
logger.Warn("backend=mbrian but PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN not both set — writes will fail closed until configured")
|
||||
}
|
||||
logger.Info("backend=mbrian (reads via store.MBrianReader, writes via store.MBrianWriter HTTP API)", "api_url", apiURL)
|
||||
case "", "store":
|
||||
// Default — srv.Items is the *Store from web.New.
|
||||
logger.Info("backend=store (read path via legacy *store.Store)")
|
||||
// Default — srv.Items and srv.Writes are both the *Store from web.New.
|
||||
logger.Info("backend=store (reads + writes via legacy *store.Store)")
|
||||
default:
|
||||
logger.Error("unknown PROJAX_BACKEND value", "value", backend)
|
||||
os.Exit(1)
|
||||
@@ -139,7 +155,11 @@ func main() {
|
||||
// shared *aggregate.Aggregator instead of pointing back at
|
||||
// *web.Server. Passing nil here disables the `timeline` tool
|
||||
// cleanly; the rest of the projax MCP toolset stays usable.
|
||||
mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv.Aggregator())
|
||||
// MCP flips with the backend too: pass the same reader+writer the web
|
||||
// handlers use (srv.Items / srv.Writes, already set by the
|
||||
// PROJAX_BACKEND switch above). The `timeline` tool keeps the legacy
|
||||
// *Store (st) + aggregator — out of slice-C scope.
|
||||
mcp.RegisterProjaxTools(mcpSrv, srv.Items, srv.Writes, st, srv.Aggregator())
|
||||
mcpMux := http.NewServeMux()
|
||||
mcpSrv.Routes(mcpMux)
|
||||
srv.MCP = mcpMux
|
||||
|
||||
@@ -39,10 +39,10 @@ func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([]
|
||||
func newToolServer(t *testing.T, agg *aggregate.Aggregator) *Server {
|
||||
t.Helper()
|
||||
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
// Pass a nil *store.Store — the timeline tool's store-backed paths
|
||||
// short-circuit cleanly on errors in this test surface (we just probe
|
||||
// registration + arg parsing here).
|
||||
RegisterProjaxTools(srv, nil, agg)
|
||||
// Pass nil reader/writer/legacy store — the timeline tool's store-backed
|
||||
// paths short-circuit cleanly on errors in this test surface (we just
|
||||
// probe registration + arg parsing here).
|
||||
RegisterProjaxTools(srv, nil, nil, nil, agg)
|
||||
return srv
|
||||
}
|
||||
|
||||
|
||||
114
mcp/tools.go
114
mcp/tools.go
@@ -46,12 +46,18 @@ type TimelineArgs struct {
|
||||
IncludeExcluded bool `json:"include_excluded"` // ignore per-item timeline_exclude arrays
|
||||
}
|
||||
|
||||
// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server.
|
||||
// All tools delegate to *store.Store directly so business logic is shared
|
||||
// with the web UI — no duplication. The optional agg argument adds the
|
||||
// timeline tool when non-nil (it needs the fan-out aggregator; passing nil
|
||||
// keeps the rest of the toolset usable without aggregate deps).
|
||||
func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) {
|
||||
// RegisterProjaxTools wires the projax MCP toolset. Phase 6 Slice C splits
|
||||
// the single *Store dependency into a reader (rd) + writer (wr) so the MCP
|
||||
// item/link surface flips with PROJAX_BACKEND atomically — the same
|
||||
// reader/writer the web handlers use. If MCP reads stayed on projax.items
|
||||
// while writes targeted mBrian (or vice-versa) an MCP client could read an
|
||||
// id from one backend and write it to the other: the exact slice-B
|
||||
// half-flip bug, just on the MCP surface.
|
||||
//
|
||||
// The `timeline` tool stays on the legacy *Store (its companion
|
||||
// *aggregate.Aggregator is out of slice-C scope), consistent with the web
|
||||
// dashboard/timeline which also aggregate via *Store.
|
||||
func RegisterProjaxTools(s *Server, rd store.ItemReader, wr store.ItemWriter, legacy *store.Store, agg *aggregate.Aggregator) {
|
||||
s.Register(Tool{
|
||||
Name: "list_items",
|
||||
Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav, public).",
|
||||
@@ -70,7 +76,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"limit": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
}`),
|
||||
Handler: listItemsTool(st),
|
||||
Handler: listItemsTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "get_item",
|
||||
@@ -83,7 +89,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"include_links": {"type": "boolean", "description": "Include item_links in the response (default true)"}
|
||||
}
|
||||
}`),
|
||||
Handler: getItemTool(st),
|
||||
Handler: getItemTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "create_item",
|
||||
@@ -103,7 +109,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"metadata": {"type": "object"}
|
||||
}
|
||||
}`),
|
||||
Handler: createItemTool(st),
|
||||
Handler: createItemTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "update_item",
|
||||
@@ -130,7 +136,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"timeline_exclude": {"type": "array", "items": {"type": "string", "enum": ["todos","events","docs","creation"]}, "description": "Phase 4f — kinds to hide from /timeline (per item)"}
|
||||
}
|
||||
}`),
|
||||
Handler: updateItemTool(st),
|
||||
Handler: updateItemTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "delete_item",
|
||||
@@ -143,7 +149,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"cascade": {"type": "boolean", "description": "Soft-delete every descendant too"}
|
||||
}
|
||||
}`),
|
||||
Handler: deleteItemTool(st),
|
||||
Handler: deleteItemTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "list_links",
|
||||
@@ -156,7 +162,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"ref_type": {"type": "string", "description": "Optional ref_type filter (e.g. 'gitea-repo')"}
|
||||
}
|
||||
}`),
|
||||
Handler: listLinksTool(st),
|
||||
Handler: listLinksTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "add_link",
|
||||
@@ -175,7 +181,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"metadata": {"type": "object"}
|
||||
}
|
||||
}`),
|
||||
Handler: addLinkTool(st),
|
||||
Handler: addLinkTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "remove_link",
|
||||
@@ -185,7 +191,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"required": ["link_id"],
|
||||
"properties": {"link_id": {"type": "string"}}
|
||||
}`),
|
||||
Handler: removeLinkTool(st),
|
||||
Handler: removeLinkTool(wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "search",
|
||||
@@ -198,7 +204,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200}
|
||||
}
|
||||
}`),
|
||||
Handler: searchTool(st),
|
||||
Handler: searchTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "tree",
|
||||
@@ -210,7 +216,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"depth": {"type": "integer", "minimum": 0, "description": "Max depth (0 = unlimited)"}
|
||||
}
|
||||
}`),
|
||||
Handler: treeTool(st),
|
||||
Handler: treeTool(rd),
|
||||
})
|
||||
if agg != nil {
|
||||
s.Register(Tool{
|
||||
@@ -230,7 +236,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
|
||||
}
|
||||
}`),
|
||||
Handler: timelineTool(st, agg),
|
||||
Handler: timelineTool(legacy, agg),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -710,14 +716,14 @@ func mapOr(v map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
// resolveItem turns an id-or-path argument pair into a concrete *store.Item.
|
||||
func resolveItem(ctx context.Context, st *store.Store, id, path string) (*store.Item, error) {
|
||||
func resolveItem(ctx context.Context, rd store.ItemReader, id, path string) (*store.Item, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
path = strings.TrimSpace(path)
|
||||
if id != "" {
|
||||
return st.GetByID(ctx, id)
|
||||
return rd.GetByID(ctx, id)
|
||||
}
|
||||
if path != "" {
|
||||
return st.GetByPathOrSlug(ctx, path)
|
||||
return rd.GetByPathOrSlug(ctx, path)
|
||||
}
|
||||
return nil, errors.New("either id or path is required")
|
||||
}
|
||||
@@ -731,7 +737,7 @@ func parseInput[T any](raw json.RawMessage, dst *T) error {
|
||||
|
||||
// --- list_items ---
|
||||
|
||||
func listItemsTool(st *store.Store) ToolHandler {
|
||||
func listItemsTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
ParentPath string `json:"parent_path"`
|
||||
Tags []string `json:"tags"`
|
||||
@@ -749,7 +755,7 @@ func listItemsTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
items, err := st.ListByFilters(ctx, store.SearchFilters{
|
||||
items, err := rd.ListByFilters(ctx, store.SearchFilters{
|
||||
ParentPath: in.ParentPath,
|
||||
Tags: in.Tags,
|
||||
Management: in.Management,
|
||||
@@ -774,7 +780,7 @@ func listItemsTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- get_item ---
|
||||
|
||||
func getItemTool(st *store.Store) ToolHandler {
|
||||
func getItemTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -785,7 +791,7 @@ func getItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -795,13 +801,13 @@ func getItemTool(st *store.Store) ToolHandler {
|
||||
include = *in.IncludeLinks
|
||||
}
|
||||
if include {
|
||||
links, err := st.LinksByType(ctx, it.ID, "") // pass "" → all types
|
||||
links, err := rd.LinksByType(ctx, it.ID, "") // pass "" → all types
|
||||
// LinksByType filters by ref_type — empty would return nothing. So
|
||||
// we explicitly list_all by fanning across the known types.
|
||||
_ = err
|
||||
links = nil
|
||||
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
|
||||
ll, err := st.LinksByType(ctx, it.ID, t)
|
||||
ll, err := rd.LinksByType(ctx, it.ID, t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -819,7 +825,7 @@ func getItemTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- create_item ---
|
||||
|
||||
func createItemTool(st *store.Store) ToolHandler {
|
||||
func createItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
@@ -836,7 +842,7 @@ func createItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths)
|
||||
parentIDs, err := resolveParentPaths(ctx, rd, in.ParentPaths)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -851,12 +857,12 @@ func createItemTool(st *store.Store) ToolHandler {
|
||||
}); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, st, itemwrite.Input{
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, rd, itemwrite.Input{
|
||||
Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs,
|
||||
}); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
it, err := st.Create(ctx, store.CreateInput{
|
||||
it, err := wr.Create(ctx, store.CreateInput{
|
||||
Kind: kind,
|
||||
Title: in.Title,
|
||||
Slug: in.Slug,
|
||||
@@ -874,14 +880,14 @@ func createItemTool(st *store.Store) ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([]string, error) {
|
||||
func resolveParentPaths(ctx context.Context, rd store.ItemReader, paths []string) ([]string, error) {
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
it, err := st.GetByPathOrSlug(ctx, p)
|
||||
it, err := rd.GetByPathOrSlug(ctx, p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent path %q: %w", p, err)
|
||||
}
|
||||
@@ -892,7 +898,7 @@ func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([
|
||||
|
||||
// --- update_item ---
|
||||
|
||||
func updateItemTool(st *store.Store) ToolHandler {
|
||||
func updateItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -917,7 +923,7 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -996,7 +1002,7 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
patch.TimelineExclude = out
|
||||
}
|
||||
if in.ParentPaths != nil {
|
||||
pids, err := resolveParentPaths(ctx, st, *in.ParentPaths)
|
||||
pids, err := resolveParentPaths(ctx, rd, *in.ParentPaths)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1015,10 +1021,10 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
if ve := itemwrite.ValidateFormat(validateIn); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, st, validateIn); ve != nil {
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, rd, validateIn); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
updated, err := st.Update(ctx, it.ID, patch)
|
||||
updated, err := wr.Update(ctx, it.ID, patch)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1028,7 +1034,7 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- delete_item ---
|
||||
|
||||
func deleteItemTool(st *store.Store) ToolHandler {
|
||||
func deleteItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -1039,11 +1045,11 @@ func deleteItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
|
||||
if err := wr.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
return map[string]any{"deleted": it.ID, "cascade": in.Cascade}, nil
|
||||
@@ -1052,7 +1058,7 @@ func deleteItemTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- list_links ---
|
||||
|
||||
func listLinksTool(st *store.Store) ToolHandler {
|
||||
func listLinksTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -1063,16 +1069,16 @@ func listLinksTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
var links []*store.ItemLink
|
||||
if in.RefType != "" {
|
||||
links, err = st.LinksByType(ctx, it.ID, in.RefType)
|
||||
links, err = rd.LinksByType(ctx, it.ID, in.RefType)
|
||||
} else {
|
||||
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
|
||||
ll, lerr := st.LinksByType(ctx, it.ID, t)
|
||||
ll, lerr := rd.LinksByType(ctx, it.ID, t)
|
||||
if lerr != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1092,7 +1098,7 @@ func listLinksTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- add_link / remove_link ---
|
||||
|
||||
func addLinkTool(st *store.Store) ToolHandler {
|
||||
func addLinkTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -1111,7 +1117,7 @@ func addLinkTool(st *store.Store) ToolHandler {
|
||||
if in.RefType == "" || in.RefID == "" {
|
||||
return nil, &ToolError{Code: codeInternalError, Msg: "ref_type and ref_id are required"}
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1132,7 +1138,7 @@ func addLinkTool(st *store.Store) ToolHandler {
|
||||
}
|
||||
datePtr = &t
|
||||
}
|
||||
link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
|
||||
link, err := wr.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1140,7 +1146,7 @@ func addLinkTool(st *store.Store) ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func removeLinkTool(st *store.Store) ToolHandler {
|
||||
func removeLinkTool(wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
LinkID string `json:"link_id"`
|
||||
}
|
||||
@@ -1152,7 +1158,7 @@ func removeLinkTool(st *store.Store) ToolHandler {
|
||||
if in.LinkID == "" {
|
||||
return nil, &ToolError{Code: codeInternalError, Msg: "link_id is required"}
|
||||
}
|
||||
if err := st.DeleteLink(ctx, in.LinkID); err != nil {
|
||||
if err := wr.DeleteLink(ctx, in.LinkID); err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
return map[string]any{"deleted": in.LinkID}, nil
|
||||
@@ -1161,7 +1167,7 @@ func removeLinkTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- search ---
|
||||
|
||||
func searchTool(st *store.Store) ToolHandler {
|
||||
func searchTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
@@ -1174,7 +1180,7 @@ func searchTool(st *store.Store) ToolHandler {
|
||||
if in.Query == "" {
|
||||
return nil, &ToolError{Code: codeInternalError, Msg: "query is required"}
|
||||
}
|
||||
items, err := st.Search(ctx, in.Query, in.Limit)
|
||||
items, err := rd.Search(ctx, in.Query, in.Limit)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1194,7 +1200,7 @@ type treeNode struct {
|
||||
Children []*treeNode `json:"children"`
|
||||
}
|
||||
|
||||
func treeTool(st *store.Store) ToolHandler {
|
||||
func treeTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
RootPath string `json:"root_path"`
|
||||
Depth int `json:"depth"`
|
||||
@@ -1204,7 +1210,7 @@ func treeTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
items, err := st.ListAll(ctx)
|
||||
items, err := rd.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1235,7 +1241,7 @@ func treeTool(st *store.Store) ToolHandler {
|
||||
}
|
||||
var out []*treeNode
|
||||
if in.RootPath != "" {
|
||||
root, err := st.GetByPathOrSlug(ctx, in.RootPath)
|
||||
root, err := rd.GetByPathOrSlug(ctx, in.RootPath)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func mustDBServer(t *testing.T) (*Server, *pgxpool.Pool) {
|
||||
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
// The MCP tests don't need a real timeline builder — passing nil keeps
|
||||
// the timeline tool unregistered without requiring a web.Server here.
|
||||
RegisterProjaxTools(srv, st, nil)
|
||||
RegisterProjaxTools(srv, st, st, st, nil)
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
return srv, pool
|
||||
}
|
||||
|
||||
268
store/mbrian_writer_test.go
Normal file
268
store/mbrian_writer_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestWriter points an MBrianWriter at an httptest server. pool is nil:
|
||||
// the HTTP-only paths under test (do, postEdge, deleteEdge, AddLink) never
|
||||
// touch it. The pool-backed read-backs (Create/Update/Reparent/SetPublic/
|
||||
// DeleteLink) are exercised by the live cutover round-trip + the reader
|
||||
// parity tests, not here.
|
||||
func newTestWriter(baseURL, token string) *MBrianWriter {
|
||||
return NewMBrianWriter(baseURL, token, nil)
|
||||
}
|
||||
|
||||
func TestMBrianWriterErrorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
body string
|
||||
wantNotFn bool // expect errors.Is(err, ErrNotFound)
|
||||
wantText string
|
||||
}{
|
||||
{http.StatusUnauthorized, `{"error":"bad token"}`, false, "unauthorized"},
|
||||
{http.StatusForbidden, `{"error":"not projax-owned"}`, false, "not projax-owned"},
|
||||
{http.StatusNotFound, `{"error":"missing"}`, true, ""},
|
||||
{http.StatusServiceUnavailable, `{"error":"token not configured"}`, false, "write backend not ready"},
|
||||
{http.StatusInternalServerError, `{"error":"db boom"}`, false, "db boom"},
|
||||
{http.StatusBadRequest, `{"error":"disallowed rel"}`, false, "disallowed rel"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(c.status)
|
||||
io.WriteString(w, c.body)
|
||||
}))
|
||||
w := newTestWriter(srv.URL, "tok")
|
||||
err := w.do(context.Background(), http.MethodDelete, "/api/projax/nodes/x", nil, nil)
|
||||
srv.Close()
|
||||
if err == nil {
|
||||
t.Fatalf("status %d: expected error, got nil", c.status)
|
||||
}
|
||||
if c.wantNotFn && !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("status %d: expected ErrNotFound wrap, got %v", c.status, err)
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("status %d: expected *APIError in chain, got %v", c.status, err)
|
||||
continue
|
||||
}
|
||||
if apiErr.Status != c.status {
|
||||
t.Errorf("status %d: APIError.Status = %d", c.status, apiErr.Status)
|
||||
}
|
||||
if c.wantText != "" && !strings.Contains(err.Error(), c.wantText) {
|
||||
t.Errorf("status %d: error %q missing %q", c.status, err.Error(), c.wantText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterFailsClosedWithoutToken(t *testing.T) {
|
||||
// No token → must not fire a request with an empty Bearer.
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "")
|
||||
err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", map[string]any{"x": 1}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error with empty token")
|
||||
}
|
||||
if called {
|
||||
t.Error("request was sent despite empty token — must fail closed")
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503-style APIError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterFailsClosedWithoutURL(t *testing.T) {
|
||||
w := newTestWriter("", "tok")
|
||||
err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", nil, nil)
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503-style APIError for empty base URL, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterSendsBearerAndJSON(t *testing.T) {
|
||||
var gotAuth, gotCT, gotMethod, gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"id":"e1"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "sekrit")
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := w.do(context.Background(), http.MethodPost, "/api/projax/edges", map[string]any{"rel": "child_of"}, &out); err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer sekrit" {
|
||||
t.Errorf("Authorization = %q, want Bearer sekrit", gotAuth)
|
||||
}
|
||||
if gotCT != "application/json" {
|
||||
t.Errorf("Content-Type = %q", gotCT)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != "/api/projax/edges" {
|
||||
t.Errorf("method/path = %s %s", gotMethod, gotPath)
|
||||
}
|
||||
if gotBody["rel"] != "child_of" {
|
||||
t.Errorf("body rel = %v", gotBody["rel"])
|
||||
}
|
||||
if out.ID != "e1" {
|
||||
t.Errorf("decoded id = %q", out.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterAddLinkConstructsSelfEdge(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/projax/edges" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"id":"edge-123"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "tok")
|
||||
|
||||
link, err := w.AddLink(context.Background(), "item-1", "caldav-list", "https://dav/cal", "contains",
|
||||
map[string]any{"display_name": "Work"})
|
||||
if err != nil {
|
||||
t.Fatalf("AddLink: %v", err)
|
||||
}
|
||||
// Self-edge: source == target == item, rel namespaced.
|
||||
if gotBody["source"] != "item-1" || gotBody["target"] != "item-1" {
|
||||
t.Errorf("self-edge source/target = %v/%v", gotBody["source"], gotBody["target"])
|
||||
}
|
||||
if gotBody["rel"] != "projax-caldav-list" {
|
||||
t.Errorf("rel = %v, want projax-caldav-list", gotBody["rel"])
|
||||
}
|
||||
meta, _ := gotBody["metadata"].(map[string]any)
|
||||
if meta["url"] != "https://dav/cal" {
|
||||
t.Errorf("metadata.url = %v (reader decodes caldav RefID from here)", meta["url"])
|
||||
}
|
||||
if meta["ref_id"] != "https://dav/cal" {
|
||||
t.Errorf("metadata.ref_id = %v", meta["ref_id"])
|
||||
}
|
||||
if meta["projax_rel"] != "contains" {
|
||||
t.Errorf("metadata.projax_rel = %v", meta["projax_rel"])
|
||||
}
|
||||
if meta["display_name"] != "Work" {
|
||||
t.Errorf("caller metadata not merged: %v", meta["display_name"])
|
||||
}
|
||||
if link.ID != "edge-123" || link.ItemID != "item-1" || link.RefType != "caldav-list" {
|
||||
t.Errorf("returned link = %+v", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterAddLinkDatedCarriesDateAndNote(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"id":"e9"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "tok")
|
||||
note := "filed brief"
|
||||
d := time.Date(2026, 3, 14, 0, 0, 0, 0, time.UTC)
|
||||
_, err := w.AddLinkDated(context.Background(), "i1", "doc", "/docs/brief.pdf", "source", ¬e, &d, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddLinkDated: %v", err)
|
||||
}
|
||||
meta, _ := gotBody["metadata"].(map[string]any)
|
||||
if meta["event_date"] != "2026-03-14" {
|
||||
t.Errorf("event_date = %v", meta["event_date"])
|
||||
}
|
||||
if meta["note"] != "filed brief" {
|
||||
t.Errorf("note = %v (gap G2: rides in metadata, not edge.note)", meta["note"])
|
||||
}
|
||||
if meta["url"] != "/docs/brief.pdf" {
|
||||
t.Errorf("doc url = %v", meta["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeMetadataForLinkPerRefType(t *testing.T) {
|
||||
mGitRepo := edgeMetadataForLink("gitea-repo", "m/projax", "contains", nil, nil, nil)
|
||||
if mGitRepo["owner"] != "m" || mGitRepo["repo"] != "projax" {
|
||||
t.Errorf("gitea-repo owner/repo = %v/%v", mGitRepo["owner"], mGitRepo["repo"])
|
||||
}
|
||||
mIssue := edgeMetadataForLink("gitea-issue", "m/projax#5", "contains", nil, nil, nil)
|
||||
if mIssue["owner"] != "m" || mIssue["repo"] != "projax" || mIssue["number"] != 5 {
|
||||
t.Errorf("gitea-issue parse = %v/%v/#%v", mIssue["owner"], mIssue["repo"], mIssue["number"])
|
||||
}
|
||||
mMai := edgeMetadataForLink("mai-project", "proj-uuid", "contains", nil, nil, nil)
|
||||
if mMai["mai_project_id"] != "proj-uuid" {
|
||||
t.Errorf("mai-project id = %v", mMai["mai_project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjaxBundleForCreateDefaults(t *testing.T) {
|
||||
b := projaxBundleForCreate(CreateInput{Kind: []string{"project"}, Title: "x"})
|
||||
if b["kind"] != "project" {
|
||||
t.Errorf("kind = %v", b["kind"])
|
||||
}
|
||||
if b["status"] != "active" {
|
||||
t.Errorf("status default = %v, want active", b["status"])
|
||||
}
|
||||
// area co-kind
|
||||
ba := projaxBundleForCreate(CreateInput{Kind: []string{"project", "area"}, Title: "x", Status: "done"})
|
||||
if ba["kind"] != "area" {
|
||||
t.Errorf("area kind = %v", ba["kind"])
|
||||
}
|
||||
if ba["status"] != "done" {
|
||||
t.Errorf("explicit status = %v", ba["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjaxBundleForUpdateNestsPublic(t *testing.T) {
|
||||
b := projaxBundleForUpdate(UpdateInput{
|
||||
Status: "active", Public: true, PublicDescription: "desc", PublicLiveURL: "https://x",
|
||||
})
|
||||
pub, ok := b["public"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("public not a nested object: %T", b["public"])
|
||||
}
|
||||
if pub["enabled"] != true || pub["description"] != "desc" || pub["live_url"] != "https://x" {
|
||||
t.Errorf("public bundle = %v (must match reader's projax.public.* shape)", pub)
|
||||
}
|
||||
}
|
||||
|
||||
var uuidV4Re = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
|
||||
func TestNewUUIDv4Format(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 100 {
|
||||
u, err := newUUIDv4()
|
||||
if err != nil {
|
||||
t.Fatalf("newUUIDv4: %v", err)
|
||||
}
|
||||
if !uuidV4Re.MatchString(u) {
|
||||
t.Fatalf("not a v4 uuid: %q", u)
|
||||
}
|
||||
if seen[u] {
|
||||
t.Fatalf("duplicate uuid %q", u)
|
||||
}
|
||||
seen[u] = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user