diff --git a/.gitignore b/.gitignore
index ea85465..64702b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,5 +17,6 @@
local.properties
/server/data/
/server/box-manifest-server
+/server/box-manifest
/web/node_modules/
/web/dist/
diff --git a/api/openapi.yaml b/api/openapi.yaml
index ddc341a..e98a443 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -8,6 +8,17 @@ info:
servers:
- url: /
paths:
+ /api/system/info:
+ get:
+ operationId: getSystemInfo
+ summary: Identify this server installation and API generation
+ responses:
+ "200":
+ description: Stable server installation information
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/SystemInfo" }
+ default: { $ref: "#/components/responses/Error" }
/api/node-types:
get:
operationId: listNodeTypes
@@ -208,6 +219,20 @@ paths:
$ref: "#/components/schemas/CreateNodesBulkResponse"
default:
$ref: "#/components/responses/Error"
+ patch:
+ operationId: updateNodesBulk
+ summary: Replace the description on multiple nodes atomically
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateNodesBulkRequest"
+ responses:
+ "204":
+ description: Descriptions updated
+ default:
+ $ref: "#/components/responses/Error"
/api/nodes/bulk/move:
post:
operationId: moveNodesBulk
@@ -429,6 +454,14 @@ components:
schema:
$ref: "#/components/schemas/Error"
schemas:
+ SystemInfo:
+ type: object
+ additionalProperties: false
+ required: [serverId, name, apiVersion]
+ properties:
+ serverId: { type: string, minLength: 1 }
+ name: { type: string, minLength: 1 }
+ apiVersion: { type: integer, minimum: 1 }
Node:
type: object
additionalProperties: false
@@ -441,6 +474,7 @@ components:
- quantity
- typeId
- tagIds
+ - revision
properties:
id:
type: string
@@ -471,6 +505,9 @@ components:
type: array
uniqueItems: true
items: { type: string }
+ revision:
+ type: integer
+ minimum: 1
ImportNode:
type: object
additionalProperties: false
@@ -575,6 +612,7 @@ components:
- quantity
- typeId
- tagIds
+ - revision
- children
properties:
id:
@@ -605,6 +643,9 @@ components:
type: array
uniqueItems: true
items: { type: string }
+ revision:
+ type: integer
+ minimum: 1
children:
type: array
items:
@@ -615,6 +656,10 @@ components:
required:
- name
properties:
+ clientId:
+ type: string
+ pattern: "^[0-9a-f]{32}$"
+ description: Optional client-generated identity used to make offline create retries idempotent.
parentId:
type: string
description: Omit to place the node beneath Unsorted.
@@ -703,6 +748,23 @@ components:
uniqueItems: true
items:
type: string
+ UpdateNodesBulkRequest:
+ type: object
+ additionalProperties: false
+ required:
+ - nodeIds
+ - description
+ properties:
+ nodeIds:
+ type: array
+ minItems: 1
+ uniqueItems: true
+ items:
+ type: string
+ description:
+ type:
+ - string
+ - "null"
IndividualizeNodeRequest:
type: object
additionalProperties: false
@@ -753,6 +815,10 @@ components:
- integer
- "null"
minimum: 0
+ baseRevision:
+ type: integer
+ minimum: 1
+ description: Reject the update with REVISION_CONFLICT unless the node still has this revision.
NodeType:
type: object
additionalProperties: false
diff --git a/docs/CLI.md b/docs/CLI.md
new file mode 100644
index 0000000..0872eb3
--- /dev/null
+++ b/docs/CLI.md
@@ -0,0 +1,119 @@
+# Box Manifest CLI
+
+The CLI is a JSON-first client for the Box Manifest HTTP API. It is intended
+for interactive use, shell automation, and AI agents. It performs no direct
+database access.
+
+Build it from the repository root:
+
+```bash
+cd server
+go build -o box-manifest ./cmd/box-manifest
+```
+
+The default server is `http://box-manifest.hosts.uuard.com:8080`. A different
+server can be selected with `BOX_MANIFEST_SERVER_URL` or a global flag placed
+before the command:
+
+```bash
+box-manifest --server http://localhost:8080 server show
+```
+
+Successful results are JSON on standard output. Failures are JSON on standard
+error and return a nonzero exit status. Add `--compact` before the command for
+single-line output.
+
+## Read and search
+
+```bash
+box-manifest server show
+box-manifest tree
+box-manifest search "blue bin"
+box-manifest node get --id NODE_ID
+box-manifest node get --code LOOKUP_CODE
+```
+
+Search matches names, descriptions, and exact lookup codes. Results contain a
+node ID and its complete path.
+
+## Nodes
+
+```bash
+box-manifest node create --parent PARENT_ID --name NAME \
+ [--description TEXT] [--quantity N]
+
+box-manifest node update --id NODE_ID [--name NAME] \
+ [--description TEXT | --clear-description] \
+ [--quantity N | --clear-quantity]
+
+box-manifest node move --id NODE_ID --parent PARENT_ID \
+ [--children WITH_SUBTREE|PROMOTE_CHILDREN|CHILDREN_TO_UNSORTED]
+
+box-manifest node delete --id NODE_ID
+box-manifest node individualize --id NODE_ID --names NAME,NAME,...
+box-manifest node combine --ids ID,ID,... --retain ID --name NAME
+box-manifest node classify --id NODE_ID [--type TYPE_ID|--clear-type] \
+ [--tags TAG_ID,TAG_ID,...]
+```
+
+Classification preserves the current type or tags when the corresponding flag
+is omitted. Pass an empty `--tags=` value to remove every tag.
+
+## Bulk operations
+
+```bash
+box-manifest node bulk-create --parent ID --names NAME,NAME,... \
+ [--description TEXT]
+box-manifest node bulk-move --ids ID,ID,... --parent ID [--children MODE]
+box-manifest node bulk-delete --ids ID,ID,...
+box-manifest node bulk-classify --ids ID,ID,... \
+ [--set-type --type TYPE_ID] [--add-tags ID,ID] [--remove-tags ID,ID]
+```
+
+Use `--set-type` without `--type` to clear the type from every selected node.
+
+## Types and tags
+
+```bash
+box-manifest type list
+box-manifest type create --name NAME [--description TEXT] [--icon KEY] [--color HEX]
+box-manifest type update --id ID --name NAME [--description TEXT] [--icon KEY] [--color HEX]
+box-manifest type delete --id ID
+
+box-manifest tag list
+box-manifest tag create --name NAME [--color HEX]
+box-manifest tag update --id ID --name NAME [--color HEX]
+box-manifest tag delete --id ID
+```
+
+## Import and export
+
+Import remains a deliberate two-step operation:
+
+```bash
+box-manifest import preview --parent PARENT_ID --file manifest.json
+box-manifest import commit --plan PLAN_ID
+```
+
+Use `--file -` or omit `--file` to read a manifest from standard input. Export
+produces the same portable, UUID-free representation as the web client:
+
+```bash
+box-manifest export
+box-manifest export --output inventory.json
+```
+
+## Raw API access
+
+The raw command ensures new API operations remain available before a friendly
+CLI wrapper is added:
+
+```bash
+box-manifest api GET /api/tree
+box-manifest api PATCH /api/nodes/NODE_ID \
+ --data '{"description":"Updated by automation"}'
+box-manifest api POST /api/nodes/bulk/move --data @request.json
+```
+
+Consult [`api/openapi.yaml`](../api/openapi.yaml) for endpoint request and
+response schemas.
diff --git a/server/cmd/box-manifest-server/main.go b/server/cmd/box-manifest-server/main.go
index de55505..490b7f0 100644
--- a/server/cmd/box-manifest-server/main.go
+++ b/server/cmd/box-manifest-server/main.go
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
"box-manifest/server/internal/httpapi"
"box-manifest/server/internal/tree"
@@ -43,11 +44,7 @@ func main() {
apiHandler := httpapi.New(store)
mux.Handle("/api/", apiHandler)
mux.Handle("/n/", apiHandler)
- webHandler := http.FileServer(http.Dir(*webPath))
- mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Cache-Control", "no-store")
- webHandler.ServeHTTP(w, r)
- }))
+ mux.Handle("/", newWebHandler(*webPath))
server := &http.Server{
Addr: *address,
@@ -58,3 +55,21 @@ func main() {
log.Fatal(err)
}
}
+
+func newWebHandler(webPath string) http.Handler {
+ files := http.FileServer(http.Dir(webPath))
+ indexPath := filepath.Join(webPath, "index.html")
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ requestedPath := filepath.Join(webPath, filepath.FromSlash(strings.TrimPrefix(filepath.Clean(r.URL.Path), "/")))
+ if info, err := os.Stat(requestedPath); err == nil && !info.IsDir() {
+ files.ServeHTTP(w, r)
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ http.NotFound(w, r)
+ return
+ }
+ http.ServeFile(w, r, indexPath)
+ })
+}
diff --git a/server/cmd/box-manifest-server/main_test.go b/server/cmd/box-manifest-server/main_test.go
new file mode 100644
index 0000000..67d3067
--- /dev/null
+++ b/server/cmd/box-manifest-server/main_test.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestWebHandlerServesAssetsAndClientRoutes(t *testing.T) {
+ directory := t.TempDir()
+ if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("app"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(directory, "app.js"), []byte("window.app=true"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ handler := newWebHandler(directory)
+ for _, path := range []string{"/", "/node/ABC123", "/import", "/export", "/types"} {
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "app") {
+ t.Fatalf("route %s: got %d %q", path, response.Code, response.Body.String())
+ }
+ }
+ asset := httptest.NewRecorder()
+ handler.ServeHTTP(asset, httptest.NewRequest(http.MethodGet, "/app.js", nil))
+ if asset.Code != http.StatusOK || asset.Body.String() != "window.app=true" {
+ t.Fatalf("asset: got %d %q", asset.Code, asset.Body.String())
+ }
+}
diff --git a/server/cmd/box-manifest/main.go b/server/cmd/box-manifest/main.go
new file mode 100644
index 0000000..aaa4309
--- /dev/null
+++ b/server/cmd/box-manifest/main.go
@@ -0,0 +1,636 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "box-manifest/server/internal/apiclient"
+)
+
+const defaultServer = "http://box-manifest.hosts.uuard.com:8080"
+
+type app struct {
+ client *apiclient.Client
+ out io.Writer
+}
+
+func main() {
+ if err := run(context.Background(), os.Args[1:], os.Stdout); err != nil {
+ var apiError *apiclient.Error
+ if errors.As(err, &apiError) {
+ _ = json.NewEncoder(os.Stderr).Encode(map[string]any{"error": apiError.Code, "message": apiError.Message, "status": apiError.Status})
+ } else {
+ _ = json.NewEncoder(os.Stderr).Encode(map[string]any{"error": "cli_error", "message": err.Error()})
+ }
+ os.Exit(1)
+ }
+}
+
+func run(ctx context.Context, args []string, out io.Writer) error {
+ global := flag.NewFlagSet("box-manifest", flag.ContinueOnError)
+ global.SetOutput(io.Discard)
+ server := global.String("server", envOr("BOX_MANIFEST_SERVER_URL", defaultServer), "server base URL")
+ compact := global.Bool("compact", false, "emit compact JSON")
+ if err := global.Parse(args); err != nil {
+ return err
+ }
+ args = global.Args()
+ if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
+ _, _ = fmt.Fprint(out, usage)
+ return nil
+ }
+ client, err := apiclient.New(*server)
+ if err != nil {
+ return err
+ }
+ a := &app{client: client, out: out}
+ result, err := a.dispatch(ctx, args)
+ if err != nil {
+ return err
+ }
+ if result == nil {
+ return nil
+ }
+ if *compact {
+ _, err = fmt.Fprintln(out, string(result))
+ return err
+ }
+ var pretty bytes.Buffer
+ if err := json.Indent(&pretty, result, "", " "); err != nil {
+ return err
+ }
+ _, err = fmt.Fprintln(out, pretty.String())
+ return err
+}
+
+func (a *app) dispatch(ctx context.Context, args []string) (json.RawMessage, error) {
+ switch args[0] {
+ case "server":
+ if len(args) == 2 && args[1] == "show" {
+ return a.client.Do(ctx, http.MethodGet, "/api/system/info", nil)
+ }
+ case "tree":
+ return a.client.Do(ctx, http.MethodGet, "/api/tree", nil)
+ case "search":
+ return a.search(ctx, args[1:])
+ case "node":
+ return a.node(ctx, args[1:])
+ case "type":
+ return a.metadata(ctx, "node-types", args[1:])
+ case "tag":
+ return a.metadata(ctx, "tags", args[1:])
+ case "import":
+ return a.importTree(ctx, args[1:])
+ case "export":
+ return a.exportTree(ctx, args[1:])
+ case "api":
+ return a.rawAPI(ctx, args[1:])
+ }
+ return nil, fmt.Errorf("unknown or incomplete command; run box-manifest help")
+}
+
+func (a *app) search(ctx context.Context, args []string) (json.RawMessage, error) {
+ if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
+ return nil, fmt.Errorf("usage: box-manifest search QUERY")
+ }
+ raw, err := a.client.Do(ctx, http.MethodGet, "/api/tree", nil)
+ if err != nil {
+ return nil, err
+ }
+ var root node
+ if err := json.Unmarshal(raw, &root); err != nil {
+ return nil, err
+ }
+ query := strings.ToLower(args[0])
+ results := make([]searchResult, 0)
+ walk(root, nil, func(n node, parents []string) {
+ if strings.Contains(strings.ToLower(n.Name), query) || strings.Contains(strings.ToLower(n.Description), query) || strings.EqualFold(n.LookupCode, query) {
+ results = append(results, searchResult{ID: n.ID, LookupCode: n.LookupCode, Name: n.Name, Description: n.Description, Quantity: n.Quantity, Path: append(append([]string{}, parents...), n.Name)})
+ }
+ })
+ return marshal(results)
+}
+
+func (a *app) node(ctx context.Context, args []string) (json.RawMessage, error) {
+ if len(args) == 0 {
+ return nil, fmt.Errorf("node command required")
+ }
+ switch args[0] {
+ case "get":
+ fs := flags("node get")
+ id, code := fs.String("id", "", "node ID"), fs.String("code", "", "lookup code")
+ if err := fs.Parse(args[1:]); err != nil || (*id == "") == (*code == "") {
+ return nil, fmt.Errorf("usage: box-manifest node get (--id ID | --code CODE)")
+ }
+ if *code != "" {
+ return a.client.Do(ctx, http.MethodGet, "/api/nodes/by-code/"+url.PathEscape(*code), nil)
+ }
+ return a.client.Do(ctx, http.MethodGet, "/api/nodes/"+url.PathEscape(*id), nil)
+ case "create":
+ fs := flags("node create")
+ parent, name := fs.String("parent", "", "parent ID"), fs.String("name", "", "name")
+ description := fs.String("description", "", "description")
+ quantity := optionalInt64Flag(fs, "quantity")
+ if err := fs.Parse(args[1:]); err != nil || strings.TrimSpace(*name) == "" {
+ return nil, fmt.Errorf("usage: box-manifest node create --name NAME [--parent ID] [--description TEXT] [--quantity N]")
+ }
+ body := map[string]any{"name": *name}
+ if *parent != "" {
+ body["parentId"] = *parent
+ }
+ if *description != "" {
+ body["description"] = *description
+ }
+ if quantity.set {
+ body["quantity"] = quantity.value
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/nodes", body)
+ case "update":
+ fs := flags("node update")
+ id, name := fs.String("id", "", "node ID"), fs.String("name", "", "new name")
+ description := optionalStringFlag(fs, "description")
+ quantity := optionalInt64Flag(fs, "quantity")
+ clearDescription := fs.Bool("clear-description", false, "clear description")
+ clearQuantity := fs.Bool("clear-quantity", false, "clear quantity")
+ if err := fs.Parse(args[1:]); err != nil || *id == "" {
+ return nil, fmt.Errorf("usage: box-manifest node update --id ID [--name NAME] [--description TEXT|--clear-description] [--quantity N|--clear-quantity]")
+ }
+ body := map[string]any{}
+ if *name != "" {
+ body["name"] = *name
+ }
+ if description.set {
+ body["description"] = description.value
+ }
+ if *clearDescription {
+ body["description"] = nil
+ }
+ if quantity.set {
+ body["quantity"] = quantity.value
+ }
+ if *clearQuantity {
+ body["quantity"] = nil
+ }
+ if len(body) == 0 {
+ return nil, fmt.Errorf("at least one update is required")
+ }
+ return a.client.Do(ctx, http.MethodPatch, "/api/nodes/"+url.PathEscape(*id), body)
+ case "move":
+ fs := flags("node move")
+ id, parent := fs.String("id", "", "node ID"), fs.String("parent", "", "target parent ID")
+ children := fs.String("children", "WITH_SUBTREE", "WITH_SUBTREE, PROMOTE_CHILDREN, or CHILDREN_TO_UNSORTED")
+ if err := fs.Parse(args[1:]); err != nil || *id == "" || *parent == "" {
+ return nil, fmt.Errorf("usage: box-manifest node move --id ID --parent ID [--children MODE]")
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/nodes/"+url.PathEscape(*id)+"/move", map[string]any{"targetParentId": *parent, "childHandling": strings.ToUpper(*children)})
+ case "delete":
+ fs := flags("node delete")
+ id := fs.String("id", "", "node ID")
+ if err := fs.Parse(args[1:]); err != nil || *id == "" {
+ return nil, fmt.Errorf("usage: box-manifest node delete --id ID")
+ }
+ _, err := a.client.Do(ctx, http.MethodDelete, "/api/nodes/"+url.PathEscape(*id), nil)
+ if err != nil {
+ return nil, err
+ }
+ return marshal(map[string]any{"deleted": []string{*id}})
+ case "individualize":
+ fs := flags("node individualize")
+ id, names := fs.String("id", "", "node ID"), fs.String("names", "", "comma-separated names")
+ if err := fs.Parse(args[1:]); err != nil || *id == "" || *names == "" {
+ return nil, fmt.Errorf("usage: box-manifest node individualize --id ID --names NAME,NAME,...")
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/nodes/"+url.PathEscape(*id)+"/individualize", map[string]any{"names": splitList(*names)})
+ case "combine":
+ fs := flags("node combine")
+ ids, retained, name := fs.String("ids", "", "comma-separated node IDs"), fs.String("retain", "", "retained node ID"), fs.String("name", "", "combined name")
+ if err := fs.Parse(args[1:]); err != nil || *ids == "" || *retained == "" || *name == "" {
+ return nil, fmt.Errorf("usage: box-manifest node combine --ids ID,ID --retain ID --name NAME")
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/combine", map[string]any{"nodeIds": splitList(*ids), "retainedNodeId": *retained, "name": *name})
+ case "classify":
+ fs := flags("node classify")
+ id, typeID := fs.String("id", "", "node ID"), fs.String("type", "", "type ID")
+ tags := optionalStringFlag(fs, "tags")
+ clearType := fs.Bool("clear-type", false, "remove type")
+ if err := fs.Parse(args[1:]); err != nil || *id == "" {
+ return nil, fmt.Errorf("usage: box-manifest node classify --id ID [--type ID|--clear-type] [--tags ID,ID]")
+ }
+ currentRaw, err := a.client.Do(ctx, http.MethodGet, "/api/nodes/"+url.PathEscape(*id), nil)
+ if err != nil {
+ return nil, err
+ }
+ var current node
+ if err := json.Unmarshal(currentRaw, ¤t); err != nil {
+ return nil, err
+ }
+ body := map[string]any{"typeId": current.TypeID, "tagIds": current.TagIDs}
+ if tags.set {
+ body["tagIds"] = splitList(tags.value)
+ }
+ if *clearType {
+ body["typeId"] = nil
+ } else if *typeID != "" {
+ body["typeId"] = *typeID
+ }
+ return a.client.Do(ctx, http.MethodPut, "/api/nodes/"+url.PathEscape(*id)+"/classification", body)
+ case "bulk-create", "bulk-move", "bulk-delete", "bulk-classify":
+ return a.nodeBulk(ctx, args)
+ }
+ return nil, fmt.Errorf("unknown node command %q", args[0])
+}
+
+func (a *app) nodeBulk(ctx context.Context, args []string) (json.RawMessage, error) {
+ fs := flags("node " + args[0])
+ ids := fs.String("ids", "", "comma-separated node IDs")
+ parent := fs.String("parent", "", "parent ID")
+ names := fs.String("names", "", "comma-separated names")
+ description := fs.String("description", "", "description")
+ children := fs.String("children", "WITH_SUBTREE", "child handling")
+ typeID := fs.String("type", "", "type ID")
+ setType := fs.Bool("set-type", false, "replace type")
+ addTags := fs.String("add-tags", "", "tag IDs")
+ removeTags := fs.String("remove-tags", "", "tag IDs")
+ if err := fs.Parse(args[1:]); err != nil {
+ return nil, err
+ }
+ switch args[0] {
+ case "bulk-create":
+ if *parent == "" || *names == "" {
+ return nil, fmt.Errorf("usage: box-manifest node bulk-create --parent ID --names NAME,NAME [--description TEXT]")
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk", map[string]any{"parentId": *parent, "names": splitList(*names), "description": nullable(*description)})
+ case "bulk-move":
+ if *ids == "" || *parent == "" {
+ return nil, fmt.Errorf("usage: box-manifest node bulk-move --ids ID,ID --parent ID [--children MODE]")
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/move", map[string]any{"nodeIds": splitList(*ids), "targetParentId": *parent, "childHandling": strings.ToUpper(*children)})
+ case "bulk-delete":
+ if *ids == "" {
+ return nil, fmt.Errorf("usage: box-manifest node bulk-delete --ids ID,ID")
+ }
+ values := splitList(*ids)
+ _, err := a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/delete", map[string]any{"nodeIds": values})
+ if err != nil {
+ return nil, err
+ }
+ return marshal(map[string]any{"deleted": values})
+ case "bulk-classify":
+ if *ids == "" {
+ return nil, fmt.Errorf("usage: box-manifest node bulk-classify --ids ID,ID [--set-type --type ID] [--add-tags ID,ID] [--remove-tags ID,ID]")
+ }
+ body := map[string]any{"nodeIds": splitList(*ids), "setType": *setType, "addTagIds": splitList(*addTags), "removeTagIds": splitList(*removeTags)}
+ if *setType {
+ body["typeId"] = nullable(*typeID)
+ }
+ _, err := a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/classification", body)
+ if err != nil {
+ return nil, err
+ }
+ return marshal(map[string]any{"updated": splitList(*ids)})
+ }
+ panic("unreachable")
+}
+
+func (a *app) metadata(ctx context.Context, resource string, args []string) (json.RawMessage, error) {
+ if len(args) == 0 {
+ return nil, fmt.Errorf("command required")
+ }
+ path := "/api/" + resource
+ if args[0] == "list" {
+ return a.client.Do(ctx, http.MethodGet, path, nil)
+ }
+ fs := flags(resource + " " + args[0])
+ id := fs.String("id", "", "ID")
+ name := fs.String("name", "", "name")
+ description := fs.String("description", "", "description")
+ icon := fs.String("icon", "", "icon key")
+ color := fs.String("color", "", "color")
+ if err := fs.Parse(args[1:]); err != nil {
+ return nil, err
+ }
+ switch args[0] {
+ case "create":
+ if *name == "" {
+ return nil, fmt.Errorf("--name is required")
+ }
+ return a.client.Do(ctx, http.MethodPost, path, metadataBody(resource, *name, *description, *icon, *color))
+ case "update":
+ if *id == "" || *name == "" {
+ return nil, fmt.Errorf("--id and --name are required")
+ }
+ return a.client.Do(ctx, http.MethodPut, path+"/"+url.PathEscape(*id), metadataBody(resource, *name, *description, *icon, *color))
+ case "delete":
+ if *id == "" {
+ return nil, fmt.Errorf("--id is required")
+ }
+ _, err := a.client.Do(ctx, http.MethodDelete, path+"/"+url.PathEscape(*id), nil)
+ if err != nil {
+ return nil, err
+ }
+ return marshal(map[string]any{"deleted": []string{*id}})
+ }
+ return nil, fmt.Errorf("unknown command %q", args[0])
+}
+
+func (a *app) importTree(ctx context.Context, args []string) (json.RawMessage, error) {
+ if len(args) == 0 {
+ return nil, fmt.Errorf("import command required")
+ }
+ fs := flags("import " + args[0])
+ file := fs.String("file", "-", "manifest file or - for stdin")
+ parent := fs.String("parent", "", "destination parent ID")
+ plan := fs.String("plan", "", "preview plan ID")
+ if err := fs.Parse(args[1:]); err != nil {
+ return nil, err
+ }
+ if args[0] == "commit" {
+ if *plan == "" {
+ return nil, fmt.Errorf("usage: box-manifest import commit --plan ID")
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/imports/tree/commit", map[string]any{"planId": *plan})
+ }
+ if args[0] != "preview" || *parent == "" {
+ return nil, fmt.Errorf("usage: box-manifest import preview --parent ID [--file FILE]")
+ }
+ data, err := readFile(*file)
+ if err != nil {
+ return nil, err
+ }
+ var manifest any
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return nil, fmt.Errorf("invalid manifest JSON: %w", err)
+ }
+ return a.client.Do(ctx, http.MethodPost, "/api/imports/tree/preview", map[string]any{"parentId": *parent, "manifest": manifest})
+}
+
+func (a *app) rawAPI(ctx context.Context, args []string) (json.RawMessage, error) {
+ if len(args) < 2 {
+ return nil, fmt.Errorf("usage: box-manifest api METHOD PATH [--data JSON|@FILE]")
+ }
+ fs := flags("api")
+ data := fs.String("data", "", "JSON or @FILE")
+ if err := fs.Parse(args[2:]); err != nil {
+ return nil, err
+ }
+ var body any
+ if *data != "" {
+ content := []byte(*data)
+ if strings.HasPrefix(*data, "@") {
+ var err error
+ content, err = os.ReadFile(strings.TrimPrefix(*data, "@"))
+ if err != nil {
+ return nil, err
+ }
+ }
+ if err := json.Unmarshal(content, &body); err != nil {
+ return nil, fmt.Errorf("invalid request JSON: %w", err)
+ }
+ }
+ return a.client.Do(ctx, strings.ToUpper(args[0]), args[1], body)
+}
+
+type node struct {
+ ID, LookupCode, Name, Description string
+ ParentID *string `json:"parentId"`
+ Quantity *int64 `json:"quantity"`
+ TypeID *string `json:"typeId"`
+ TagIDs []string `json:"tagIds"`
+ Children []node `json:"children"`
+}
+type searchResult struct {
+ ID string `json:"id"`
+ LookupCode string `json:"lookupCode"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Quantity *int64 `json:"quantity,omitempty"`
+ Path []string `json:"path"`
+}
+
+func walk(n node, parents []string, visit func(node, []string)) {
+ visit(n, parents)
+ next := append(append([]string{}, parents...), n.Name)
+ for _, child := range n.Children {
+ walk(child, next, visit)
+ }
+}
+func marshal(value any) (json.RawMessage, error) { data, err := json.Marshal(value); return data, err }
+func nullable(value string) any {
+ if value == "" {
+ return nil
+ }
+ return value
+}
+func splitList(value string) []string {
+ if strings.TrimSpace(value) == "" {
+ return []string{}
+ }
+ parts := strings.Split(value, ",")
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if value := strings.TrimSpace(part); value != "" {
+ result = append(result, value)
+ }
+ }
+ return result
+}
+func metadataBody(resource, name, description, icon, color string) map[string]any {
+ body := map[string]any{"name": name, "color": nullable(color)}
+ if resource == "node-types" {
+ body["description"] = nullable(description)
+ body["iconKey"] = nullable(icon)
+ }
+ return body
+}
+func flags(name string) *flag.FlagSet {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ return fs
+}
+func envOr(name, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(name)); value != "" {
+ return value
+ }
+ return fallback
+}
+func readFile(path string) ([]byte, error) {
+ if path == "-" {
+ return io.ReadAll(os.Stdin)
+ }
+ return os.ReadFile(path)
+}
+
+type optionalString struct {
+ value string
+ set bool
+}
+
+func (o *optionalString) String() string { return o.value }
+func (o *optionalString) Set(value string) error { o.value, o.set = value, true; return nil }
+func optionalStringFlag(fs *flag.FlagSet, name string) *optionalString {
+ value := &optionalString{}
+ fs.Var(value, name, name)
+ return value
+}
+
+type optionalInt64 struct {
+ value int64
+ set bool
+}
+
+func (o *optionalInt64) String() string { return strconv.FormatInt(o.value, 10) }
+func (o *optionalInt64) Set(value string) error {
+ parsed, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || parsed < 0 {
+ return fmt.Errorf("must be a non-negative integer")
+ }
+ o.value, o.set = parsed, true
+ return nil
+}
+func optionalInt64Flag(fs *flag.FlagSet, name string) *optionalInt64 {
+ value := &optionalInt64{}
+ fs.Var(value, name, name)
+ return value
+}
+
+func (a *app) exportTree(ctx context.Context, args []string) (json.RawMessage, error) {
+ fs := flags("export")
+ output := fs.String("output", "", "write JSON to a file")
+ if err := fs.Parse(args); err != nil {
+ return nil, err
+ }
+ values := make([]json.RawMessage, 3)
+ paths := []string{"/api/tree", "/api/node-types", "/api/tags"}
+ for index := range paths {
+ raw, err := a.client.Do(ctx, http.MethodGet, paths[index], nil)
+ if err != nil {
+ return nil, err
+ }
+ values[index] = raw
+ }
+ var root node
+ var types, tags []map[string]any
+ if err := json.Unmarshal(values[0], &root); err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal(values[1], &types)
+ _ = json.Unmarshal(values[2], &tags)
+ typeNames, tagNames := namesByID(types), namesByID(tags)
+ usedTypes, usedTags := map[string]bool{}, map[string]bool{}
+ var encode func(node) map[string]any
+ encode = func(n node) map[string]any {
+ result := map[string]any{"name": n.Name, "lookupCode": n.LookupCode}
+ if n.Description != "" {
+ result["description"] = n.Description
+ }
+ if n.Quantity != nil {
+ result["quantity"] = *n.Quantity
+ }
+ if n.TypeID != nil {
+ usedTypes[*n.TypeID] = true
+ if name := typeNames[*n.TypeID]; name != "" {
+ result["type"] = name
+ }
+ }
+ if len(n.TagIDs) > 0 {
+ names := []string{}
+ for _, id := range n.TagIDs {
+ usedTags[id] = true
+ if name := tagNames[id]; name != "" {
+ names = append(names, name)
+ }
+ }
+ result["tags"] = names
+ }
+ if len(n.Children) > 0 {
+ children := make([]map[string]any, len(n.Children))
+ for i := range n.Children {
+ children[i] = encode(n.Children[i])
+ }
+ result["children"] = children
+ }
+ return result
+ }
+ nodes := make([]map[string]any, len(root.Children))
+ for i := range root.Children {
+ nodes[i] = encode(root.Children[i])
+ }
+ payload := map[string]any{"format": "box-manifest-portable", "exportedAt": time.Now().UTC().Format(time.RFC3339), "note": "Internal UUIDs are intentionally omitted. Lookup codes are included as human-readable references and may change when re-imported.", "types": filterMetadata(types, usedTypes, "description", "iconKey", "color"), "tags": filterMetadata(tags, usedTags, "color"), "nodes": nodes}
+ raw, err := marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ if *output != "" {
+ var pretty bytes.Buffer
+ if err := json.Indent(&pretty, raw, "", " "); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(*output, append(pretty.Bytes(), '\n'), 0o644); err != nil {
+ return nil, err
+ }
+ return marshal(map[string]any{"output": *output})
+ }
+ return raw, nil
+}
+
+func namesByID(values []map[string]any) map[string]string {
+ result := map[string]string{}
+ for _, value := range values {
+ id, _ := value["id"].(string)
+ name, _ := value["name"].(string)
+ result[id] = name
+ }
+ return result
+}
+func filterMetadata(values []map[string]any, used map[string]bool, optional ...string) []map[string]any {
+ result := []map[string]any{}
+ for _, value := range values {
+ id, _ := value["id"].(string)
+ if !used[id] {
+ continue
+ }
+ item := map[string]any{"name": value["name"]}
+ for _, key := range optional {
+ if value[key] != nil && value[key] != "" {
+ item[key] = value[key]
+ }
+ }
+ result = append(result, item)
+ }
+ sort.Slice(result, func(i, j int) bool { return fmt.Sprint(result[i]["name"]) < fmt.Sprint(result[j]["name"]) })
+ return result
+}
+
+const usage = `box-manifest — JSON-first Box Manifest CLI
+
+Global flags (before the command):
+ --server URL API server (or BOX_MANIFEST_SERVER_URL)
+ --compact emit one-line JSON
+
+Commands:
+ server show
+ tree
+ search QUERY
+ node get|create|update|move|delete|individualize|combine|classify
+ node bulk-create|bulk-move|bulk-delete|bulk-classify
+ type list|create|update|delete
+ tag list|create|update|delete
+ import preview|commit
+ export [--output FILE]
+ api METHOD PATH [--data JSON|@FILE]
+
+Run a subcommand with the required flags shown by an error, or use the raw api
+command for direct access to any API endpoint.
+`
diff --git a/server/cmd/box-manifest/main_test.go b/server/cmd/box-manifest/main_test.go
new file mode 100644
index 0000000..fe95614
--- /dev/null
+++ b/server/cmd/box-manifest/main_test.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSearchProducesStructuredPaths(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/tree" {
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"root","lookupCode":"ROOT00","name":"Root","description":null,"quantity":null,"tagIds":[],"children":[{"id":"a","lookupCode":"ABC234","name":"Blue bin","description":"Fasteners","quantity":null,"tagIds":[],"children":[]}]}`))
+ }))
+ defer server.Close()
+ var output bytes.Buffer
+ if err := run(context.Background(), []string{"--server", server.URL, "search", "fast"}, &output); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(output.String(), `"path": [`) || !strings.Contains(output.String(), `"Blue bin"`) {
+ t.Fatalf("unexpected output: %s", output.String())
+ }
+}
+
+func TestHelpDoesNotRequireServer(t *testing.T) {
+ var output bytes.Buffer
+ if err := run(context.Background(), []string{"help"}, &output); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(output.String(), "JSON-first") {
+ t.Fatalf("unexpected help: %s", output.String())
+ }
+}
diff --git a/server/internal/apiclient/client.go b/server/internal/apiclient/client.go
new file mode 100644
index 0000000..84773a1
--- /dev/null
+++ b/server/internal/apiclient/client.go
@@ -0,0 +1,79 @@
+package apiclient
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+type Client struct {
+ BaseURL string
+ HTTP *http.Client
+}
+
+type Error struct {
+ Status int
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func (e *Error) Error() string {
+ if e.Code == "" {
+ return fmt.Sprintf("server returned HTTP %d: %s", e.Status, e.Message)
+ }
+ return fmt.Sprintf("%s: %s", e.Code, e.Message)
+}
+
+func New(baseURL string) (*Client, error) {
+ baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ parsed, err := url.Parse(baseURL)
+ if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
+ return nil, fmt.Errorf("invalid server URL %q", baseURL)
+ }
+ return &Client{BaseURL: baseURL, HTTP: http.DefaultClient}, nil
+}
+
+func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
+ var input io.Reader
+ if body != nil {
+ encoded, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ input = bytes.NewReader(encoded)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, input)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ response, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+ data, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, err
+ }
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ apiError := &Error{Status: response.StatusCode, Message: strings.TrimSpace(string(data))}
+ _ = json.Unmarshal(data, apiError)
+ return nil, apiError
+ }
+ if len(bytes.TrimSpace(data)) == 0 {
+ return json.RawMessage(`{}`), nil
+ }
+ if !json.Valid(data) {
+ return nil, fmt.Errorf("server returned invalid JSON")
+ }
+ return data, nil
+}
diff --git a/server/internal/apiclient/client_test.go b/server/internal/apiclient/client_test.go
new file mode 100644
index 0000000..e2a1d30
--- /dev/null
+++ b/server/internal/apiclient/client_test.go
@@ -0,0 +1,28 @@
+package apiclient
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestDoReturnsStructuredAPIError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ _, _ = w.Write([]byte(`{"code":"not_empty","message":"node is not empty"}`))
+ }))
+ defer server.Close()
+ client, _ := New(server.URL)
+ _, err := client.Do(context.Background(), http.MethodDelete, "/api/nodes/a", nil)
+ apiError, ok := err.(*Error)
+ if !ok || apiError.Status != http.StatusConflict || apiError.Code != "not_empty" {
+ t.Fatalf("unexpected error: %#v", err)
+ }
+}
+
+func TestNewRejectsNonHTTPURL(t *testing.T) {
+ if _, err := New("file:///tmp/data"); err == nil {
+ t.Fatal("expected invalid URL error")
+ }
+}
diff --git a/server/internal/httpapi/handler.go b/server/internal/httpapi/handler.go
index 48f7de6..da1e446 100644
--- a/server/internal/httpapi/handler.go
+++ b/server/internal/httpapi/handler.go
@@ -19,6 +19,7 @@ type Handler struct {
func New(store *tree.Store) http.Handler {
h := &Handler{store: store, imports: importer.New(store)}
mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/system/info", h.getSystemInfo)
mux.HandleFunc("GET /api/tree", h.getTree)
mux.HandleFunc("GET /api/node-types", h.listNodeTypes)
mux.HandleFunc("POST /api/node-types", h.createNodeType)
@@ -30,6 +31,7 @@ func New(store *tree.Store) http.Handler {
mux.HandleFunc("DELETE /api/tags/{tagId}", h.deleteTag)
mux.HandleFunc("POST /api/nodes", h.createNode)
mux.HandleFunc("POST /api/nodes/bulk", h.createNodesBulk)
+ mux.HandleFunc("PATCH /api/nodes/bulk", h.updateNodesBulk)
mux.HandleFunc("POST /api/nodes/bulk/move", h.moveNodesBulk)
mux.HandleFunc("POST /api/nodes/bulk/delete", h.deleteNodesBulk)
mux.HandleFunc("POST /api/nodes/bulk/combine", h.combineNodes)
@@ -47,7 +49,23 @@ func New(store *tree.Store) http.Handler {
return mux
}
+type systemInfoResponse struct {
+ ServerID string `json:"serverId"`
+ Name string `json:"name"`
+ APIVersion int `json:"apiVersion"`
+}
+
+func (h *Handler) getSystemInfo(w http.ResponseWriter, r *http.Request) {
+ id, err := h.store.ServerID(r.Context())
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, systemInfoResponse{ServerID: id, Name: "Box Manifest", APIVersion: 1})
+}
+
type createNodeRequest struct {
+ ClientID *string `json:"clientId"`
ParentID *string `json:"parentId"`
Name string `json:"name"`
Description *string `json:"description"`
@@ -78,6 +96,11 @@ type deleteNodesBulkRequest struct {
NodeIDs []string `json:"nodeIds"`
}
+type updateNodesBulkRequest struct {
+ NodeIDs []string `json:"nodeIds"`
+ Description optionalString `json:"description"`
+}
+
type individualizeNodeRequest struct {
Names []string `json:"names"`
}
@@ -154,9 +177,10 @@ func (o *optionalInt64) UnmarshalJSON(data []byte) error {
}
type updateNodeRequest struct {
- Name *string `json:"name"`
- Description optionalString `json:"description"`
- Quantity optionalInt64 `json:"quantity"`
+ Name *string `json:"name"`
+ Description optionalString `json:"description"`
+ Quantity optionalInt64 `json:"quantity"`
+ BaseRevision *int64 `json:"baseRevision"`
}
type moveNodeRequest struct {
@@ -267,7 +291,7 @@ func (h *Handler) createNode(w http.ResponseWriter, r *http.Request) {
writeError(w, tree.ErrInvalid)
return
}
- node, err := h.store.Create(r.Context(), request.ParentID, request.Name, request.Description, request.Quantity)
+ node, err := h.store.CreateWithID(r.Context(), request.ClientID, request.ParentID, request.Name, request.Description, request.Quantity)
if err != nil {
writeError(w, err)
return
@@ -318,6 +342,19 @@ func (h *Handler) deleteNodesBulk(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
+func (h *Handler) updateNodesBulk(w http.ResponseWriter, r *http.Request) {
+ var request updateNodesBulkRequest
+ if err := decodeJSON(r, &request); err != nil || !request.Description.Set {
+ writeError(w, tree.ErrInvalid)
+ return
+ }
+ if err := h.store.UpdateDescriptions(r.Context(), request.NodeIDs, request.Description.Value); err != nil {
+ writeError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
func (h *Handler) individualizeNode(w http.ResponseWriter, r *http.Request) {
var request individualizeNodeRequest
if err := decodeJSON(r, &request); err != nil {
@@ -437,7 +474,7 @@ func (h *Handler) updateNode(w http.ResponseWriter, r *http.Request) {
node, err := h.store.Update(r.Context(), r.PathValue("nodeId"), request.Name, tree.StringUpdate{Set: request.Description.Set, Value: request.Description.Value}, tree.QuantityUpdate{
Set: request.Quantity.Set,
Value: request.Quantity.Value,
- })
+ }, request.BaseRevision)
if err != nil {
writeError(w, err)
return
@@ -500,6 +537,9 @@ func writeError(w http.ResponseWriter, err error) {
case errors.Is(err, tree.ErrConflict):
status = http.StatusConflict
response = errorResponse{Code: "TREE_CONFLICT", Message: "operation conflicts with tree rules"}
+ case errors.Is(err, tree.ErrRevisionConflict):
+ status = http.StatusConflict
+ response = errorResponse{Code: "REVISION_CONFLICT", Message: "node changed since it was read"}
case errors.Is(err, tree.ErrInvalid):
status = http.StatusBadRequest
response = errorResponse{Code: "INVALID_REQUEST", Message: "invalid request"}
diff --git a/server/internal/httpapi/handler_test.go b/server/internal/httpapi/handler_test.go
index 7a16883..caf5fa2 100644
--- a/server/internal/httpapi/handler_test.go
+++ b/server/internal/httpapi/handler_test.go
@@ -5,6 +5,7 @@ import (
"context"
"database/sql"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -13,6 +14,25 @@ import (
_ "modernc.org/sqlite"
)
+func TestSystemInfoIsStable(t *testing.T) {
+ handler := newTestHandler(t)
+ first := request(t, handler, http.MethodGet, "/api/system/info", "")
+ second := request(t, handler, http.MethodGet, "/api/system/info", "")
+ if first.Code != http.StatusOK || second.Code != http.StatusOK {
+ t.Fatalf("system info: got %d and %d", first.Code, second.Code)
+ }
+ var firstInfo, secondInfo systemInfoResponse
+ if err := json.Unmarshal(first.Body.Bytes(), &firstInfo); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(second.Body.Bytes(), &secondInfo); err != nil {
+ t.Fatal(err)
+ }
+ if firstInfo.ServerID == "" || firstInfo.ServerID != secondInfo.ServerID || firstInfo.APIVersion != 1 {
+ t.Fatalf("unexpected system info: %#v, %#v", firstInfo, secondInfo)
+ }
+}
+
func TestNodeLifecycleThroughHTTP(t *testing.T) {
handler := newTestHandler(t)
@@ -61,6 +81,40 @@ func TestNodeLifecycleThroughHTTP(t *testing.T) {
}
}
+func TestUpdateRevisionConflictThroughHTTP(t *testing.T) {
+ handler := newTestHandler(t)
+ node := createNodeThroughHTTP(t, handler, tree.UnsortedID, "Box")
+ first := request(t, handler, http.MethodPatch, "/api/nodes/"+node.ID, fmt.Sprintf(`{"name":"New","baseRevision":%d}`, node.Revision))
+ if first.Code != http.StatusOK {
+ t.Fatalf("first update: %d %s", first.Code, first.Body.String())
+ }
+ stale := request(t, handler, http.MethodPatch, "/api/nodes/"+node.ID, fmt.Sprintf(`{"name":"Stale","baseRevision":%d}`, node.Revision))
+ if stale.Code != http.StatusConflict || !bytes.Contains(stale.Body.Bytes(), []byte("REVISION_CONFLICT")) {
+ t.Fatalf("stale update: %d %s", stale.Code, stale.Body.String())
+ }
+}
+
+func TestClientGeneratedNodeIDMakesCreateRetryIdempotent(t *testing.T) {
+ handler := newTestHandler(t)
+ id := "0123456789abcdef0123456789abcdef"
+ body := `{"clientId":"` + id + `","parentId":"unsorted","name":"Offline box"}`
+ first := request(t, handler, http.MethodPost, "/api/nodes", body)
+ second := request(t, handler, http.MethodPost, "/api/nodes", body)
+ if first.Code != http.StatusCreated || second.Code != http.StatusCreated {
+ t.Fatalf("create retries: %d %s; %d %s", first.Code, first.Body.String(), second.Code, second.Body.String())
+ }
+ var firstNode, secondNode tree.Node
+ if err := json.Unmarshal(first.Body.Bytes(), &firstNode); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(second.Body.Bytes(), &secondNode); err != nil {
+ t.Fatal(err)
+ }
+ if firstNode.ID != id || secondNode.ID != id || firstNode.LookupCode != secondNode.LookupCode {
+ t.Fatalf("retry changed identity: %#v %#v", firstNode, secondNode)
+ }
+}
+
func TestBulkCreateThroughHTTP(t *testing.T) {
handler := newTestHandler(t)
response := request(
@@ -103,6 +157,26 @@ func TestBulkMoveAndDeleteThroughHTTP(t *testing.T) {
}
}
+func TestBulkDescriptionUpdateThroughHTTP(t *testing.T) {
+ handler := newTestHandler(t)
+ first := createNodeThroughHTTP(t, handler, tree.UnsortedID, "first")
+ second := createNodeThroughHTTP(t, handler, tree.UnsortedID, "second")
+ body, _ := json.Marshal(map[string]any{
+ "nodeIds": []string{first.ID, second.ID},
+ "description": "Shared description",
+ })
+ response := request(t, handler, http.MethodPatch, "/api/nodes/bulk", string(body))
+ if response.Code != http.StatusNoContent {
+ t.Fatalf("bulk description: got %d: %s", response.Code, response.Body.String())
+ }
+ for _, id := range []string{first.ID, second.ID} {
+ updated := request(t, handler, http.MethodGet, "/api/nodes/"+id, "")
+ if updated.Code != http.StatusOK || !bytes.Contains(updated.Body.Bytes(), []byte(`"description":"Shared description"`)) {
+ t.Fatalf("description missing: %d %s", updated.Code, updated.Body.String())
+ }
+ }
+}
+
func TestIndividualizeAndCombineThroughHTTP(t *testing.T) {
handler := newTestHandler(t)
create := request(t, handler, http.MethodPost, "/api/nodes", `{"parentId":"unsorted","name":"Bin","quantity":2}`)
diff --git a/server/internal/tree/store.go b/server/internal/tree/store.go
index a186fdd..ac02da8 100644
--- a/server/internal/tree/store.go
+++ b/server/internal/tree/store.go
@@ -17,9 +17,10 @@ const (
)
var (
- ErrNotFound = errors.New("node not found")
- ErrConflict = errors.New("operation conflicts with tree rules")
- ErrInvalid = errors.New("invalid node data")
+ ErrNotFound = errors.New("node not found")
+ ErrConflict = errors.New("operation conflicts with tree rules")
+ ErrRevisionConflict = errors.New("node changed since it was read")
+ ErrInvalid = errors.New("invalid node data")
)
type Node struct {
@@ -31,6 +32,7 @@ type Node struct {
Quantity *int64 `json:"quantity"`
TypeID *string `json:"typeId"`
TagIDs []string `json:"tagIds"`
+ Revision int64 `json:"revision"`
}
type TreeNode struct {
@@ -81,6 +83,19 @@ func (s *Store) Initialize(ctx context.Context) error {
return err
}
defer tx.Rollback()
+ if _, err = tx.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS system_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ )`); err != nil {
+ return fmt.Errorf("create system settings table: %w", err)
+ }
+ if _, err = tx.ExecContext(ctx, `
+ INSERT INTO system_settings (key, value)
+ VALUES ('server_id', lower(hex(randomblob(16))))
+ ON CONFLICT(key) DO NOTHING`); err != nil {
+ return fmt.Errorf("create server identity: %w", err)
+ }
if _, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS node_types (
id TEXT PRIMARY KEY,
@@ -117,7 +132,7 @@ func (s *Store) Initialize(ctx context.Context) error {
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO nodes (id, parent_id, name, system)
- VALUES (?, NULL, 'Root', 1)
+ VALUES (?, NULL, 'Inventory', 1)
ON CONFLICT(id) DO NOTHING`, RootID); err != nil {
return fmt.Errorf("create root: %w", err)
}
@@ -146,6 +161,11 @@ func (s *Store) Initialize(ctx context.Context) error {
return fmt.Errorf("add node type: %w", err)
}
}
+ if !columns["revision"] {
+ if _, err = tx.ExecContext(ctx, `ALTER TABLE nodes ADD COLUMN revision INTEGER NOT NULL DEFAULT 1`); err != nil {
+ return fmt.Errorf("add node revision: %w", err)
+ }
+ }
if _, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS node_tags (
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
@@ -185,7 +205,19 @@ func (s *Store) Initialize(ctx context.Context) error {
return tx.Commit()
}
+func (s *Store) ServerID(ctx context.Context) (string, error) {
+ var id string
+ if err := s.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key = 'server_id'`).Scan(&id); err != nil {
+ return "", fmt.Errorf("read server identity: %w", err)
+ }
+ return id, nil
+}
+
func (s *Store) Create(ctx context.Context, parentID *string, name string, description *string, quantity *int64) (Node, error) {
+ return s.CreateWithID(ctx, nil, parentID, name, description, quantity)
+}
+
+func (s *Store) CreateWithID(ctx context.Context, requestedID, parentID *string, name string, description *string, quantity *int64) (Node, error) {
name = strings.TrimSpace(name)
description = cleanOptionalString(description)
if name == "" || quantity != nil && *quantity < 0 {
@@ -199,9 +231,23 @@ func (s *Store) Create(ctx context.Context, parentID *string, name string, descr
return Node{}, err
}
- id, err := newID()
- if err != nil {
- return Node{}, fmt.Errorf("generate node id: %w", err)
+ var id string
+ if requestedID == nil {
+ generated, err := newID()
+ if err != nil {
+ return Node{}, fmt.Errorf("generate node id: %w", err)
+ }
+ id = generated
+ } else {
+ id = strings.TrimSpace(*requestedID)
+ if len(id) != 32 {
+ return Node{}, ErrInvalid
+ }
+ if existing, err := s.Get(ctx, id); err == nil {
+ return existing, nil
+ } else if !errors.Is(err, ErrNotFound) {
+ return Node{}, err
+ }
}
code, err := newLookupCode(ctx, s.db)
if err != nil {
@@ -322,7 +368,7 @@ func (s *Store) CreateTree(ctx context.Context, parentID string, nodes []ImportN
func (s *Store) Get(ctx context.Context, id string) (Node, error) {
node, err := scanNode(s.db.QueryRowContext(ctx,
- `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE id = ?`, id,
+ `SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes WHERE id = ?`, id,
))
if err != nil {
return Node{}, err
@@ -335,7 +381,7 @@ func (s *Store) Get(ctx context.Context, id string) (Node, error) {
func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error) {
node, err := scanNode(s.db.QueryRowContext(ctx,
- `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE lookup_code = ?`,
+ `SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes WHERE lookup_code = ?`,
strings.ToUpper(strings.TrimSpace(code)),
))
if err != nil {
@@ -349,7 +395,7 @@ func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error)
func (s *Store) Tree(ctx context.Context) (*TreeNode, error) {
rows, err := s.db.QueryContext(ctx,
- `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes ORDER BY rowid`)
+ `SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes ORDER BY rowid`)
if err != nil {
return nil, err
}
@@ -392,7 +438,7 @@ func (s *Store) Tree(ctx context.Context) (*TreeNode, error) {
return root, nil
}
-func (s *Store) Update(ctx context.Context, id string, name *string, description StringUpdate, quantity QuantityUpdate) (Node, error) {
+func (s *Store) Update(ctx context.Context, id string, name *string, description StringUpdate, quantity QuantityUpdate, baseRevision *int64) (Node, error) {
if name == nil && !description.Set && !quantity.Set {
return Node{}, ErrInvalid
}
@@ -402,7 +448,7 @@ func (s *Store) Update(ctx context.Context, id string, name *string, description
return Node{}, ErrInvalid
}
name = &trimmed
- if id == RootID || id == UnsortedID {
+ if id == UnsortedID {
return Node{}, ErrConflict
}
}
@@ -412,27 +458,70 @@ func (s *Store) Update(ctx context.Context, id string, name *string, description
if description.Set {
description.Value = cleanOptionalString(description.Value)
}
- if _, err := s.Get(ctx, id); err != nil {
+ current, err := s.Get(ctx, id)
+ if err != nil {
return Node{}, err
}
- if name != nil {
- if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET name = ? WHERE id = ?`, *name, id); err != nil {
- return Node{}, err
- }
+ if baseRevision != nil && current.Revision != *baseRevision {
+ return Node{}, ErrRevisionConflict
}
- if quantity.Set {
- if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET quantity = ? WHERE id = ?`, quantity.Value, id); err != nil {
- return Node{}, err
- }
+ newName := current.Name
+ newDescription := current.Description
+ newQuantity := current.Quantity
+ if name != nil {
+ newName = *name
}
if description.Set {
- if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET description = ? WHERE id = ?`, description.Value, id); err != nil {
- return Node{}, err
- }
+ newDescription = description.Value
+ }
+ if quantity.Set {
+ newQuantity = quantity.Value
+ }
+ query := `UPDATE nodes SET name = ?, description = ?, quantity = ?, revision = revision + 1 WHERE id = ?`
+ arguments := []any{newName, newDescription, newQuantity, id}
+ if baseRevision != nil {
+ query += ` AND revision = ?`
+ arguments = append(arguments, *baseRevision)
+ }
+ result, err := s.db.ExecContext(ctx, query, arguments...)
+ if err != nil {
+ return Node{}, err
+ }
+ changed, err := result.RowsAffected()
+ if err != nil {
+ return Node{}, err
+ }
+ if changed != 1 {
+ return Node{}, ErrRevisionConflict
}
return s.Get(ctx, id)
}
+// UpdateDescriptions replaces the description on every selected node in one
+// transaction. A nil or blank description clears the field.
+func (s *Store) UpdateDescriptions(ctx context.Context, ids []string, description *string) error {
+ if len(ids) == 0 || hasDuplicateIDs(ids) {
+ return ErrInvalid
+ }
+ description = cleanOptionalString(description)
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, id := range ids {
+ if _, err := getWith(ctx, tx, id); err != nil {
+ return err
+ }
+ }
+ for _, id := range ids {
+ if _, err := tx.ExecContext(ctx, `UPDATE nodes SET description = ?, revision = revision + 1 WHERE id = ?`, description, id); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
func (s *Store) Delete(ctx context.Context, id string) error {
if id == RootID || id == UnsortedID {
return ErrConflict
@@ -783,7 +872,7 @@ func scanNode(row scanner) (Node, error) {
var description sql.NullString
var quantity sql.NullInt64
var typeID sql.NullString
- if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &description, &quantity, &typeID); err != nil {
+ if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &description, &quantity, &typeID, &node.Revision); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Node{}, ErrNotFound
}
@@ -807,7 +896,7 @@ func scanNode(row scanner) (Node, error) {
func getWith(ctx context.Context, tx *sql.Tx, id string) (Node, error) {
return scanNode(tx.QueryRowContext(ctx,
- `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE id = ?`, id))
+ `SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes WHERE id = ?`, id))
}
func isDescendant(ctx context.Context, tx *sql.Tx, ancestorID, candidateID string) (bool, error) {
diff --git a/server/internal/tree/store_test.go b/server/internal/tree/store_test.go
index 72f76f9..0514312 100644
--- a/server/internal/tree/store_test.go
+++ b/server/internal/tree/store_test.go
@@ -13,11 +13,22 @@ import (
func TestInitializeCreatesProtectedTree(t *testing.T) {
store := newTestStore(t)
+ firstID, err := store.ServerID(context.Background())
+ if err != nil || firstID == "" {
+ t.Fatalf("expected server identity, got %q, %v", firstID, err)
+ }
+ if err := store.Initialize(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ secondID, err := store.ServerID(context.Background())
+ if err != nil || secondID != firstID {
+ t.Fatalf("server identity changed: %q -> %q, %v", firstID, secondID, err)
+ }
root, err := store.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
- if root.ID != RootID || root.ParentID != nil || root.Name != "Root" {
+ if root.ID != RootID || root.ParentID != nil || root.Name != "Inventory" {
t.Fatalf("unexpected root: %#v", root.Node)
}
if len(root.Children) != 1 || root.Children[0].ID != UnsortedID {
@@ -62,7 +73,7 @@ func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) {
node := createTestNode(t, store, RootID, "Box")
code := node.LookupCode
newName := "Renamed box"
- if _, err := store.Update(ctx, node.ID, &newName, StringUpdate{}, QuantityUpdate{}); err != nil {
+ if _, err := store.Update(ctx, node.ID, &newName, StringUpdate{}, QuantityUpdate{}, nil); err != nil {
t.Fatal(err)
}
if _, err := store.Move(ctx, node.ID, UnsortedID, WithSubtree); err != nil {
@@ -77,6 +88,24 @@ func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) {
}
}
+func TestUpdateRejectsStaleRevision(t *testing.T) {
+ ctx := context.Background()
+ store := newTestStore(t)
+ node := createTestNode(t, store, RootID, "Box")
+ name := "First edit"
+ updated, err := store.Update(ctx, node.ID, &name, StringUpdate{}, QuantityUpdate{}, &node.Revision)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.Revision != node.Revision+1 {
+ t.Fatalf("revision did not advance: before=%d after=%d", node.Revision, updated.Revision)
+ }
+ staleName := "Stale edit"
+ if _, err := store.Update(ctx, node.ID, &staleName, StringUpdate{}, QuantityUpdate{}, &node.Revision); !errors.Is(err, ErrRevisionConflict) {
+ t.Fatalf("expected revision conflict, got %v", err)
+ }
+}
+
func TestMoveWithSubtree(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
@@ -151,7 +180,7 @@ func TestDeleteOnlyAllowsEmptyNodes(t *testing.T) {
}
}
-func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) {
+func TestProtectedNodesCannotBeMovedOrDeletedAndUnsortedCannotBeRenamed(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
newName := "Elsewhere"
@@ -163,9 +192,13 @@ func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) {
if _, err := store.Move(ctx, id, RootID, WithSubtree); !errors.Is(err, ErrConflict) {
t.Errorf("move %s: expected conflict, got %v", id, err)
}
- if _, err := store.Update(ctx, id, &newName, StringUpdate{}, QuantityUpdate{}); !errors.Is(err, ErrConflict) {
- t.Errorf("rename %s: expected conflict, got %v", id, err)
- }
+ }
+ if _, err := store.Update(ctx, UnsortedID, &newName, StringUpdate{}, QuantityUpdate{}, nil); !errors.Is(err, ErrConflict) {
+ t.Errorf("rename unsorted: expected conflict, got %v", err)
+ }
+ updated, err := store.Update(ctx, RootID, &newName, StringUpdate{}, QuantityUpdate{}, nil)
+ if err != nil || updated.Name != newName {
+ t.Fatalf("rename root: got %#v, %v", updated, err)
}
}
@@ -344,6 +377,36 @@ func TestCombineRejectsDifferentParentsWithoutChanges(t *testing.T) {
}
}
+func TestUpdateDescriptionsIsAtomicAndCanClear(t *testing.T) {
+ ctx := context.Background()
+ store := newTestStore(t)
+ first := createTestNode(t, store, UnsortedID, "first")
+ second := createTestNode(t, store, UnsortedID, "second")
+ description := "Shared description"
+ if err := store.UpdateDescriptions(ctx, []string{first.ID, second.ID}, &description); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range []string{first.ID, second.ID} {
+ node := mustGet(t, store, id)
+ if node.Description == nil || *node.Description != description || node.Revision != 2 {
+ t.Fatalf("description not updated: %#v", node)
+ }
+ }
+ other := "should not be applied"
+ if err := store.UpdateDescriptions(ctx, []string{first.ID, "missing"}, &other); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("expected not found, got %v", err)
+ }
+ if node := mustGet(t, store, first.ID); node.Description == nil || *node.Description != description {
+ t.Fatalf("atomic update partially changed node: %#v", node)
+ }
+ if err := store.UpdateDescriptions(ctx, []string{first.ID, second.ID}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if mustGet(t, store, first.ID).Description != nil || mustGet(t, store, second.ID).Description != nil {
+ t.Fatal("descriptions were not cleared")
+ }
+}
+
func newTestStore(t *testing.T) *Store {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")