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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,8 @@ jobs:
: > test/e2e/CHECKLIST.md
ADMIN_KEY="$(cat "$RUNNER_TEMP/orva-data/.admin-key")"
python3 test/e2e/run.py --url http://127.0.0.1:8443 --api-key "$ADMIN_KEY"
ORVA_ENDPOINT=http://127.0.0.1:8443 ORVA_API_KEY="$ADMIN_KEY" \
bash test/sdk-test.sh

- name: Stop Orva
if: always()
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ docs-embed:
# Copy adapter sources + bundled SDK into backend/cmd/orva/adapters/ so
# //go:embed has them at build time. Keeps backend/runtimes/ as the
# source-of-truth directory (shared with Dockerfile COPY paths).
# Also copies the v0.2 orva SDK module (kv / invoke / jobs).
# Also copies the bundled Orva SDK module (kv / invoke / jobs / tracing).
adapters-embed:
@rm -rf backend/cmd/orva/adapters
@mkdir -p backend/cmd/orva/adapters/node backend/cmd/orva/adapters/python
@cp backend/runtimes/node/adapter.js backend/cmd/orva/adapters/node/adapter.js
@cp backend/runtimes/python/adapter.py backend/cmd/orva/adapters/python/adapter.py
@cp backend/runtimes/node/orva.js backend/cmd/orva/adapters/node/orva.js
@cp backend/runtimes/python/orva.py backend/cmd/orva/adapters/python/orva.py
@# v0.6 SDK: ship .d.ts + package.json so TS handlers get types;
@# Ship .d.ts + package.json so TS handlers get types;
@# py.typed marks the Python module as fully typed for static checkers.
@cp backend/runtimes/node/orva.d.ts backend/cmd/orva/adapters/node/orva.d.ts
@cp backend/runtimes/node/package.json backend/cmd/orva/adapters/node/package.json
Expand Down
2 changes: 1 addition & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Key files: `deploy.go`, `deployments.go`, `diff.go`, `rollback.go`, `functions.g

`CORS → BodySizeLimit → Auth → RequestID → Logger → Handler`

Auth middleware only runs on paths starting with `/api/`. Everything else (`/fn/`, `/metrics`, `/webhook/`, `/mcp`, custom routes) bypasses the API-key check entirely — per-function auth for invocations is enforced inside `InvokeHandler`. Internal SDK paths (`/api/v1/_kv/`, `/api/v1/_internal/`) use the per-process internal token instead of API keys.
Auth middleware only runs on paths starting with `/api/`. Everything else (`/fn/`, `/metrics`, `/webhook/`, `/mcp`, custom routes) bypasses the API-key check entirely — per-function auth for invocations is enforced inside `InvokeHandler`. Internal SDK paths (`/api/v1/_kv/`, `/api/v1/_internal/`) use process-signed, function-scoped credentials instead of API keys.

## Database

Expand Down
1 change: 1 addition & 0 deletions backend/cmd/orva/adapters/node/orva.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface KVPutEntry<T = unknown> {
}

export interface KVOptions {
/** Omit to preserve expiry, use 0 to clear it, or a positive value to set it. */
ttlSeconds?: number
}

Expand Down
62 changes: 40 additions & 22 deletions backend/cmd/orva/adapters/node/orva.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Orva Node.js SDK — kv, invoke, jobs, crons, trace, log, context.
//
// Routes through ORVA_API_BASE (loopback) using ORVA_INTERNAL_TOKEN that
// was injected at worker spawn. Both env vars must be present in
// Routes through ORVA_API_BASE using the function-scoped ORVA_INTERNAL_TOKEN
// injected at worker spawn. Both env vars must be present in
// production; absent in tests where the SDK throws OrvaUnavailableError
// (unless __test_mode__ has supplied an override implementation).
//
Expand All @@ -14,7 +14,7 @@
// SDK version baked at adapter-embed time. Bumped in lockstep with the
// server. The string is sent on every internal-token call so operators
// can see drift in deployment logs.
const SDK_VERSION = '0.6.0'
const SDK_VERSION = '0.7.0'

const COMMON_HEADERS = { 'Content-Type': 'application/json' }

Expand Down Expand Up @@ -73,8 +73,6 @@ function _traceHeaders() {
const exec = _execID()
if (trace) h['X-Orva-Trace-Id'] = trace
if (span) h['X-Orva-Span-Id'] = span
if (fn) h['X-Orva-Caller-Function'] = fn
if (fn) h['X-Orva-Function-Id'] = fn
if (exec) h['X-Orva-Execution-Id'] = exec
h['X-Orva-SDK-Version'] = SDK_VERSION
return h
Expand Down Expand Up @@ -146,13 +144,17 @@ const kv = {
return data.value != null ? data.value : defaultValue
},

/** Upsert a value. ttlSeconds=0 disables expiry. */
async put(key, value, { ttlSeconds = 0 } = {}) {
/** Upsert a value. Omit ttlSeconds to preserve expiry; 0 clears it. */
async put(key, value, options = {}) {
const fn = _fnID()
const requestBody = { value }
if (Object.hasOwn(options, 'ttlSeconds')) {
requestBody.ttl_seconds = Number(options.ttlSeconds)
}
const { status, body } = await _request(
'PUT',
`/api/v1/_kv/${fn}/${encodeURIComponent(key)}`,
{ body: { value, ttl_seconds: ttlSeconds | 0 } }
{ body: requestBody }
)
if (status >= 400) throw new OrvaError(`kv.put(${key}) failed: ${body}`, status)
},
Expand Down Expand Up @@ -198,6 +200,7 @@ const kv = {
})
if (status >= 400) throw new OrvaError(`kv.getMany failed: ${body}`, status)
const data = JSON.parse(body)
_assertAtomicBatch(data, 'kv.getMany')
const out = {}
for (const r of data.results || []) {
out[r.key] = r.found ? r.value : null
Expand All @@ -209,16 +212,16 @@ const kv = {
async putMany(entries) {
if (!entries || entries.length === 0) return
const fn = _fnID()
const ops = entries.map((e) => ({
op: 'put',
key: e.key,
value: e.value,
ttl_seconds: (e.ttlSeconds | 0) || 0,
}))
const ops = entries.map((e) => {
const op = { op: 'put', key: e.key, value: e.value }
if (Object.hasOwn(e, 'ttlSeconds')) op.ttl_seconds = Number(e.ttlSeconds)
return op
})
const { status, body } = await _request('POST', `/api/v1/_kv/${fn}/batch`, {
body: { ops },
})
if (status >= 400) throw new OrvaError(`kv.putMany failed: ${body}`, status)
_assertAtomicBatch(JSON.parse(body), 'kv.putMany')
},

/** Delete N keys in one transaction. Returns the number removed. */
Expand All @@ -231,16 +234,21 @@ const kv = {
})
if (status >= 400) throw new OrvaError(`kv.deleteMany failed: ${body}`, status)
const data = JSON.parse(body)
_assertAtomicBatch(data, 'kv.deleteMany')
return (data.results || []).filter((r) => r.found).length
},

/** Atomic increment. Missing keys are treated as 0. Returns new value. */
async incr(key, delta = 1, { ttlSeconds = 0 } = {}) {
async incr(key, delta = 1, options = {}) {
const fn = _fnID()
const requestBody = { delta }
if (Object.hasOwn(options, 'ttlSeconds')) {
requestBody.ttl_seconds = Number(options.ttlSeconds)
}
const { status, body } = await _request(
'POST',
`/api/v1/_kv/${fn}/${encodeURIComponent(key)}/incr`,
{ body: { delta, ttl_seconds: ttlSeconds | 0 } }
{ body: requestBody }
)
if (status >= 400) throw new OrvaError(`kv.incr(${key}) failed: ${body}`, status)
return JSON.parse(body).value
Expand All @@ -251,17 +259,20 @@ const kv = {
* Returns true on success; on mismatch, throws OrvaCASMismatch carrying
* the current value so callers can retry.
*/
async cas(key, expected, newValue, { ttlSeconds = 0 } = {}) {
async cas(key, expected, newValue, options = {}) {
const fn = _fnID()
const requestBody = {
expected: expected === null ? null : expected,
new: newValue,
}
if (Object.hasOwn(options, 'ttlSeconds')) {
requestBody.ttl_seconds = Number(options.ttlSeconds)
}
const { status, body } = await _request(
'POST',
`/api/v1/_kv/${fn}/${encodeURIComponent(key)}/cas`,
{
body: {
expected: expected === null ? null : expected,
new: newValue,
ttl_seconds: ttlSeconds | 0,
},
body: requestBody,
}
)
if (status >= 400) throw new OrvaError(`kv.cas(${key}) failed: ${body}`, status)
Expand All @@ -271,6 +282,13 @@ const kv = {
},
}

function _assertAtomicBatch(data, operation) {
const failed = (data.results || []).find((result) => result.error)
if (failed) {
throw new OrvaError(`${operation} failed: ${failed.error}`, 500)
}
}

// ── Function-to-function invoke ─────────────────────────────────────

async function invoke(functionName, payload = {}, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/orva/adapters/node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "orva",
"version": "0.6.0",
"version": "0.7.0",
"main": "./orva.js",
"types": "./orva.d.ts",
"private": true,
Expand Down
56 changes: 33 additions & 23 deletions backend/cmd/orva/adapters/python/orva.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Orva Python SDK — kv, invoke, jobs, crons, trace, log, context.

Available inside any function running on Orva. Routes through the
ORVA_API_BASE loopback URL using the per-process ORVA_INTERNAL_TOKEN
that the worker received at spawn time. Both env vars are present in
ORVA_API_BASE URL using the function-scoped ORVA_INTERNAL_TOKEN that the
worker received at spawn time. Both env vars are present in
production and absent in tests; helpers raise OrvaUnavailableError when
the SDK can't reach the host unless __test_mode__ has installed an
override.
Expand All @@ -27,7 +27,7 @@


# SDK version baked at adapter-embed time. Sent on every internal call.
SDK_VERSION = "0.6.0"
SDK_VERSION = "0.7.0"

T = TypeVar("T")

Expand Down Expand Up @@ -129,9 +129,6 @@ def _trace_headers() -> Dict[str, str]:
h["X-Orva-Trace-Id"] = v
if v := _span_id():
h["X-Orva-Span-Id"] = v
if v := _function_id():
h["X-Orva-Caller-Function"] = v
h["X-Orva-Function-Id"] = v
if v := _execution_id():
h["X-Orva-Execution-Id"] = v
return h
Expand Down Expand Up @@ -211,9 +208,12 @@ def get(key: str, default: Any = None) -> Any:
return data["value"] if data.get("value") is not None else default

@staticmethod
def put(key: str, value: Any, *, ttl_seconds: int = 0) -> None:
def put(key: str, value: Any, *, ttl_seconds: Optional[int] = None) -> None:
fn = _function_id()
payload = json.dumps({"value": value, "ttl_seconds": int(ttl_seconds)}).encode("utf-8")
request_body: Dict[str, Any] = {"value": value}
if ttl_seconds is not None:
request_body["ttl_seconds"] = int(ttl_seconds)
payload = json.dumps(request_body).encode("utf-8")
status, body, _ = _request(
"PUT", f"/api/v1/_kv/{fn}/{quote(key, safe='')}", body=payload
)
Expand Down Expand Up @@ -241,6 +241,7 @@ def list(
if status >= 400:
raise OrvaError(f"kv.list failed: {body!r}", status=status)
data = json.loads(body)
_assert_atomic_batch(data, "kv.get_many")
return {"keys": list(data.get("keys") or []), "next_cursor": data.get("next_cursor", "")}

@staticmethod
Expand All @@ -263,19 +264,17 @@ def put_many(entries: List[Dict[str, Any]]) -> None:
if not entries:
return
fn = _function_id()
ops = [
{
"op": "put",
"key": e["key"],
"value": e["value"],
"ttl_seconds": int(e.get("ttl_seconds", 0)),
}
for e in entries
]
ops = []
for entry in entries:
op = {"op": "put", "key": entry["key"], "value": entry["value"]}
if "ttl_seconds" in entry:
op["ttl_seconds"] = int(entry["ttl_seconds"])
ops.append(op)
payload = json.dumps({"ops": ops}).encode("utf-8")
status, body, _ = _request("POST", f"/api/v1/_kv/{fn}/batch", body=payload)
if status >= 400:
raise OrvaError(f"kv.put_many failed: {body!r}", status=status)
_assert_atomic_batch(json.loads(body), "kv.put_many")

@staticmethod
def delete_many(keys: List[str]) -> int:
Expand All @@ -287,12 +286,16 @@ def delete_many(keys: List[str]) -> int:
if status >= 400:
raise OrvaError(f"kv.delete_many failed: {body!r}", status=status)
data = json.loads(body)
_assert_atomic_batch(data, "kv.delete_many")
return sum(1 for r in data.get("results") or [] if r.get("found"))

@staticmethod
def incr(key: str, delta: int = 1, *, ttl_seconds: int = 0) -> int:
def incr(key: str, delta: int = 1, *, ttl_seconds: Optional[int] = None) -> int:
fn = _function_id()
payload = json.dumps({"delta": int(delta), "ttl_seconds": int(ttl_seconds)}).encode("utf-8")
request_body: Dict[str, Any] = {"delta": int(delta)}
if ttl_seconds is not None:
request_body["ttl_seconds"] = int(ttl_seconds)
payload = json.dumps(request_body).encode("utf-8")
status, body, _ = _request(
"POST", f"/api/v1/_kv/{fn}/{quote(key, safe='')}/incr", body=payload
)
Expand All @@ -301,11 +304,12 @@ def incr(key: str, delta: int = 1, *, ttl_seconds: int = 0) -> int:
return int(json.loads(body)["value"])

@staticmethod
def cas(key: str, expected: Any, new: Any, *, ttl_seconds: int = 0) -> bool:
def cas(key: str, expected: Any, new: Any, *, ttl_seconds: Optional[int] = None) -> bool:
fn = _function_id()
payload = json.dumps(
{"expected": expected, "new": new, "ttl_seconds": int(ttl_seconds)}
).encode("utf-8")
request_body: Dict[str, Any] = {"expected": expected, "new": new}
if ttl_seconds is not None:
request_body["ttl_seconds"] = int(ttl_seconds)
payload = json.dumps(request_body).encode("utf-8")
status, body, _ = _request(
"POST", f"/api/v1/_kv/{fn}/{quote(key, safe='')}/cas", body=payload
)
Expand All @@ -320,6 +324,12 @@ def cas(key: str, expected: Any, new: Any, *, ttl_seconds: int = 0) -> bool:
kv = _KV()


def _assert_atomic_batch(data: Dict[str, Any], operation: str) -> None:
failed = next((item for item in data.get("results") or [] if item.get("error")), None)
if failed:
raise OrvaError(f"{operation} failed: {failed['error']}", status=500)


# ── Function-to-function invoke ─────────────────────────────────────


Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/orva/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ func installAdapter(rootfs, runtime string) error {
}
fmt.Printf("[ok] installed %s adapter at %s\n", runtime, dst)

// v0.6: the runtime SDK ships alongside the adapter. Without these
// The runtime SDK ships alongside the adapter. Without these
// files, `require('orva')` / `from orva import …` raise inside a
// sandbox. Python places orva.py + py.typed directly under
// /opt/orva/; Node places orva.js + orva.d.ts + package.json under
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/database/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (db *Database) InsertActivity(row ActivityRow) {
if row.TS == 0 {
row.TS = time.Now().UnixMilli()
}
db.AsyncExec(`
db.AsyncExecTelemetry(`
INSERT INTO activity_log (
ts, source, actor_type, actor_id, actor_label,
method, path, status, duration_ms, summary, request_id, metadata, trace_id
Expand Down
Loading