From 35ed58d6c4ccbabf243df6650a2e3f80df8b846e Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Wed, 2 Sep 2026 09:04:01 -0500 Subject: [PATCH] add automation CLI and sync API support --- .gitignore | 1 + api/openapi.yaml | 66 ++ docs/CLI.md | 119 ++++ server/cmd/box-manifest-server/main.go | 25 +- server/cmd/box-manifest-server/main_test.go | 33 + server/cmd/box-manifest/main.go | 636 ++++++++++++++++++++ server/cmd/box-manifest/main_test.go | 38 ++ server/internal/apiclient/client.go | 79 +++ server/internal/apiclient/client_test.go | 28 + server/internal/httpapi/handler.go | 50 +- server/internal/httpapi/handler_test.go | 74 +++ server/internal/tree/store.go | 141 ++++- server/internal/tree/store_test.go | 75 ++- 13 files changed, 1323 insertions(+), 42 deletions(-) create mode 100644 docs/CLI.md create mode 100644 server/cmd/box-manifest-server/main_test.go create mode 100644 server/cmd/box-manifest/main.go create mode 100644 server/cmd/box-manifest/main_test.go create mode 100644 server/internal/apiclient/client.go create mode 100644 server/internal/apiclient/client_test.go 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:")