Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/internal/database/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,14 @@ func (db *Database) ListDeploymentsForFunction(fnID string, limit int) ([]*Deplo
return out, nil
}

// CountDeploymentsForFunction returns the total number of deployments for a
// function (across all pages) so list responses can carry a truncation signal.
func (db *Database) CountDeploymentsForFunction(fnID string) (int, error) {
var n int
err := db.read.QueryRow(`SELECT COUNT(*) FROM deployments WHERE function_id = ?`, fnID).Scan(&n)
return n, err
}

// AppendBuildLog writes one log line. Seq numbers are provided by the caller
// to guarantee monotonicity across concurrent writers for the same
// deployment (the build worker owns the deployment, so that's trivial).
Expand Down
103 changes: 103 additions & 0 deletions backend/internal/database/kv_atomic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package database

import (
"testing"
"time"
)

func kvTestFn(t *testing.T, db *Database, id string) {
t.Helper()
fn := &Function{
ID: id, Name: id, Runtime: "node", Entrypoint: "handler.js",
TimeoutMS: 30000, MemoryMB: 64, CPUs: 0.5,
EnvVars: map[string]string{}, NetworkMode: "none", Status: "active",
}
if err := db.InsertFunction(fn); err != nil {
t.Fatal(err)
}
}

// TestKVIncr covers the atomic counter the CLI `kv incr` and MCP kv_incr both
// drive: create-at-delta, accumulate (incl. negative), and the not-an-integer
// guard.
func TestKVIncr(t *testing.T) {
db := newTestDB(t)
kvTestFn(t, db, "fn_kvincr12345")

// Fresh key is created at the delta.
if v, err := db.KVIncr("fn_kvincr12345", "n", 1, 0); err != nil || v != 1 {
t.Fatalf("first incr: v=%d err=%v (want 1, nil)", v, err)
}
if v, err := db.KVIncr("fn_kvincr12345", "n", 5, 0); err != nil || v != 6 {
t.Fatalf("second incr: v=%d err=%v (want 6, nil)", v, err)
}
if v, err := db.KVIncr("fn_kvincr12345", "n", -2, 0); err != nil || v != 4 {
t.Fatalf("negative incr: v=%d err=%v (want 4, nil)", v, err)
}

// A non-integer value cannot be incremented.
if err := db.KVPut("fn_kvincr12345", "s", []byte(`"abc"`), 0); err != nil {
t.Fatal(err)
}
if _, err := db.KVIncr("fn_kvincr12345", "s", 1, 0); err == nil {
t.Error("expected incr on a non-integer value to error")
}
}

// TestKVCAS covers compare-and-swap: insert-if-absent (expected=nil), match,
// and the precondition-miss path that returns the current value (the lock /
// optimistic-concurrency primitive the CLI `kv cas` exposes).
func TestKVCAS(t *testing.T) {
db := newTestDB(t)
kvTestFn(t, db, "fn_kvcas123456")

// Insert-if-absent: expected=nil on a missing key swaps.
if ok, _, err := db.KVCAS("fn_kvcas123456", "lock", nil, []byte(`"held"`), 0); err != nil || !ok {
t.Fatalf("insert-if-absent: ok=%v err=%v (want true, nil)", ok, err)
}
// expected=nil on an existing key fails (already held), returns current.
ok, current, err := db.KVCAS("fn_kvcas123456", "lock", nil, []byte(`"other"`), 0)
if err != nil || ok {
t.Fatalf("insert-if-absent on existing: ok=%v err=%v (want false, nil)", ok, err)
}
if string(current) != `"held"` {
t.Errorf("expected current=%q, got %q", `"held"`, string(current))
}
// Matching expected swaps.
if ok, _, err := db.KVCAS("fn_kvcas123456", "lock", []byte(`"held"`), []byte(`"taken"`), 0); err != nil || !ok {
t.Fatalf("matching cas: ok=%v err=%v (want true, nil)", ok, err)
}
// Stale expected fails and returns the new current.
ok, current, err = db.KVCAS("fn_kvcas123456", "lock", []byte(`"held"`), []byte(`"z"`), 0)
if err != nil || ok {
t.Fatalf("stale cas: ok=%v err=%v (want false, nil)", ok, err)
}
if string(current) != `"taken"` {
t.Errorf("expected current=%q, got %q", `"taken"`, string(current))
}
}

