add automation CLI and sync API support

This commit is contained in:
nicholas committed 2026-09-02 09:04:01 -05:00
1 parent da63ee8f7c
commit 35ed58d6c4
13 files changed
+1323 -42

No files matched your search

+1
View File
@@ -17,5 +17,6 @@
local.properties
/server/data/
/server/box-manifest-server
/server/box-manifest
/web/node_modules/
/web/dist/
+66
View File
@@ -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
+119
View File
@@ -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.
+20 -5
View File
@@ -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)
})
}
@@ -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("<main>app</main>"), 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(), "<main>app</main>") {
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())
}
}
+636
View File
@@ -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, &current); 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.
`
+38
View File
@@ -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())
}
}
+79
View File
@@ -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
}
+28
View File
@@ -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")
}
}
+45 -5
View File
@@ -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"}
+74
View File
@@ -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}`)
+115 -26
View File
@@ -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) {
+69 -6
View File
@@ -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:")