mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 08:04:50 +00:00
feat: Functions — JS/TS execution with run log
core.functions + core.function_runs: project-scoped code executed by a runtime binary on the backend host (bun or deno per function). Payload arrives on stdin + PRIMORA_PAYLOAD env; stdout/stderr captured per run (capped), with status/exit code/duration. Invoke returns 502 + the recorded run on failure so errors stay observable. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Runner config via FUNCTIONS_RUNTIME / FUNCTIONS_TIMEOUT_SECONDS / FUNCTIONS_MAX_OUTPUT_BYTES. Verified live: bun ran code, stdin payload delivered, error path returns 502 with captured stderr; timeout kills at deadline. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
3aca5bd1e0
commit
32587c8063
37 files changed
+2157
-6
No files matched your search
@@ -24,6 +24,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
|||||||
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
|
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
|
||||||
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
|
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
|
||||||
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
|
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
|
||||||
|
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
|
||||||
|
|
||||||
## [0.7.1] - 2026-09-23
|
## [0.7.1] - 2026-09-23
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -357,9 +357,11 @@ Reusable templates + an operational send log.
|
|||||||
|
|
||||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||||
|
|
||||||
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
|
- **Phase 9 — Functions** — ✅ first slice shipped: `core.functions` +
|
||||||
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
|
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
|
||||||
The job runner already supplies the scheduling half.
|
capped output, timeout), invoke API + dashboard page. Remaining
|
||||||
|
candidates: schedule/event/HTTP triggers riding jobs + inbound hooks,
|
||||||
|
and isolated runtimes (sidecar/Firecracker) for untrusted code.
|
||||||
- **Phase 10 — Storage backends** — ✅ S3-compatible object storage shipped:
|
- **Phase 10 — Storage backends** — ✅ S3-compatible object storage shipped:
|
||||||
`BACKEND_STORAGE_DRIVER=s3` + `S3_*` env vars, stdlib SigV4, verified
|
`BACKEND_STORAGE_DRIVER=s3` + `S3_*` env vars, stdlib SigV4, verified
|
||||||
against a real S3 server. Presigned-URL uploads for large files remain a
|
against a real S3 server. Presigned-URL uploads for large files remain a
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.functions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE core.function_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE,
|
||||||
|
trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('success','error','timeout')),
|
||||||
|
exit_code INT,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
duration_ms INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook','function'));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||||
|
DROP TABLE IF EXISTS core.function_runs;
|
||||||
|
DROP TABLE IF EXISTS core.functions;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- name: CreateFunction :one
|
||||||
|
INSERT INTO core.functions (project_id, name, code, runtime)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetFunction :one
|
||||||
|
SELECT * FROM core.functions WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetFunctionByName :one
|
||||||
|
SELECT * FROM core.functions WHERE project_id = $1 AND name = $2;
|
||||||
|
|
||||||
|
-- name: ListFunctions :many
|
||||||
|
SELECT * FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: UpdateFunction :one
|
||||||
|
UPDATE core.functions
|
||||||
|
SET code = COALESCE(sqlc.narg('code'), code),
|
||||||
|
enabled = COALESCE(sqlc.narg('enabled'), enabled),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteFunction :exec
|
||||||
|
DELETE FROM core.functions WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: InsertFunctionRun :one
|
||||||
|
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListFunctionRuns :many
|
||||||
|
SELECT * FROM core.function_runs
|
||||||
|
WHERE function_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2;
|
||||||
@@ -112,7 +112,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encryption key: %w", err)
|
return nil, fmt.Errorf("encryption key: %w", err)
|
||||||
}
|
}
|
||||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
|
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes))
|
||||||
settings := services.NewSettingsService(repo, encryptor)
|
settings := services.NewSettingsService(repo, encryptor)
|
||||||
|
|
||||||
if cfg.Env == "production" {
|
if cfg.Env == "production" {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -45,6 +46,12 @@ type Config struct {
|
|||||||
S3SecretAccessKey string
|
S3SecretAccessKey string
|
||||||
S3Prefix string
|
S3Prefix string
|
||||||
S3PathStyle bool
|
S3PathStyle bool
|
||||||
|
|
||||||
|
// Functions runtime: binary name/path ("auto" resolves per-function
|
||||||
|
// runtime from PATH — "bun" or "deno"), timeout and output cap.
|
||||||
|
FunctionsRuntime string
|
||||||
|
FunctionsTimeout time.Duration
|
||||||
|
FunctionsMaxBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// devEncryptionKey is the built-in credential key for local development only.
|
// devEncryptionKey is the built-in credential key for local development only.
|
||||||
@@ -80,6 +87,8 @@ func Load() (Config, error) {
|
|||||||
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||||
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||||
S3Prefix: os.Getenv("S3_PREFIX"),
|
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||||
|
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||||
|
FunctionsMaxBytes: 65536,
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||||
@@ -118,6 +127,12 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.S3PathStyle = s3PathStyle
|
cfg.S3PathStyle = s3PathStyle
|
||||||
|
|
||||||
|
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
|
||||||
|
}
|
||||||
|
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
missing = append(missing, "DATABASE_URL")
|
missing = append(missing, "DATABASE_URL")
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: functions.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createFunction = `-- name: CreateFunction :one
|
||||||
|
INSERT INTO core.functions (project_id, name, code, runtime)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateFunctionParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createFunction,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Code,
|
||||||
|
arg.Runtime,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFunction = `-- name: DeleteFunction :exec
|
||||||
|
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunction = `-- name: GetFunction :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunction, id)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunctionByName = `-- name: GetFunctionByName :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions WHERE project_id = $1 AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFunctionByNameParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertFunctionRun = `-- name: InsertFunctionRun :one
|
||||||
|
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertFunctionRunParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertFunctionRun,
|
||||||
|
arg.FunctionID,
|
||||||
|
arg.Trigger,
|
||||||
|
arg.Status,
|
||||||
|
arg.ExitCode,
|
||||||
|
arg.Stdout,
|
||||||
|
arg.Stderr,
|
||||||
|
arg.DurationMs,
|
||||||
|
)
|
||||||
|
var i CoreFunctionRun
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctionRuns = `-- name: ListFunctionRuns :many
|
||||||
|
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
|
||||||
|
WHERE function_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListFunctionRunsParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunctionRun
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunctionRun
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctions = `-- name: ListFunctions :many
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctions, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunction
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFunction = `-- name: UpdateFunction :one
|
||||||
|
UPDATE core.functions
|
||||||
|
SET code = COALESCE($3, code),
|
||||||
|
enabled = COALESCE($4, enabled),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Code *string `json:"code"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateFunction,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Code,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -266,6 +266,29 @@ type CoreEvent struct {
|
|||||||
Ts pgtype.Timestamptz `json:"ts"`
|
Ts pgtype.Timestamptz `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreFunction struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreFunctionRun struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreInboundHook struct {
|
type CoreInboundHook struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Querier interface {
|
|||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||||
|
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
|
||||||
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||||
@@ -44,6 +45,7 @@ type Querier interface {
|
|||||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||||
|
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
|
||||||
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
@@ -67,6 +69,8 @@ type Querier interface {
|
|||||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||||
|
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
|
||||||
|
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
|
||||||
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
||||||
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
||||||
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||||
@@ -86,6 +90,7 @@ type Querier interface {
|
|||||||
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
||||||
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
||||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||||
|
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
|
||||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
||||||
// Per-component most recent heartbeat — heartbeat_silence detection.
|
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
@@ -105,6 +110,8 @@ type Querier interface {
|
|||||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||||
|
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
|
||||||
|
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
|
||||||
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||||
@@ -150,6 +157,7 @@ type Querier interface {
|
|||||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||||
|
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
|
||||||
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
||||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listFunctions(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createFunction(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.CreateFunctionInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) getFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"function": fn, "code": code})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) updateFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.UpdateFunctionInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) invokeFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
// An empty body is a valid invoke — defaults to {}.
|
||||||
|
if c.Request.Body != nil && c.Request.ContentLength > 0 {
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusOK
|
||||||
|
if run.Status != "success" {
|
||||||
|
status = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
c.JSON(status, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listFunctionRuns(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64)
|
||||||
|
runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": runs})
|
||||||
|
}
|
||||||
@@ -84,6 +84,13 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||||
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||||
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
||||||
|
api.GET("/projects/:projectID/functions", h.listFunctions)
|
||||||
|
api.POST("/projects/:projectID/functions", h.createFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
|
||||||
|
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
|
||||||
|
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
|
||||||
|
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
|
||||||
api.GET("/projects/:projectID/events", h.listEvents)
|
api.GET("/projects/:projectID/events", h.listEvents)
|
||||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||||
api.GET("/projects/:projectID/components", h.listComponents)
|
api.GET("/projects/:projectID/components", h.listComponents)
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var functionNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`)
|
||||||
|
|
||||||
|
// FunctionRunner executes a function's code with a JSON payload on stdin
|
||||||
|
// and returns the outcome. Implemented by execRunner (a runtime binary on
|
||||||
|
// the backend host); tests substitute a fake.
|
||||||
|
type FunctionRunner interface {
|
||||||
|
Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionResult is one execution's captured outcome.
|
||||||
|
type FunctionResult struct {
|
||||||
|
Status string // success | error | timeout
|
||||||
|
ExitCode *int32
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
DurationMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// execRunner shells out to a JS runtime binary. User code runs with the
|
||||||
|
// backend's privileges — this is the documented self-hosted model (same as
|
||||||
|
// n8n custom-code nodes); operators harden at the container/VM boundary.
|
||||||
|
// jarvis: ceiling unsandboxed exec; upgrade to Firecracker/gVisor or a
|
||||||
|
// sidecar runtime pool when multi-tenant isolation is needed.
|
||||||
|
type execRunner struct {
|
||||||
|
bin string
|
||||||
|
timeout time.Duration
|
||||||
|
maxOutput int64
|
||||||
|
resolveBin func(runtime string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecRunner(bin string, timeout time.Duration, maxOutput int64) *execRunner {
|
||||||
|
return &execRunner{
|
||||||
|
bin: bin,
|
||||||
|
timeout: timeout,
|
||||||
|
maxOutput: maxOutput,
|
||||||
|
resolveBin: func(runtime string) string {
|
||||||
|
if bin != "" && bin != "auto" {
|
||||||
|
return bin
|
||||||
|
}
|
||||||
|
return runtime // "bun" or "deno" from PATH
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *execRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
|
||||||
|
bin := r.resolveBin(runtime)
|
||||||
|
if bin == "" {
|
||||||
|
return FunctionResult{Status: "error", Stderr: "no functions runtime configured"}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := os.MkdirTemp("", "primora-fn-*")
|
||||||
|
if err != nil {
|
||||||
|
return FunctionResult{Status: "error", Stderr: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
file := filepath.Join(dir, "fn.ts")
|
||||||
|
if err := os.WriteFile(file, []byte(code), 0o600); err != nil {
|
||||||
|
return FunctionResult{Status: "error", Stderr: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime {
|
||||||
|
case "deno":
|
||||||
|
// --no-prompt: never block on interactive permission prompts.
|
||||||
|
cmd = exec.CommandContext(ctx, bin, "run", "--no-prompt", file)
|
||||||
|
default: // bun
|
||||||
|
cmd = exec.CommandContext(ctx, bin, "run", file)
|
||||||
|
}
|
||||||
|
cmd.Stdin = bytes.NewReader(payload)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"PRIMORA_PAYLOAD="+string(payload),
|
||||||
|
"PRIMORA_FUNCTION_RUNTIME="+runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
|
||||||
|
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
runErr := cmd.Run()
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
res := FunctionResult{
|
||||||
|
Status: "success",
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
DurationMs: duration,
|
||||||
|
}
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
res.Status = "timeout"
|
||||||
|
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
res.Status = "error"
|
||||||
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
||||||
|
code := int32(exitErr.ExitCode())
|
||||||
|
res.ExitCode = &code
|
||||||
|
} else {
|
||||||
|
res.Stderr = strings.TrimSpace(runErr.Error() + "\n" + res.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitedWriter caps captured output; excess bytes are discarded, not
|
||||||
|
// stored — a chatty function can't grow function_runs rows unboundedly.
|
||||||
|
type limitedWriter struct {
|
||||||
|
w *bytes.Buffer
|
||||||
|
max int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitedWriter) Write(p []byte) (int, error) {
|
||||||
|
remaining := l.max - int64(l.w.Len())
|
||||||
|
if remaining > 0 {
|
||||||
|
if int64(len(p)) > remaining {
|
||||||
|
l.w.Write(p[:remaining])
|
||||||
|
} else {
|
||||||
|
l.w.Write(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- service layer -------------------------------------------------------
|
||||||
|
|
||||||
|
type CreateFunctionInput struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Code string `json:"code" validate:"required,max=262144"`
|
||||||
|
Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFunctionInput struct {
|
||||||
|
Code *string `json:"code" validate:"omitempty,max=262144"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionRunSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code,omitempty"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionSummary(r db.CoreFunction) FunctionSummary {
|
||||||
|
return FunctionSummary{
|
||||||
|
ID: r.ID.String(),
|
||||||
|
Name: r.Name,
|
||||||
|
Runtime: r.Runtime,
|
||||||
|
Enabled: r.Enabled,
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
UpdatedAt: r.UpdatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSummary(r db.CoreFunctionRun) FunctionRunSummary {
|
||||||
|
return FunctionRunSummary{
|
||||||
|
ID: r.ID.String(),
|
||||||
|
Trigger: r.Trigger,
|
||||||
|
Status: r.Status,
|
||||||
|
ExitCode: r.ExitCode,
|
||||||
|
Stdout: r.Stdout,
|
||||||
|
Stderr: r.Stderr,
|
||||||
|
DurationMs: int64(r.DurationMs),
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateFunctionInput, requestID string) (FunctionSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if !functionNamePattern.MatchString(name) {
|
||||||
|
return FunctionSummary{}, fmt.Errorf("invalid function name")
|
||||||
|
}
|
||||||
|
runtime := input.Runtime
|
||||||
|
if runtime == "" {
|
||||||
|
runtime = "bun"
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: name,
|
||||||
|
Code: input.Code,
|
||||||
|
Runtime: runtime,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
return functionSummary(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListFunctions(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]FunctionSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListFunctions(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]FunctionSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = functionSummary(r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFunctionCode returns the function's source — code is not a secret,
|
||||||
|
// project members already see it in the dashboard editor.
|
||||||
|
func (s *PlatformService) GetFunctionCode(ctx context.Context, actor *models.Actor, functionID uuid.UUID) (FunctionSummary, string, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, "", err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return FunctionSummary{}, "", err
|
||||||
|
}
|
||||||
|
return functionSummary(row), row.Code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, input UpdateFunctionInput, requestID string) (FunctionSummary, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{
|
||||||
|
ID: functionID,
|
||||||
|
ProjectID: row.ProjectID,
|
||||||
|
Code: input.Code,
|
||||||
|
Enabled: input.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
return functionSummary(updated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, requestID string) error {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
|
||||||
|
ID: functionID,
|
||||||
|
ProjectID: row.ProjectID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeFunction executes a function with a JSON payload, records the run,
|
||||||
|
// and returns its summary.
|
||||||
|
func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, payload json.RawMessage, requestID string) (FunctionRunSummary, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return FunctionRunSummary{}, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||||
|
return FunctionRunSummary{}, err
|
||||||
|
}
|
||||||
|
if !row.Enabled {
|
||||||
|
return FunctionRunSummary{}, fmt.Errorf("function is disabled")
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
if !json.Valid(payload) {
|
||||||
|
return FunctionRunSummary{}, fmt.Errorf("payload must be valid JSON")
|
||||||
|
}
|
||||||
|
if s.functions == nil {
|
||||||
|
return FunctionRunSummary{}, fmt.Errorf("functions runtime not configured")
|
||||||
|
}
|
||||||
|
result := s.functions.Run(ctx, row.Runtime, row.Code, payload)
|
||||||
|
var exit *int32
|
||||||
|
if result.ExitCode != nil {
|
||||||
|
exit = result.ExitCode
|
||||||
|
}
|
||||||
|
run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{
|
||||||
|
FunctionID: row.ID,
|
||||||
|
Trigger: "manual",
|
||||||
|
Status: result.Status,
|
||||||
|
ExitCode: exit,
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
DurationMs: int32(result.DurationMs),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return FunctionRunSummary{}, err
|
||||||
|
}
|
||||||
|
return runSummary(run), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListFunctionRuns(ctx context.Context, actor *models.Actor, functionID uuid.UUID, limit int64) ([]FunctionRunSummary, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListFunctionRuns(ctx, db.ListFunctionRunsParams{
|
||||||
|
FunctionID: row.ID,
|
||||||
|
Limit: int32(limit),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]FunctionRunSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = runSummary(r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests run the real bun binary — skipped when it is not installed,
|
||||||
|
// same convention as exec-dependent tests elsewhere.
|
||||||
|
func bunAvailable(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
r := NewExecRunner("auto", 5*time.Second, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun", `console.log("probe")`, nil)
|
||||||
|
if strings.Contains(res.Stderr, "no functions runtime") || (res.Status == "error" && res.ExitCode == nil && strings.Contains(res.Stderr, "executable file not found")) {
|
||||||
|
t.Skip("bun not installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRunnerSuccess(t *testing.T) {
|
||||||
|
bunAvailable(t)
|
||||||
|
r := NewExecRunner("auto", 10*time.Second, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun",
|
||||||
|
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("got:" + p.x);`,
|
||||||
|
[]byte(`{"x":42}`))
|
||||||
|
if res.Status != "success" {
|
||||||
|
t.Fatalf("status=%s stderr=%s", res.Status, res.Stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Stdout, "got:42") {
|
||||||
|
t.Fatalf("stdout=%q", res.Stdout)
|
||||||
|
}
|
||||||
|
if res.DurationMs <= 0 {
|
||||||
|
t.Fatalf("duration=%d", res.DurationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRunnerErrorCapturesStderr(t *testing.T) {
|
||||||
|
bunAvailable(t)
|
||||||
|
r := NewExecRunner("auto", 10*time.Second, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun", `throw new Error("test-boom")`, nil)
|
||||||
|
if res.Status != "error" {
|
||||||
|
t.Fatalf("status=%s", res.Status)
|
||||||
|
}
|
||||||
|
if res.ExitCode == nil || *res.ExitCode == 0 {
|
||||||
|
t.Fatalf("exit=%v", res.ExitCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Stderr, "test-boom") {
|
||||||
|
t.Fatalf("stderr=%q", res.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRunnerTimeout(t *testing.T) {
|
||||||
|
bunAvailable(t)
|
||||||
|
r := NewExecRunner("auto", 500*time.Millisecond, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun", `await new Promise(r => setTimeout(r, 10000))`, nil)
|
||||||
|
if res.Status != "timeout" {
|
||||||
|
t.Fatalf("status=%s", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitedWriterCaps(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
lw := &limitedWriter{w: &buf, max: 5}
|
||||||
|
if n, err := lw.Write([]byte("hello world")); err != nil || n != 11 {
|
||||||
|
t.Fatalf("write n=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
if buf.String() != "hello" {
|
||||||
|
t.Fatalf("got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ type PlatformService struct {
|
|||||||
dispatcher *WebhookDispatcher
|
dispatcher *WebhookDispatcher
|
||||||
scheduler *JobScheduler
|
scheduler *JobScheduler
|
||||||
alerts *alertEvaluator
|
alerts *alertEvaluator
|
||||||
|
functions FunctionRunner
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
@@ -224,8 +225,8 @@ type InvitationSummary struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
|
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
|
||||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
|
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions}
|
||||||
if enc != nil {
|
if enc != nil {
|
||||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||||
s.dispatcher.Start(context.Background())
|
s.dispatcher.Start(context.Background())
|
||||||
|
|||||||
@@ -2167,6 +2167,182 @@ paths:
|
|||||||
$ref: "#/components/schemas/EmailLogListResponse"
|
$ref: "#/components/schemas/EmailLogListResponse"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions:
|
||||||
|
get:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: listFunctions
|
||||||
|
summary: List functions in a project.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Functions
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
post:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: createFunction
|
||||||
|
summary: Create a function (name, JS/TS code, runtime bun|deno).
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateFunctionRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created function
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions/{functionID}:
|
||||||
|
get:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: getFunction
|
||||||
|
summary: Function metadata plus its source code.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Function with code
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionDetailResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
put:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: updateFunction
|
||||||
|
summary: Update function code and/or enabled state.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpdateFunctionRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated function
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
delete:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: deleteFunction
|
||||||
|
summary: Delete a function and its run history.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Deleted
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions/{functionID}/invoke:
|
||||||
|
post:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: invokeFunction
|
||||||
|
summary: Execute the function with a JSON payload on stdin; returns the recorded run. 502 when the run fails or times out.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/InvokeFunctionRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Recorded run (status success)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionRun"
|
||||||
|
"502":
|
||||||
|
description: Recorded run (status error|timeout)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionRun"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions/{functionID}/runs:
|
||||||
|
get:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: listFunctionRuns
|
||||||
|
summary: Recent runs for a function.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 200
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Function runs
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionRunListResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -4213,3 +4389,95 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/EmailLogEntry"
|
$ref: "#/components/schemas/EmailLogEntry"
|
||||||
required: [items]
|
required: [items]
|
||||||
|
Function:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
runtime:
|
||||||
|
type: string
|
||||||
|
enum: [bun, deno]
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, name, runtime, enabled, created_at, updated_at]
|
||||||
|
FunctionListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
required: [items]
|
||||||
|
FunctionDetailResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
function:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
required: [function, code]
|
||||||
|
CreateFunctionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
runtime:
|
||||||
|
type: string
|
||||||
|
enum: [bun, deno]
|
||||||
|
default: bun
|
||||||
|
required: [name, code]
|
||||||
|
UpdateFunctionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
InvokeFunctionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
payload: {}
|
||||||
|
FunctionRun:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
trigger:
|
||||||
|
type: string
|
||||||
|
enum: [manual, schedule, hook]
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [success, error, timeout]
|
||||||
|
exit_code:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
stdout:
|
||||||
|
type: string
|
||||||
|
stderr:
|
||||||
|
type: string
|
||||||
|
duration_ms:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, trigger, status, stdout, stderr, duration_ms, created_at]
|
||||||
|
FunctionRunListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/FunctionRun"
|
||||||
|
required: [items]
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
TelemetryPage,
|
TelemetryPage,
|
||||||
IntegrationsPage,
|
IntegrationsPage,
|
||||||
VaultPage,
|
VaultPage,
|
||||||
|
FunctionsPage,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
MembersPage,
|
MembersPage,
|
||||||
ProjectsPage,
|
ProjectsPage,
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
IconDatabases,
|
IconDatabases,
|
||||||
IconTelemetry,
|
IconTelemetry,
|
||||||
IconZap,
|
IconZap,
|
||||||
|
IconTerminal,
|
||||||
IconCollections,
|
IconCollections,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconAuth,
|
IconAuth,
|
||||||
@@ -1533,6 +1535,7 @@ export default function App() {
|
|||||||
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
||||||
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
|
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
|
||||||
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
|
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
|
||||||
|
{ id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: <IconTerminal class="w-4 h-4" />, keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") },
|
||||||
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
||||||
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
||||||
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
||||||
@@ -1784,6 +1787,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeView() === "functions"}>
|
||||||
|
<FunctionsPage
|
||||||
|
projectID={activeProject()?.id}
|
||||||
|
canManage={canUpdateProject()}
|
||||||
|
demoMode={isDemo}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={activeView() === "storage"}>
|
<Show when={activeView() === "storage"}>
|
||||||
<StoragePage
|
<StoragePage
|
||||||
buckets={buckets()}
|
buckets={buckets()}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconTelemetry,
|
IconTelemetry,
|
||||||
IconIntegrations,
|
IconIntegrations,
|
||||||
IconZap,
|
IconZap,
|
||||||
|
IconTerminal,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconAuth,
|
IconAuth,
|
||||||
@@ -33,6 +34,7 @@ export type ViewType =
|
|||||||
| "telemetry"
|
| "telemetry"
|
||||||
| "integrations"
|
| "integrations"
|
||||||
| "automation"
|
| "automation"
|
||||||
|
| "functions"
|
||||||
| "vault"
|
| "vault"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "audit"
|
| "audit"
|
||||||
@@ -51,6 +53,7 @@ const projectNav: NavEntry[] = [
|
|||||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||||
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||||
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
||||||
|
{ id: "functions", label: "Functions", shortLabel: "Funcs", icon: IconTerminal },
|
||||||
{ id: "vault", label: "Vault", icon: IconKey },
|
{ id: "vault", label: "Vault", icon: IconKey },
|
||||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||||
@@ -99,6 +102,7 @@ const viewTitle: Record<ViewType, string> = {
|
|||||||
telemetry: "Telemetry",
|
telemetry: "Telemetry",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
automation: "Automation",
|
automation: "Automation",
|
||||||
|
functions: "Functions",
|
||||||
vault: "Vault",
|
vault: "Vault",
|
||||||
auth: "Authentication",
|
auth: "Authentication",
|
||||||
audit: "Audit log",
|
audit: "Audit log",
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { For, Show, createEffect, createSignal } from "solid-js";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
CreateFunctionRequest,
|
||||||
|
Function,
|
||||||
|
FunctionsService,
|
||||||
|
type FunctionRun,
|
||||||
|
} from "@primora/api-client";
|
||||||
|
import { Modal, ModalFooter } from "../components/Modal";
|
||||||
|
import { Input, Select, Textarea } from "../components/Input";
|
||||||
|
import { Badge } from "../components/Badge";
|
||||||
|
import { IconPlus, IconTrash, IconPlay, IconTerminal } from "../components/Icons";
|
||||||
|
|
||||||
|
interface FunctionsPageProps {
|
||||||
|
projectID?: string;
|
||||||
|
canManage: boolean;
|
||||||
|
demoMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||||
|
|
||||||
|
const STARTER_CODE = `// The payload arrives on stdin and as PRIMORA_PAYLOAD.
|
||||||
|
const input = JSON.parse(await new Response(Bun.stdin.stream()).text());
|
||||||
|
console.log(JSON.stringify({ echo: input }));
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DEMO_FUNCTIONS: Function[] = [
|
||||||
|
{ id: "f1", name: "resize-image", runtime: Function.runtime.BUN, enabled: true, created_at: "", updated_at: "2026-09-20T09:00:00Z" },
|
||||||
|
{ id: "f2", name: "sync-billing", runtime: Function.runtime.BUN, enabled: false, created_at: "", updated_at: "2026-09-15T14:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FunctionsPage(props: FunctionsPageProps) {
|
||||||
|
const [functions, setFunctions] = createSignal<Function[]>([]);
|
||||||
|
const [selected, setSelected] = createSignal<Function>();
|
||||||
|
const [code, setCode] = createSignal("");
|
||||||
|
const [codeDirty, setCodeDirty] = createSignal(false);
|
||||||
|
const [runs, setRuns] = createSignal<FunctionRun[]>([]);
|
||||||
|
const [payload, setPayload] = createSignal("{}");
|
||||||
|
const [lastRun, setLastRun] = createSignal<FunctionRun>();
|
||||||
|
const [message, setMessage] = createSignal("");
|
||||||
|
const [error, setError] = createSignal("");
|
||||||
|
const [busy, setBusy] = createSignal<string>();
|
||||||
|
const [showCreate, setShowCreate] = createSignal(false);
|
||||||
|
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun" });
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (!props.projectID) return;
|
||||||
|
if (props.demoMode) {
|
||||||
|
setFunctions(DEMO_FUNCTIONS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await FunctionsService.listFunctions({ projectId: props.projectID });
|
||||||
|
setFunctions(res.items ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshRuns = async (fn: Function) => {
|
||||||
|
if (!props.projectID || props.demoMode) return;
|
||||||
|
try {
|
||||||
|
const res = await FunctionsService.listFunctionRuns({ projectId: props.projectID, functionId: fn.id, limit: 20 });
|
||||||
|
setRuns(res.items ?? []);
|
||||||
|
} catch {
|
||||||
|
/* run history is best-effort */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = async (fn: Function) => {
|
||||||
|
setSelected(fn);
|
||||||
|
setCodeDirty(false);
|
||||||
|
setLastRun(undefined);
|
||||||
|
if (props.demoMode) {
|
||||||
|
setCode(STARTER_CODE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await FunctionsService.getFunction({ projectId: props.projectID!, functionId: fn.id });
|
||||||
|
setCode(res.code);
|
||||||
|
await refreshRuns(fn);
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => void refresh());
|
||||||
|
|
||||||
|
const create = async (e: SubmitEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!props.projectID || props.demoMode) return;
|
||||||
|
setBusy("create");
|
||||||
|
try {
|
||||||
|
const fn = await FunctionsService.createFunction({
|
||||||
|
projectId: props.projectID,
|
||||||
|
requestBody: { name: createForm().name, runtime: createForm().runtime as CreateFunctionRequest.runtime, code: STARTER_CODE },
|
||||||
|
});
|
||||||
|
setShowCreate(false);
|
||||||
|
setCreateForm({ name: "", runtime: "bun" });
|
||||||
|
setMessage(`Function "${fn.name}" created.`);
|
||||||
|
await refresh();
|
||||||
|
await select(fn);
|
||||||
|
} catch (e2) {
|
||||||
|
setError(err(e2));
|
||||||
|
} finally {
|
||||||
|
setBusy(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const fn = selected();
|
||||||
|
if (!fn || !props.projectID || props.demoMode) return;
|
||||||
|
setBusy("save");
|
||||||
|
try {
|
||||||
|
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { code: code() } });
|
||||||
|
setCodeDirty(false);
|
||||||
|
setMessage(`Saved "${fn.name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoke = async () => {
|
||||||
|
const fn = selected();
|
||||||
|
if (!fn || !props.projectID || props.demoMode) return;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload() || "{}");
|
||||||
|
} catch {
|
||||||
|
setError("Payload is not valid JSON.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("invoke");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const run = await FunctionsService.invokeFunction({
|
||||||
|
projectId: props.projectID,
|
||||||
|
functionId: fn.id,
|
||||||
|
requestBody: { payload: parsed },
|
||||||
|
});
|
||||||
|
setLastRun(run);
|
||||||
|
} catch (e) {
|
||||||
|
// 502 still returns a FunctionRun body — the run is recorded either way.
|
||||||
|
if (e instanceof ApiError && e.body && typeof e.body === "object" && "status" in e.body) {
|
||||||
|
setLastRun(e.body as FunctionRun);
|
||||||
|
} else {
|
||||||
|
setError(err(e));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(undefined);
|
||||||
|
await refreshRuns(fn);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnabled = async (fn: Function) => {
|
||||||
|
if (!props.projectID || props.demoMode) return;
|
||||||
|
try {
|
||||||
|
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { enabled: !fn.enabled } });
|
||||||
|
await refresh();
|
||||||
|
if (selected()?.id === fn.id) setSelected({ ...fn, enabled: !fn.enabled });
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (fn: Function) => {
|
||||||
|
if (!props.projectID || props.demoMode) return;
|
||||||
|
setBusy(fn.id);
|
||||||
|
try {
|
||||||
|
await FunctionsService.deleteFunction({ projectId: props.projectID, functionId: fn.id });
|
||||||
|
if (selected()?.id === fn.id) setSelected(undefined);
|
||||||
|
setMessage(`Deleted "${fn.name}".`);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runBadge = (status: string) =>
|
||||||
|
status === "success" ? "success" : status === "timeout" ? "warning" : "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Functions</h1>
|
||||||
|
<p class="page-description">
|
||||||
|
Serverless JS/TS executed by a runtime on this instance (<code>bun</code> or <code>deno</code>).
|
||||||
|
The payload arrives on stdin and as <code>PRIMORA_PAYLOAD</code>; stdout is captured per run.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Show when={props.canManage && !props.demoMode}>
|
||||||
|
<button class="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||||
|
<IconPlus class="w-4 h-4" /> New function
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={message()}>
|
||||||
|
<div class="message message-success" onClick={() => setMessage("")}>{message()}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={error()}>
|
||||||
|
<div class="message message-error" onClick={() => setError("")}>{error()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={!props.projectID && !props.demoMode ? false : true}
|
||||||
|
fallback={<div class="card card-flush empty-state">Select a project to manage functions.</div>}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={functions().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="card card-flush empty-state">
|
||||||
|
<IconTerminal class="w-8 h-8" />
|
||||||
|
<p>No functions yet.</p>
|
||||||
|
<p class="text-text-3" style="font-size:0.85rem">
|
||||||
|
Functions run your code on demand — invoke them from the dashboard, the API, or wire them to schedules and hooks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Runtime</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th style="width:1%"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={functions()}>
|
||||||
|
{(fn) => (
|
||||||
|
<tr
|
||||||
|
style={selected()?.id === fn.id ? "background:var(--bg-3)" : "cursor:pointer"}
|
||||||
|
onClick={() => void select(fn)}
|
||||||
|
>
|
||||||
|
<td class="font-medium text-text-1">{fn.name}</td>
|
||||||
|
<td><Badge variant="neutral">{fn.runtime}</Badge></td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={fn.enabled ? "success" : "neutral"}>{fn.enabled ? "enabled" : "disabled"}</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="text-text-3">{fmtTime(fn.updated_at)}</td>
|
||||||
|
<td>
|
||||||
|
<Show when={props.canManage && !props.demoMode}>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
title={fn.enabled ? "Disable" : "Enable"}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void toggleEnabled(fn); }}
|
||||||
|
>
|
||||||
|
{fn.enabled ? "⏸" : "▶"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm text-danger"
|
||||||
|
disabled={busy() === fn.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void remove(fn); }}
|
||||||
|
>
|
||||||
|
<IconTrash class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={selected()}>
|
||||||
|
{(fn) => (
|
||||||
|
<div class="card card-flush" style="margin-top:1.25rem">
|
||||||
|
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
|
||||||
|
<div class="flex items-center justify-between" style="width:100%">
|
||||||
|
<span class="card-header-title">
|
||||||
|
{fn().name} <Badge variant="neutral">{fn().runtime}</Badge>
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<Show when={props.canManage && !props.demoMode}>
|
||||||
|
<button class="btn btn-secondary btn-sm" disabled={!codeDirty() || busy() === "save"} onClick={() => void save()}>
|
||||||
|
{busy() === "save" ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" disabled={busy() === "invoke" || !fn().enabled} onClick={() => void invoke()}>
|
||||||
|
<IconPlay class="w-3.5 h-3.5" /> {busy() === "invoke" ? "Running…" : "Invoke"}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1rem 1.25rem;display:grid;gap:1rem;grid-template-columns:1fr 1fr">
|
||||||
|
<div>
|
||||||
|
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Code</label>
|
||||||
|
<Textarea
|
||||||
|
rows={14}
|
||||||
|
value={code()}
|
||||||
|
onInput={(e) => { setCode(e.currentTarget.value); setCodeDirty(true); }}
|
||||||
|
disabled={!props.canManage || props.demoMode}
|
||||||
|
style="font-family:var(--font-mono);font-size:0.8rem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Payload (JSON)</label>
|
||||||
|
<Textarea
|
||||||
|
rows={5}
|
||||||
|
value={payload()}
|
||||||
|
onInput={(e) => setPayload(e.currentTarget.value)}
|
||||||
|
disabled={!props.canManage || props.demoMode}
|
||||||
|
style="font-family:var(--font-mono);font-size:0.8rem"
|
||||||
|
/>
|
||||||
|
<Show when={lastRun()}>
|
||||||
|
{(run) => (
|
||||||
|
<div style="margin-top:0.75rem">
|
||||||
|
<div class="flex items-center gap-2" style="margin-bottom:0.35rem">
|
||||||
|
<Badge variant={runBadge(run().status)}>{run().status}</Badge>
|
||||||
|
<span class="text-text-3" style="font-size:0.8rem">
|
||||||
|
{run().duration_ms} ms{run().exit_code != null ? ` · exit ${run().exit_code}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={run().stdout}>
|
||||||
|
<pre class="text-text-2" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stdout}</pre>
|
||||||
|
</Show>
|
||||||
|
<Show when={run().stderr}>
|
||||||
|
<pre class="text-danger" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stderr}</pre>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={selected() && runs().length > 0}>
|
||||||
|
<div class="card card-flush" style="margin-top:1.25rem">
|
||||||
|
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
|
||||||
|
<span class="card-header-title">Recent runs</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={runs()}>
|
||||||
|
{(run) => (
|
||||||
|
<tr>
|
||||||
|
<td><Badge variant={runBadge(run.status)}>{run.status}</Badge></td>
|
||||||
|
<td class="text-text-2">{run.trigger}</td>
|
||||||
|
<td class="text-text-3">{run.duration_ms} ms</td>
|
||||||
|
<td class="text-text-3">{fmtTime(run.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New function">
|
||||||
|
<form onSubmit={create}>
|
||||||
|
<div class="modal-body">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
placeholder="resize-image"
|
||||||
|
value={createForm().name}
|
||||||
|
onInput={(e) => setCreateForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Runtime"
|
||||||
|
value={createForm().runtime}
|
||||||
|
onChange={(e) => setCreateForm((f) => ({ ...f, runtime: e.currentTarget.value }))}
|
||||||
|
>
|
||||||
|
<option value="bun">bun</option>
|
||||||
|
<option value="deno">deno</option>
|
||||||
|
</Select>
|
||||||
|
<p class="text-text-3" style="font-size:0.8rem">
|
||||||
|
Starts from a stdin-echo template — edit the code after creating.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<button type="button" class="btn btn-secondary" onClick={() => setShowCreate(false)}>Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={busy() === "create" || !createForm().name.trim()}>
|
||||||
|
{busy() === "create" ? "Creating…" : "Create function"}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export { DatabasesPage } from "./DatabasesPage";
|
|||||||
export { TelemetryPage } from "./TelemetryPage";
|
export { TelemetryPage } from "./TelemetryPage";
|
||||||
export { IntegrationsPage } from "./IntegrationsPage";
|
export { IntegrationsPage } from "./IntegrationsPage";
|
||||||
export { VaultPage } from "./VaultPage";
|
export { VaultPage } from "./VaultPage";
|
||||||
|
export { FunctionsPage } from "./FunctionsPage";
|
||||||
export { AutomationPage } from "./AutomationPage";
|
export { AutomationPage } from "./AutomationPage";
|
||||||
export { SettingsPage } from "./SettingsPage";
|
export { SettingsPage } from "./SettingsPage";
|
||||||
export { AuditPage } from "./AuditPage";
|
export { AuditPage } from "./AuditPage";
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type { CreatedApiKey } from './models/CreatedApiKey';
|
|||||||
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
||||||
export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest';
|
export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest';
|
||||||
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
||||||
|
export { CreateFunctionRequest } from './models/CreateFunctionRequest';
|
||||||
export { CreateInboundHookRequest } from './models/CreateInboundHookRequest';
|
export { CreateInboundHookRequest } from './models/CreateInboundHookRequest';
|
||||||
export { CreateIntegrationRequest } from './models/CreateIntegrationRequest';
|
export { CreateIntegrationRequest } from './models/CreateIntegrationRequest';
|
||||||
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
||||||
@@ -71,6 +72,11 @@ export { EmailLogEntry } from './models/EmailLogEntry';
|
|||||||
export type { EmailLogListResponse } from './models/EmailLogListResponse';
|
export type { EmailLogListResponse } from './models/EmailLogListResponse';
|
||||||
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
||||||
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
||||||
|
export { Function } from './models/Function';
|
||||||
|
export type { FunctionDetailResponse } from './models/FunctionDetailResponse';
|
||||||
|
export type { FunctionListResponse } from './models/FunctionListResponse';
|
||||||
|
export { FunctionRun } from './models/FunctionRun';
|
||||||
|
export type { FunctionRunListResponse } from './models/FunctionRunListResponse';
|
||||||
export type { HealthStatus } from './models/HealthStatus';
|
export type { HealthStatus } from './models/HealthStatus';
|
||||||
export { InboundHook } from './models/InboundHook';
|
export { InboundHook } from './models/InboundHook';
|
||||||
export type { InboundHookListResponse } from './models/InboundHookListResponse';
|
export type { InboundHookListResponse } from './models/InboundHookListResponse';
|
||||||
@@ -88,6 +94,7 @@ export type { IntegrationListResponse } from './models/IntegrationListResponse';
|
|||||||
export type { IntegrationTestResult } from './models/IntegrationTestResult';
|
export type { IntegrationTestResult } from './models/IntegrationTestResult';
|
||||||
export type { Invitation } from './models/Invitation';
|
export type { Invitation } from './models/Invitation';
|
||||||
export type { InvitationID } from './models/InvitationID';
|
export type { InvitationID } from './models/InvitationID';
|
||||||
|
export type { InvokeFunctionRequest } from './models/InvokeFunctionRequest';
|
||||||
export type { JobID } from './models/JobID';
|
export type { JobID } from './models/JobID';
|
||||||
export type { MeResponse } from './models/MeResponse';
|
export type { MeResponse } from './models/MeResponse';
|
||||||
export type { MetricPoint } from './models/MetricPoint';
|
export type { MetricPoint } from './models/MetricPoint';
|
||||||
@@ -132,6 +139,7 @@ export type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectReque
|
|||||||
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
|
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
|
||||||
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
|
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
|
||||||
export type { UpdateDocumentRequest } from './models/UpdateDocumentRequest';
|
export type { UpdateDocumentRequest } from './models/UpdateDocumentRequest';
|
||||||
|
export type { UpdateFunctionRequest } from './models/UpdateFunctionRequest';
|
||||||
export type { UpdateInstanceSettingRequest } from './models/UpdateInstanceSettingRequest';
|
export type { UpdateInstanceSettingRequest } from './models/UpdateInstanceSettingRequest';
|
||||||
export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganizationMemberRoleRequest';
|
export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganizationMemberRoleRequest';
|
||||||
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
|
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
|
||||||
@@ -179,6 +187,7 @@ export { $CreatedApiKey } from './schemas/$CreatedApiKey';
|
|||||||
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
||||||
export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest';
|
export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest';
|
||||||
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
||||||
|
export { $CreateFunctionRequest } from './schemas/$CreateFunctionRequest';
|
||||||
export { $CreateInboundHookRequest } from './schemas/$CreateInboundHookRequest';
|
export { $CreateInboundHookRequest } from './schemas/$CreateInboundHookRequest';
|
||||||
export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest';
|
export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest';
|
||||||
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
||||||
@@ -213,6 +222,11 @@ export { $EmailLogEntry } from './schemas/$EmailLogEntry';
|
|||||||
export { $EmailLogListResponse } from './schemas/$EmailLogListResponse';
|
export { $EmailLogListResponse } from './schemas/$EmailLogListResponse';
|
||||||
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
||||||
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
||||||
|
export { $Function } from './schemas/$Function';
|
||||||
|
export { $FunctionDetailResponse } from './schemas/$FunctionDetailResponse';
|
||||||
|
export { $FunctionListResponse } from './schemas/$FunctionListResponse';
|
||||||
|
export { $FunctionRun } from './schemas/$FunctionRun';
|
||||||
|
export { $FunctionRunListResponse } from './schemas/$FunctionRunListResponse';
|
||||||
export { $HealthStatus } from './schemas/$HealthStatus';
|
export { $HealthStatus } from './schemas/$HealthStatus';
|
||||||
export { $InboundHook } from './schemas/$InboundHook';
|
export { $InboundHook } from './schemas/$InboundHook';
|
||||||
export { $InboundHookListResponse } from './schemas/$InboundHookListResponse';
|
export { $InboundHookListResponse } from './schemas/$InboundHookListResponse';
|
||||||
@@ -230,6 +244,7 @@ export { $IntegrationListResponse } from './schemas/$IntegrationListResponse';
|
|||||||
export { $IntegrationTestResult } from './schemas/$IntegrationTestResult';
|
export { $IntegrationTestResult } from './schemas/$IntegrationTestResult';
|
||||||
export { $Invitation } from './schemas/$Invitation';
|
export { $Invitation } from './schemas/$Invitation';
|
||||||
export { $InvitationID } from './schemas/$InvitationID';
|
export { $InvitationID } from './schemas/$InvitationID';
|
||||||
|
export { $InvokeFunctionRequest } from './schemas/$InvokeFunctionRequest';
|
||||||
export { $JobID } from './schemas/$JobID';
|
export { $JobID } from './schemas/$JobID';
|
||||||
export { $MeResponse } from './schemas/$MeResponse';
|
export { $MeResponse } from './schemas/$MeResponse';
|
||||||
export { $MetricPoint } from './schemas/$MetricPoint';
|
export { $MetricPoint } from './schemas/$MetricPoint';
|
||||||
@@ -274,6 +289,7 @@ export { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest
|
|||||||
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
|
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
|
||||||
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
|
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
|
||||||
export { $UpdateDocumentRequest } from './schemas/$UpdateDocumentRequest';
|
export { $UpdateDocumentRequest } from './schemas/$UpdateDocumentRequest';
|
||||||
|
export { $UpdateFunctionRequest } from './schemas/$UpdateFunctionRequest';
|
||||||
export { $UpdateInstanceSettingRequest } from './schemas/$UpdateInstanceSettingRequest';
|
export { $UpdateInstanceSettingRequest } from './schemas/$UpdateInstanceSettingRequest';
|
||||||
export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizationMemberRoleRequest';
|
export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizationMemberRoleRequest';
|
||||||
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
|
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
|
||||||
@@ -296,6 +312,7 @@ export { AutomationService } from './services/AutomationService';
|
|||||||
export { CollectionsService } from './services/CollectionsService';
|
export { CollectionsService } from './services/CollectionsService';
|
||||||
export { DatabasesService } from './services/DatabasesService';
|
export { DatabasesService } from './services/DatabasesService';
|
||||||
export { EmailService } from './services/EmailService';
|
export { EmailService } from './services/EmailService';
|
||||||
|
export { FunctionsService } from './services/FunctionsService';
|
||||||
export { HealthService } from './services/HealthService';
|
export { HealthService } from './services/HealthService';
|
||||||
export { InboundService } from './services/InboundService';
|
export { InboundService } from './services/InboundService';
|
||||||
export { InstanceService } from './services/InstanceService';
|
export { InstanceService } from './services/InstanceService';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type CreateFunctionRequest = {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
runtime?: CreateFunctionRequest.runtime;
|
||||||
|
};
|
||||||
|
export namespace CreateFunctionRequest {
|
||||||
|
export enum runtime {
|
||||||
|
BUN = 'bun',
|
||||||
|
DENO = 'deno',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type Function = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
runtime: Function.runtime;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
export namespace Function {
|
||||||
|
export enum runtime {
|
||||||
|
BUN = 'bun',
|
||||||
|
DENO = 'deno',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
import type { Function } from './Function';
|
||||||
|
export type FunctionDetailResponse = {
|
||||||
|
function: Function;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
import type { Function } from './Function';
|
||||||
|
export type FunctionListResponse = {
|
||||||
|
items: Array<Function>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type FunctionRun = {
|
||||||
|
id: string;
|
||||||
|
trigger: FunctionRun.trigger;
|
||||||
|
status: FunctionRun.status;
|
||||||
|
exit_code?: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
duration_ms: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
export namespace FunctionRun {
|
||||||
|
export enum trigger {
|
||||||
|
MANUAL = 'manual',
|
||||||
|
SCHEDULE = 'schedule',
|
||||||
|
HOOK = 'hook',
|
||||||
|
}
|
||||||
|
export enum status {
|
||||||
|
SUCCESS = 'success',
|
||||||
|
ERROR = 'error',
|
||||||
|
TIMEOUT = 'timeout',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
import type { FunctionRun } from './FunctionRun';
|
||||||
|
export type FunctionRunListResponse = {
|
||||||
|
items: Array<FunctionRun>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type InvokeFunctionRequest = {
|
||||||
|
payload?: any;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type UpdateFunctionRequest = {
|
||||||
|
code?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $CreateFunctionRequest = {
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
type: 'Enum',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $Function = {
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'uuid',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
type: 'Enum',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $FunctionDetailResponse = {
|
||||||
|
properties: {
|
||||||
|
function: {
|
||||||
|
type: 'Function',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $FunctionListResponse = {
|
||||||
|
properties: {
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
contains: {
|
||||||
|
type: 'Function',
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $FunctionRun = {
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'uuid',
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: 'Enum',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: 'Enum',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
exit_code: {
|
||||||
|
type: 'number',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
stdout: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
stderr: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
duration_ms: {
|
||||||
|
type: 'number',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $FunctionRunListResponse = {
|
||||||
|
properties: {
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
contains: {
|
||||||
|
type: 'FunctionRun',
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $InvokeFunctionRequest = {
|
||||||
|
properties: {
|
||||||
|
payload: {
|
||||||
|
properties: {
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $UpdateFunctionRequest = {
|
||||||
|
properties: {
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
import type { CreateFunctionRequest } from '../models/CreateFunctionRequest';
|
||||||
|
import type { Function } from '../models/Function';
|
||||||
|
import type { FunctionDetailResponse } from '../models/FunctionDetailResponse';
|
||||||
|
import type { FunctionListResponse } from '../models/FunctionListResponse';
|
||||||
|
import type { FunctionRun } from '../models/FunctionRun';
|
||||||
|
import type { FunctionRunListResponse } from '../models/FunctionRunListResponse';
|
||||||
|
import type { InvokeFunctionRequest } from '../models/InvokeFunctionRequest';
|
||||||
|
import type { UpdateFunctionRequest } from '../models/UpdateFunctionRequest';
|
||||||
|
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||||
|
import { OpenAPI } from '../core/OpenAPI';
|
||||||
|
import { request as __request } from '../core/request';
|
||||||
|
export class FunctionsService {
|
||||||
|
/**
|
||||||
|
* List functions in a project.
|
||||||
|
* @returns FunctionListResponse Functions
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static listFunctions({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
}): CancelablePromise<FunctionListResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/projects/{projectID}/functions',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
401: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a function (name, JS/TS code, runtime bun|deno).
|
||||||
|
* @returns Function Created function
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static createFunction({
|
||||||
|
projectId,
|
||||||
|
requestBody,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
requestBody: CreateFunctionRequest,
|
||||||
|
}): CancelablePromise<Function> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/projects/{projectID}/functions',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
400: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Function metadata plus its source code.
|
||||||
|
* @returns FunctionDetailResponse Function with code
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static getFunction({
|
||||||
|
projectId,
|
||||||
|
functionId,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
functionId: string,
|
||||||
|
}): CancelablePromise<FunctionDetailResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/projects/{projectID}/functions/{functionID}',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
'functionID': functionId,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
404: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update function code and/or enabled state.
|
||||||
|
* @returns Function Updated function
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static updateFunction({
|
||||||
|
projectId,
|
||||||
|
functionId,
|
||||||
|
requestBody,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
functionId: string,
|
||||||
|
requestBody: UpdateFunctionRequest,
|
||||||
|
}): CancelablePromise<Function> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/projects/{projectID}/functions/{functionID}',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
'functionID': functionId,
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
404: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Delete a function and its run history.
|
||||||
|
* @returns void
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static deleteFunction({
|
||||||
|
projectId,
|
||||||
|
functionId,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
functionId: string,
|
||||||
|
}): CancelablePromise<void> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/projects/{projectID}/functions/{functionID}',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
'functionID': functionId,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
404: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Execute the function with a JSON payload on stdin; returns the recorded run. 502 when the run fails or times out.
|
||||||
|
* @returns FunctionRun Recorded run (status success)
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static invokeFunction({
|
||||||
|
projectId,
|
||||||
|
functionId,
|
||||||
|
requestBody,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
functionId: string,
|
||||||
|
requestBody?: InvokeFunctionRequest,
|
||||||
|
}): CancelablePromise<FunctionRun> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/projects/{projectID}/functions/{functionID}/invoke',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
'functionID': functionId,
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
404: `Error response`,
|
||||||
|
502: `Recorded run (status error|timeout)`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Recent runs for a function.
|
||||||
|
* @returns FunctionRunListResponse Function runs
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static listFunctionRuns({
|
||||||
|
projectId,
|
||||||
|
functionId,
|
||||||
|
limit = 50,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
functionId: string,
|
||||||
|
limit?: number,
|
||||||
|
}): CancelablePromise<FunctionRunListResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/projects/{projectID}/functions/{functionID}/runs',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
'functionID': functionId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
'limit': limit,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
404: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user