// TestCountDeploymentsForFunction pins the COUNT(*) added so the deployments
// list response can carry a truncation signal.
func TestCountDeploymentsForFunction(t *testing.T) {
db := newTestDB(t)
kvTestFn(t, db, "fn_depcount123")

if n, err := db.CountDeploymentsForFunction("fn_depcount123"); err != nil || n != 0 {
t.Fatalf("empty count: n=%d err=%v (want 0, nil)", n, err)
}

now := time.Now().UTC()
for i, id := range []string{"dep_count00001", "dep_count00002", "dep_count00003"} {
d := &Deployment{
ID: id, FunctionID: "fn_depcount123", Version: int64(i + 1),
Status: "succeeded", Phase: "done", SubmittedAt: now.Add(time.Duration(i) * time.Second),
}
if err := db.InsertDeployment(d); err != nil {
t.Fatal(err)
}
}
if n, err := db.CountDeploymentsForFunction("fn_depcount123"); err != nil || n != 3 {
t.Fatalf("count: n=%d err=%v (want 3, nil)", n, err)
}
}
8 changes: 7 additions & 1 deletion backend/internal/server/handlers/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ func (h *DeploymentHandler) ListForFunction(w http.ResponseWriter, r *http.Reque
respond.Error(w, http.StatusInternalServerError, "INTERNAL", err.Error(), reqID)
return
}
respond.JSON(w, http.StatusOK, map[string]any{"deployments": list})
total, err := h.DB.CountDeploymentsForFunction(fnID)
if err != nil {
// Soft-fail on the count — the list itself succeeded, so report the
// page length rather than 500ing the whole response.
total = len(list)
}
respond.JSON(w, http.StatusOK, map[string]any{"deployments": list, "total": total})
}

// GetLogs — GET /api/v1/deployments/{id}/logs?from=<seq>
Expand Down
96 changes: 96 additions & 0 deletions backend/internal/server/handlers/kv_operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,99 @@ func (h *KVOperatorHandler) Delete(w http.ResponseWriter, r *http.Request) {
}
respond.JSON(w, http.StatusOK, map[string]string{"status": "deleted", "key": key})
}

// kvOperatorIncrRequest carries the delta and optional TTL refresh. Mirrors
// the internal kvIncrRequest (kv.go) — same shape, operator auth.
type kvOperatorIncrRequest struct {
Delta int64 `json:"delta"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
}

// Incr handles POST /api/v1/functions/{fn_id}/kv/{key}/incr. Atomically adds
// delta (default 1) to an integer value and returns the new value. The
// API-key-authed twin of the internal-token KVHandler.Incr.
func (h *KVOperatorHandler) Incr(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
fnID, ok := h.resolveFnID(r.PathValue("fn_id"))
if !ok {
respond.Error(w, http.StatusNotFound, "NOT_FOUND", "function not found", reqID)
return
}
key := r.PathValue("key")
if key == "" {
respond.Error(w, http.StatusBadRequest, "VALIDATION", "key is required", reqID)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<16))
if err != nil {
respond.Error(w, http.StatusBadRequest, "INVALID_BODY", "failed to read body", reqID)
return
}
req := kvOperatorIncrRequest{Delta: 1}
if len(body) > 0 {
if err := json.Unmarshal(body, &req); err != nil {
respond.Error(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", reqID)
return
}
}
next, err := h.DB.KVIncr(fnID, key, req.Delta, req.TTLSeconds)
if err != nil {
respond.Error(w, http.StatusConflict, "KV_INCR_FAILED", err.Error(), reqID)
return
}
respond.JSON(w, http.StatusOK, map[string]any{"value": next})
}

// kvOperatorCASRequest expresses "swap from Expected to New, only if Expected
// matches the current value". A null Expected means "the key must not currently
// exist" (insert-if-absent). Mirrors the internal kvCASRequest (kv.go).
type kvOperatorCASRequest struct {
Expected *json.RawMessage `json:"expected"`
New json.RawMessage `json:"new"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
}

