package mcpsrv import ( "context" "errors" "fmt" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // The generic tools of docs/DESIGN.mcp.md §9.1: the browse layer (browse/) // offered as tools, for any hosted database rather than a beads tracker. // // They live beside read.go rather than in it because §9.1 and §9.2 are two // chapters with two file sets — read.go keeps list_databases, the entry point // whose answers every tool here takes its arguments from, and the beads tools of // §9.2 arrive in a file of their own. One file per chapter is what keeps each of // them readable; the registration of the whole surface still happens in one // place (register, read.go). // // The rules read.go states once hold here too — the {owner, name} address, // visibility that is applied rather than re-derived, an array for every list — // and these are the ones this chapter adds: // // - Every tool resolves the database exactly as the browse handlers do // (resolveDatabase): the row, the caller's ACL grant, core.Allowed. A // database the caller may not read is *not found*, the same sentence a name // nobody took gets, because a distinguishable refusal is what the 404 exists // to erase. // - A ref that does not resolve and a table that does not exist are the other // kind of miss entirely: they are ordinary answers *about a database the // caller can see*, and they name what was not found. Confusing the two would // either leak the existence of a private database or hide a typo behind a // sentence about a database the agent is looking straight at. // - A cap is applied and *stated*. A page that stops short says so and carries // the number it stopped short of, because a caller that cannot see the table // has no other way to tell a full answer from a clipped one (§9.3). // - One session per call, closed by the handler that opened it. That is the // browse discipline: a fresh read of the on-disk manifest, so a push that // landed a second ago is visible, and no handle outlives the call. // The caps of docs/DESIGN.mcp.md §9.3. // // A default exists so that an agent that names no limit gets a page rather than // a table; a maximum exists because this surface answers in one response and a // caller can always page. Neither is silent: the answer reports the limit that // was applied and whether anything was left behind. const ( defaultRowLimit = 100 maxRowLimit = 500 defaultCommitLimit = 25 maxCommitLimit = 100 ) // databaseRef is how every tool of this chapter addresses a database: the two // fields of the URL, both without the "~", exactly as list_databases answers // them. // // It is embedded in each tool's input struct, so the derived schema carries // owner and name as two required properties of that tool rather than as a nested // object — an agent copying an address out of a link fills in two strings, and // does not have to learn a wrapper type first. type databaseRef struct { Owner string `json:"owner" jsonschema:"the database owner's SourceHut username, without the \"~\" (a leading one is accepted)"` Name string `json:"name" jsonschema:"the database name, as list_databases reports it"` } // owner is the username with the sigil and any stray whitespace removed. A // leading "~" is tolerated rather than refused for read.go's reason: it is what // a link shows, so an agent copying an address is more likely to include it than // not. func (r databaseRef) owner() string { return strings.TrimPrefix(strings.TrimSpace(r.Owner), "~") } func (r databaseRef) name() string { return strings.TrimSpace(r.Name) } // String renders the address the way a link does, which is how every sentence a // caller reads names the database it is about. func (r databaseRef) String() string { return "~" + r.owner() + "/" + r.name() } // --- the shapes a caller decodes ------------------------------------------- // branchJSON is one branch and the head commit it points at. type branchJSON struct { Name string `json:"name"` Head string `json:"head"` } type listBranchesInput struct { databaseRef } type listBranchesOutput struct { Branches []branchJSON `json:"branches"` // DefaultBranch is browse.DefaultBranch's answer over the list above, and it // is reported rather than left for the agent to work out: it is the ref every // tool here falls back to, so a caller that wants to name the same one // explicitly should not have to reimplement the rule. It is "" for a database // with no branches at all. DefaultBranch string `json:"default_branch"` } // columnJSON is one column of a table's schema. type columnJSON struct { Name string `json:"name"` Type string `json:"type"` PrimaryKey bool `json:"primary_key"` Nullable bool `json:"nullable"` } // tableJSON is one table at a ref: its schema and its exact row count. type tableJSON struct { Name string `json:"name"` Columns []columnJSON `json:"columns"` RowCount uint64 `json:"row_count"` } type listTablesInput struct { databaseRef Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"` } type listTablesOutput struct { // Ref is the ref actually read, which is the default branch when the call // named none. An agent that omitted it learns from the answer which branch it // is looking at, and can name that one for the rest of a series of calls. Ref string `json:"ref"` Tables []tableJSON `json:"tables"` } type readRowsInput struct { databaseRef Table string `json:"table" jsonschema:"the table to read, as list_tables names it"` Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"` // Offset and Limit are pointers so that an omitted argument and an explicit // zero are two different calls. An omitted limit is "you choose" and gets the // default; a limit of 0 was typed by the caller, means "no rows at all", and // is refused rather than quietly turned into 100 — a page nobody asked for // reads as an empty table, which is the same class of lie as a silent // truncation. Offset *int `json:"offset,omitempty" jsonschema:"how many rows to skip, zero or more; defaults to 0"` Limit *int `json:"limit,omitempty" jsonschema:"how many rows to return, at most 500; defaults to 100"` } type readRowsOutput struct { Ref string `json:"ref"` Table string `json:"table"` // Columns names the cells of every row, in order: for a keyed table the // primary-key columns first, then the rest. Columns []string `json:"columns"` // Rows are rendered strings and not typed values: this surface reads a bare // store without a SQL engine, so what it has is the stored value formatted. // A NULL cell reads as "NULL" and an unprintable one as "", neither // of which is distinguishable from a row that stores those very strings — // which is a limit worth knowing rather than a bug to work around. Rows [][]string `json:"rows"` Offset int `json:"offset"` // Limit is the limit that was applied, which is not always the one asked for: // a request above the cap is answered at the cap, and saying so here is what // keeps that from being a silent clamp. Limit int `json:"limit"` // Total is the number of rows in the whole table at this ref — the honest // denominator of the page above, straight from browse.RowPage. Total int `json:"total"` // Truncated reports that rows remain after this page (docs/DESIGN.mcp.md // §9.3). Raise offset to read them. Truncated bool `json:"truncated"` } // commitJSON is one commit of a log listing. // // The author's email is deliberately absent: the browse UI publishes the name // and not the address (web/templates/log.html), and a surface reachable by any // token has no business publishing more of a person than the page does. type commitJSON struct { Hash string `json:"hash"` Author string `json:"author"` Date time.Time `json:"date"` Message string `json:"message"` Parents []string `json:"parents"` } type getCommitLogInput struct { databaseRef Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to start from; omit it for the database's default branch"` From string `json:"from,omitempty" jsonschema:"the \"next\" hash of a previous page, to continue where it stopped; it takes the place of ref"` Limit *int `json:"limit,omitempty" jsonschema:"how many commits to return, at most 100; defaults to 25"` } type getCommitLogOutput struct { // Ref is the ref this page was read at, and is empty when the call continued // a cursor: a from-hash is a point in the graph and claiming it belongs to // some branch would be a claim this service did not check. Ref string `json:"ref"` Commits []commitJSON `json:"commits"` // Next is the hash to pass as from for the following page, or "" at the end // of the history. Next string `json:"next"` // Limit is the limit applied, as in readRowsOutput. Limit int `json:"limit"` // Truncated reports that the history goes on past this page. // // There is no total beside it, and that is not an omission: a commit count // means walking the whole graph, browse/ offers no such call, and a number // that expensive would be paid for by every caller of a paging tool. Next is // the honest continuation here — it is present exactly when something was // left behind. Truncated bool `json:"truncated"` } // tableDiffJSON is how one table changed in a commit. type tableDiffJSON struct { Name string `json:"name"` Added bool `json:"added"` Dropped bool `json:"dropped"` SchemaChanged bool `json:"schema_changed"` // The row counts are exact, with one documented exception browse/ owns: when // the primary key set changed there is no row-level correspondence to count // across, so the three are 0 and SchemaChanged is true. RowsAdded int64 `json:"rows_added"` RowsRemoved int64 `json:"rows_removed"` RowsModified int64 `json:"rows_modified"` } type getCommitDiffInput struct { databaseRef Hash string `json:"hash" jsonschema:"the commit to summarize; a branch name is accepted and means that branch's head"` } type getCommitDiffOutput struct { // Hash is the resolved commit hash, which is worth carrying back when the // call named a branch: it pins which commit the answer is about even if the // branch moves a moment later. Hash string `json:"hash"` Tables []tableDiffJSON `json:"tables"` } // --- registration ----------------------------------------------------------- // registerBrowseTools installs the tools of docs/DESIGN.mcp.md §9.1. // // The descriptions are the agent-facing documentation of this surface: what the // tool answers, how to address it, what it costs, and — for every one of them — // what it cannot do, since an agent that knows there is no SQL here stops // looking for it. func (s *Server) registerBrowseTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "list_branches", Annotations: readOnlyTool, Description: "List the branches of one hosted Dolt database, each with the hash of its head commit.\n\n" + "Address the database by the `owner` and `name` that list_databases reports (the two parts of " + "its URL, without the \"~\").\n\n" + "`default_branch` is the branch every other tool reads when you pass no `ref`: \"main\" when it " + "exists, otherwise the first branch by name. A database nothing has been pushed to yet has no " + "branches, and answers an empty list with an empty default.\n\n" + "A database you may not read is reported as not existing, which is the same answer a name " + "nobody took gets.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in listBranchesInput) (*mcp.CallToolResult, listBranchesOutput, error) { out, err := s.listBranches(ctx, in) return nil, out, err }) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "list_tables", Annotations: readOnlyTool, Description: "List the tables of one hosted Dolt database at one ref, each with its columns — name, SQL " + "type, whether it is part of the primary key, whether it is nullable — and its exact row count.\n\n" + "`ref` is a branch name or a commit hash; omit it to read the database's default branch, which " + "the answer names back to you. A ref that matches neither is reported as such, and says so " + "about that database rather than pretending the database is missing.\n\n" + "This is the schema of the data, not a query interface: there is no SQL on this surface and " + "there will not be one. Read a table with read_rows and project or aggregate it yourself.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in listTablesInput) (*mcp.CallToolResult, listTablesOutput, error) { out, err := s.listTables(ctx, in) return nil, out, err }) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "read_rows", Annotations: readOnlyTool, Description: "Read one page of rows from one table of a hosted Dolt database.\n\n" + "`columns` names the cells of every row in order (primary-key columns first for a keyed " + "table). Cells are rendered strings, because this surface reads a bare store without a SQL " + "engine: a NULL cell reads as `NULL` and an unprintable one as ``, neither " + "distinguishable from a row that stores those strings literally.\n\n" + "`limit` defaults to 100 and is capped at 500; the `limit` in the answer is the one actually " + "applied, so a larger request is visibly answered at the cap. `total` is the number of rows " + "in the whole table, and `truncated` says rows remain after this page — read them by raising " + "`offset`, which is O(1) here regardless of how far in it points.\n\n" + "Rows come back in the table's own key order and cannot be filtered or sorted server-side. " + "For a beads tracker, prefer the beads tools: they answer the questions this table would " + "make you assemble by hand.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in readRowsInput) (*mcp.CallToolResult, readRowsOutput, error) { out, err := s.readRows(ctx, in) return nil, out, err }) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "get_commit_log", Annotations: readOnlyTool, Description: "Read the commit history of a hosted Dolt database, newest first: hash, author, date, " + "message and parent hashes.\n\n" + "`ref` is a branch name or a commit hash to start from; omit it for the default branch. " + "`limit` defaults to 25 and is capped at 100.\n\n" + "When the history goes on past the page, `truncated` is true and `next` carries the hash of " + "the commit that follows it: pass that as `from` for the next page. `from` takes the place of " + "`ref`, so a continued page reports no ref. There is no commit total — counting one would " + "mean walking the entire graph — so `next` is how you tell a full answer from a clipped one.\n\n" + "A merge commit lists every parent; the history is walked in reverse topological order.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitLogInput) (*mcp.CallToolResult, getCommitLogOutput, error) { out, err := s.getCommitLog(ctx, in) return nil, out, err }) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "get_commit_diff", Annotations: readOnlyTool, Description: "Summarize what one commit changed, table by table, against its first parent — and for the " + "initial commit against an empty database, where every table reads as added.\n\n" + "`hash` is a commit hash; a branch name is accepted and means that branch's head. Each entry " + "says whether the table was added or dropped, whether its schema changed, and how many rows " + "were added, removed and modified.\n\n" + "The row counts are exact, with one exception: when the primary key set changed there is no " + "row-level correspondence to count across, so `schema_changed` is true and the three counts " + "are 0.\n\n" + "This is a summary and never a row-level diff — no cell values are reported. To see what the " + "rows became, read the table at this commit with read_rows, passing the hash as `ref`.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitDiffInput) (*mcp.CallToolResult, getCommitDiffOutput, error) { out, err := s.getCommitDiff(ctx, in) return nil, out, err }) } // --- the handlers ----------------------------------------------------------- // listBranches answers list_branches: every branch of one database with its // head, plus the default the other tools fall back to. func (s *Server) listBranches(ctx context.Context, in listBranchesInput) (listBranchesOutput, error) { const tool = "list_branches" var out listBranchesOutput repo, err := s.resolveDatabase(ctx, tool, in.databaseRef) if err != nil { return out, err } sess, err := s.openStore(ctx, tool, repo) if err != nil { return out, err } defer sess.Close() branches, err := sess.Branches(ctx) if err != nil { // Branches resolves no ref and names no table, so there is no miss it can // report: whatever went wrong here is this service's. return out, internalError(err, tool) } out.Branches = make([]branchJSON, 0, len(branches)) for _, b := range branches { out.Branches = append(out.Branches, branchJSON{Name: b.Name, Head: b.Head}) } out.DefaultBranch = browse.DefaultBranch(branches) return out, nil } // listTables answers list_tables: the schema of a database at one ref. func (s *Server) listTables(ctx context.Context, in listTablesInput) (listTablesOutput, error) { const tool = "list_tables" var out listTablesOutput repo, err := s.resolveDatabase(ctx, tool, in.databaseRef) if err != nil { return out, err } sess, err := s.openStore(ctx, tool, repo) if err != nil { return out, err } defer sess.Close() ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref) if err != nil { return out, err } tables, err := sess.Tables(ctx, ref) if err != nil { return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref)) } out.Ref = ref out.Tables = make([]tableJSON, 0, len(tables)) for _, t := range tables { cols := make([]columnJSON, 0, len(t.Columns)) for _, c := range t.Columns { cols = append(cols, columnJSON{ Name: c.Name, Type: c.Type, PrimaryKey: c.PrimaryKey, Nullable: c.Nullable, }) } out.Tables = append(out.Tables, tableJSON{Name: t.Name, Columns: cols, RowCount: t.RowCount}) } return out, nil } // readRows answers read_rows: one page of a table, with the denominator that // makes the page readable as a page. func (s *Server) readRows(ctx context.Context, in readRowsInput) (readRowsOutput, error) { const tool = "read_rows" var out readRowsOutput table := strings.TrimSpace(in.Table) if table == "" { return out, errors.New("name the table to read; list_tables names the tables of a database") } offset, err := pageOffset(in.Offset) if err != nil { return out, err } limit, err := pageLimit(in.Limit, defaultRowLimit, maxRowLimit, "rows") if err != nil { return out, err } repo, err := s.resolveDatabase(ctx, tool, in.databaseRef) if err != nil { return out, err } sess, err := s.openStore(ctx, tool, repo) if err != nil { return out, err } defer sess.Close() ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref) if err != nil { return out, err } page, err := sess.Rows(ctx, ref, table, offset, limit) if err != nil { // Two misses about a database the caller can see, and the table arm is // asked first because it is the more specific of the two. if errors.Is(err, browse.ErrTableNotFound) { return out, errors.New(noSuchTable(in.databaseRef, ref, table)) } return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref)) } out = readRowsOutput{ Ref: ref, Table: table, Columns: page.Columns, Rows: page.Rows, Offset: page.Offset, Limit: limit, Total: page.Total, // The page stopped short exactly when something is left after it. Reading // it off the page's own offset and length, rather than off "did I get // limit rows back", is what keeps this honest when browse returns a short // page for a reason of its own. Truncated: page.Offset+len(page.Rows) < page.Total, } if out.Columns == nil { out.Columns = []string{} } if out.Rows == nil { out.Rows = [][]string{} } return out, nil } // getCommitLog answers get_commit_log: a page of history with the cursor that // continues it. func (s *Server) getCommitLog(ctx context.Context, in getCommitLogInput) (getCommitLogOutput, error) { const tool = "get_commit_log" var out getCommitLogOutput limit, err := pageLimit(in.Limit, defaultCommitLimit, maxCommitLimit, "commits") if err != nil { return out, err } from := strings.TrimSpace(in.From) repo, err := s.resolveDatabase(ctx, tool, in.databaseRef) if err != nil { return out, err } sess, err := s.openStore(ctx, tool, repo) if err != nil { return out, err } defer sess.Close() // A cursor is a point in the graph and browse.Log starts there, ignoring the // ref entirely. Resolving a default branch anyway would cost a read and buy a // ref the answer could not honestly claim the page belongs to. ref := "" if from == "" { if ref, err = refFor(ctx, sess, tool, in.databaseRef, in.Ref); err != nil { return out, err } } commits, next, err := sess.Log(ctx, ref, from, limit) if err != nil { // browse classifies an unparseable from-hash as a fault of its own rather // than as a miss (it has no sentinel for it), so a hand-written cursor // takes the protocol arm here. A cursor comes from a previous page of this // very tool, and teaching this package to parse a dolt hash would be a // second reading of a format browse/ owns. return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref)) } out.Ref = ref out.Limit = limit out.Next = next out.Truncated = next != "" out.Commits = make([]commitJSON, 0, len(commits)) for _, c := range commits { parents := c.ParentHashes if parents == nil { parents = []string{} } out.Commits = append(out.Commits, commitJSON{ Hash: c.Hash, Author: c.Author, Date: c.Date, Message: c.Message, Parents: parents, }) } return out, nil } // getCommitDiff answers get_commit_diff: the per-table summary browse computes // for one commit against its first parent. func (s *Server) getCommitDiff(ctx context.Context, in getCommitDiffInput) (getCommitDiffOutput, error) { const tool = "get_commit_diff" var out getCommitDiffOutput hash := strings.TrimSpace(in.Hash) if hash == "" { return out, errors.New("name the commit to summarize; get_commit_log lists the hashes of a database") } repo, err := s.resolveDatabase(ctx, tool, in.databaseRef) if err != nil { return out, err } sess, err := s.openStore(ctx, tool, repo) if err != nil { return out, err } defer sess.Close() // No default here: a diff is about one named commit, and defaulting to the // head would answer a question the caller did not ask. diff, err := sess.CommitSummary(ctx, hash) if err != nil { return out, refMiss(err, tool, noSuchCommit(in.databaseRef, hash)) } out.Hash = diff.Hash out.Tables = make([]tableDiffJSON, 0, len(diff.Tables)) for _, t := range diff.Tables { out.Tables = append(out.Tables, tableDiffJSON{ Name: t.Name, Added: t.Added, Dropped: t.Dropped, SchemaChanged: t.SchemaChanged, RowsAdded: t.RowsAdded, RowsRemoved: t.RowsRemoved, RowsModified: t.RowsModified, }) } return out, nil } // --- resolving, opening, capping -------------------------------------------- // resolveDatabase turns an address into a repository the caller is allowed to // read, or into the one refusal this surface has. // // It is the browse handlers' dance call for call (web/router.go's // loadRepoForBrowse, docs/DESIGN.mcp.md §4.3): the row, the caller's ACL grant, // core.Allowed for OpBrowse. Two differences from the web, both deliberate: // // - There is no forbidden arm. The web tells a caller who may see that a // database exists but may not read it apart from one who may not learn it // exists at all (core.NotFoundForPrivate); here both are the same sentence, // so the distinction cannot be read out of a pair of answers. That is // stricter than the web and never looser, so nothing becomes visible that // was not. // - An ACL lookup that fails is a protocol error, where the web degrades to // "no grant" and falls back to visibility. On a page that degradation costs // a signed-in user a rendering; here it would tell an agent that a database // it was granted access to does not exist, and an agent believes that and // rewrites its plan. "I could not check" is not "you may not". func (s *Server) resolveDatabase(ctx context.Context, tool string, ref databaseRef) (*core.Repo, error) { if ref.owner() == "" || ref.name() == "" { return nil, errors.New("address a database by both its owner and its name, as list_databases reports them") } caller := callerOf(ctx) missing := noSuchDatabase(ref) repo, err := s.repos.GetRepoByOwnerAndName(ctx, ref.owner(), ref.name()) if err != nil { return nil, missingOrDenied(err, tool, missing) } // An anonymous caller holds no ACL entry and there is no user id to look one // up by; visibility decides alone, which is what core.Allowed does with a nil // grant. var mode *core.AccessMode if caller != nil { if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil { return nil, internalError(err, tool) } } if !core.Allowed(caller, repo, mode, core.OpBrowse) { return nil, errors.New(missing) } return repo, nil } // openStore opens the bare store of a database the caller may read. The handler // that calls it closes the session (defer sess.Close()). // // A store that will not open is a protocol error and not a miss: the database // exists, the caller may read it, and this service could not. list_databases // answers differently — it degrades that one entry and keeps listing, because a // listing is about a set — but a tool whose whole subject is this database has // nothing to answer with, and "not found" would be a false statement about the // data rather than a true one about the server. func (s *Server) openStore(ctx context.Context, tool string, repo *core.Repo) (BrowseSession, error) { sess, err := s.opener.Open(ctx, repo.Path) if err != nil { return nil, internalError(fmt.Errorf("opening the store of %s/%s: %w", repo.OwnerName, repo.Name, err), tool) } return sess, nil } // refFor answers which ref a call reads at: the one it named, or the // repository's default branch when it named none (docs/DESIGN.mcp.md §9.1), so // that an agent which does not care about branches never has to name one. // // A named ref is passed through unchecked, because browse resolves a branch name // *or* a commit hash and this package has no business deciding which of the two // a string is. One that resolves to neither comes back as a miss from the call // that used it. // // A database with no branches at all has no default to fall back to. That is an // answer and not a failure — nothing has been pushed to it yet, exactly the // database list_databases reports with a null content — so it is a sentence the // agent reads rather than an error the client raises. func refFor(ctx context.Context, sess BrowseSession, tool string, ref databaseRef, named string) (string, error) { if named = strings.TrimSpace(named); named != "" { return named, nil } branches, err := sess.Branches(ctx) if err != nil { return "", internalError(err, tool) } name := browse.DefaultBranch(branches) if name == "" { return "", errors.New(ref.String() + " has no branches: nothing has been pushed to it yet, so there is nothing to read") } return name, nil } // pageOffset validates the offset of a paging tool: absent is 0, and a negative // one is refused rather than clamped. A caller that computed -3 has a bug, and // answering its first page would hide it. func pageOffset(want *int) (int, error) { if want == nil { return 0, nil } if *want < 0 { return 0, fmt.Errorf("offset must be zero or more, not %d", *want) } return *want, nil } // pageLimit applies a cap of docs/DESIGN.mcp.md §9.3: absent takes the default, // more than the maximum is answered at the maximum, and zero or less is refused. // // Clamping and refusing are not inconsistent. A caller asking for 5000 rows // wants as many as it can have, and gets them with the applied limit stated in // the answer, so nothing is hidden. A caller asking for 0 or -1 wants something // that does not exist, and quietly handing it a default page would make the // answer a statement about a request nobody made. func pageLimit(want *int, def, max int, noun string) (int, error) { if want == nil { return def, nil } if *want <= 0 { return 0, fmt.Errorf("limit must be a positive number of %s, not %d", noun, *want) } if *want > max { return max, nil } return *want, nil } // --- the sentences a caller reads ------------------------------------------- // // Every one of them is built from the arguments of the call being answered and // never from an error's own text, which is errors.go's rule: browse names // on-disk paths and dolt internals, and db/ names what it looked up. // noSuchDatabase is the masked not-found: one sentence for a database that does // not exist and for one this caller may not read, so that the two cannot be told // apart by an agent probing names. func noSuchDatabase(ref databaseRef) string { return "no database " + ref.String() + ": it does not exist, or the credential this call carries does not reach it" } // noSuchRef is an ordinary answer about a database the caller *can* see, and // says so by naming it — the opposite of the masked sentence above. func noSuchRef(ref databaseRef, named string) string { return fmt.Sprintf("%s has no branch or commit %q; list_branches names its branches", ref, named) } func noSuchTable(ref databaseRef, at, table string) string { return fmt.Sprintf("%s has no table %q at %q; list_tables names the tables there", ref, table, at) } func noSuchCommit(ref databaseRef, hash string) string { return fmt.Sprintf("%s has no commit %q; get_commit_log lists its history", ref, hash) }