// CAS handles POST /api/v1/functions/{fn_id}/kv/{key}/cas. A failed precondition
// is a successful HTTP 200 with {"ok": false, "current": <value>} so callers can
// retry; only a genuine store error is a 500.
func (h *KVOperatorHandler) CAS(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
fnID, ok := h.resolveFnID(r.PathValue("fn_id"))
if !ok {
respond.Error(w, http.StatusNotFound, "NOT_FOUND", "function not found", reqID)
return
}
key := r.PathValue("key")
if key == "" {
respond.Error(w, http.StatusBadRequest, "VALIDATION", "key is required", reqID)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, kvOperatorBodyCap))
if err != nil {
respond.Error(w, http.StatusBadRequest, "INVALID_BODY", "failed to read body", reqID)
return
}
var req kvOperatorCASRequest
if err := json.Unmarshal(body, &req); err != nil {
respond.Error(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", reqID)
return
}
if len(req.New) == 0 {
respond.Error(w, http.StatusBadRequest, "VALIDATION", "new value is required", reqID)
return
}
var expectedBytes []byte
if req.Expected != nil {
expectedBytes = []byte(*req.Expected)
}
swapped, current, err := h.DB.KVCAS(fnID, key, expectedBytes, []byte(req.New), req.TTLSeconds)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "INTERNAL", "kv cas failed: "+err.Error(), reqID)
return
}
resp := map[string]any{"ok": swapped}
if !swapped && current != nil {
resp["current"] = json.RawMessage(current)
}
respond.JSON(w, http.StatusOK, resp)
}
2 changes: 2 additions & 0 deletions backend/internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("GET /api/v1/functions/{fn_id}/kv/{key}", kvOperatorHandler.Get)
r.mux.HandleFunc("PUT /api/v1/functions/{fn_id}/kv/{key}", kvOperatorHandler.Put)
r.mux.HandleFunc("DELETE /api/v1/functions/{fn_id}/kv/{key}", kvOperatorHandler.Delete)
r.mux.HandleFunc("POST /api/v1/functions/{fn_id}/kv/{key}/incr", kvOperatorHandler.Incr)
r.mux.HandleFunc("POST /api/v1/functions/{fn_id}/kv/{key}/cas", kvOperatorHandler.CAS)

// Saved request fixtures (v0.4 B3) — Postman-style presets reused by
// the editor's Test pane and the test_function_with_fixture MCP tool.
Expand Down
8 changes: 7 additions & 1 deletion cli/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func TestCommandTree(t *testing.T) {
{"rollback"},
{"invoke"},
{"logs"},
{"kv"}, {"kv", "get"}, {"kv", "put"}, {"kv", "delete"}, {"kv", "list"},
{"kv"}, {"kv", "get"}, {"kv", "put"}, {"kv", "delete"}, {"kv", "list"}, {"kv", "incr"}, {"kv", "cas"},
{"cron"}, {"cron", "create"}, {"cron", "list"}, {"cron", "update"}, {"cron", "delete"},
{"jobs"}, {"jobs", "enqueue"}, {"jobs", "list"}, {"jobs", "get"}, {"jobs", "retry"}, {"jobs", "delete"},
{"secrets"}, {"secrets", "set"}, {"secrets", "list"}, {"secrets", "delete"},
Expand Down Expand Up @@ -80,6 +80,12 @@ func TestRequiredFlagsPresent(t *testing.T) {
{[]string{"activity"}, "follow"},
{[]string{"kv", "list"}, "prefix"},
{[]string{"kv", "list"}, "limit"},
{[]string{"kv", "incr"}, "by"},
{[]string{"kv", "cas"}, "expected"},
{[]string{"kv", "cas"}, "new"},
{[]string{"functions", "list"}, "limit"},
{[]string{"functions", "list"}, "offset"},
{[]string{"traces", "list"}, "before"},
{[]string{"jobs", "list"}, "status"},
{[]string{"jobs", "list"}, "fn"},
{[]string{"upgrade"}, "check"},
Expand Down
8 changes: 8 additions & 0 deletions cli/commands/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func runDeploymentsList(cmd *cobra.Command, args []string) error {
SubmittedAt time.Time `json:"submitted_at"`
DurationMS *int64 `json:"duration_ms"`
} `json:"deployments"`
Total int `json:"total"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return fmt.Errorf("decode response: %w", err)
Expand All @@ -153,6 +154,13 @@ func runDeploymentsList(cmd *cobra.Command, args []string) error {
)
}
t.flush()

shown := len(result.Deployments)
if shown < result.Total {
infof(cmd, "\nShowing %d of %d (raise --limit to see older deployments)", shown, result.Total)
} else {
infof(cmd, "\nShowing %d of %d", shown, result.Total)
}
return nil
}

Expand Down
39 changes: 35 additions & 4 deletions cli/commands/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"strconv"
"time"

"github.com/spf13/cobra"
Expand All @@ -17,10 +19,16 @@ var functionsCmd = &cobra.Command{

var functionsListCmd = &cobra.Command{
Use: "list",
Short: "List all functions",
Long: `List every function on the server with its runtime, status, and version.
Short: "List functions",
Long: `List functions on the server with their runtime, status, and version.

Results are paginated. The footer reports "showing N of TOTAL"; when more
exist than the current page, raise --limit or page with --offset so a script
or agent never silently misses functions.

orva functions list
orva functions list --limit 500
orva functions list --limit 50 --offset 50
orva functions list -o json | jq '.functions[].name'`,
Args: cobra.NoArgs,
RunE: runFunctionsList,
Expand Down Expand Up @@ -63,6 +71,9 @@ confirmation unless --yes is set.
}

func init() {
functionsListCmd.Flags().Int("limit", 100, "maximum number of functions to return")
functionsListCmd.Flags().Int("offset", 0, "number of functions to skip (pagination)")

functionsCreateCmd.Flags().String("name", "", "function name (required)")
functionsCreateCmd.Flags().String("runtime", "", "runtime: node or python (required)")
functionsCreateCmd.Flags().Int("memory-mb", 0, "memory limit in MB (0 = server default)")
Expand All @@ -83,7 +94,21 @@ func runFunctionsList(cmd *cobra.Command, args []string) error {
return err
}

resp, err := client.Get("/api/v1/functions")
limit, _ := cmd.Flags().GetInt("limit")
offset, _ := cmd.Flags().GetInt("offset")
q := url.Values{}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
if offset > 0 {
q.Set("offset", strconv.Itoa(offset))
}
path := "/api/v1/functions"
if encoded := q.Encode(); encoded != "" {
path += "?" + encoded
}

resp, err := client.Get(path)
if err != nil {
return err
}
Expand Down Expand Up @@ -120,7 +145,13 @@ func runFunctionsList(cmd *cobra.Command, args []string) error {
t.row(fn.ID, fn.Name, fn.Runtime, fn.Status, fn.Version, fn.CreatedAt.Format(time.DateTime))
}
t.flush()
infof(cmd, "\nTotal: %d", result.Total)

shown := len(result.Functions)
if offset+shown < result.Total {
infof(cmd, "\nShowing %d of %d (raise --limit or page with --offset %d)", shown, result.Total, offset+shown)
} else {
infof(cmd, "\nShowing %d of %d", shown, result.Total)
}
return nil
}

Expand Down
Loading
Loading