mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +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.
|
||||
- **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).
|
||||
- **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
|
||||
|
||||
|
||||
+5
-3
@@ -357,9 +357,11 @@ Reusable templates + an operational send log.
|
||||
|
||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||
|
||||
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
|
||||
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
|
||||
The job runner already supplies the scheduling half.
|
||||
- **Phase 9 — Functions** — ✅ first slice shipped: `core.functions` +
|
||||
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
|
||||
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:
|
||||
`BACKEND_STORAGE_DRIVER=s3` + `S3_*` env vars, stdlib SigV4, verified
|
||||
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 {
|
||||
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)
|
||||
|
||||
if cfg.Env == "production" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -45,6 +46,12 @@ type Config struct {
|
||||
S3SecretAccessKey string
|
||||
S3Prefix string
|
||||
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.
|
||||
@@ -80,6 +87,8 @@ func Load() (Config, error) {
|
||||
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||
FunctionsMaxBytes: 65536,
|
||||
}
|
||||
|
||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||
@@ -118,6 +127,12 @@ func Load() (Config, error) {
|
||||
}
|
||||
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
|
||||
if cfg.DatabaseURL == "" {
|
||||
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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
|
||||
@@ -30,6 +30,7 @@ type Querier interface {
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, 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)
|
||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||
@@ -44,6 +45,7 @@ type Querier interface {
|
||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
|
||||
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, 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)
|
||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, 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)
|
||||
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, 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)
|
||||
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, 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)
|
||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
||||
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||
@@ -105,6 +110,8 @@ type Querier interface {
|
||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, 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)
|
||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, 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)
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
|
||||
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, 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.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||
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/issues", h.listIssues)
|
||||
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
|
||||
scheduler *JobScheduler
|
||||
alerts *alertEvaluator
|
||||
functions FunctionRunner
|
||||
}
|
||||
|
||||
type BootstrapInput struct {
|
||||
@@ -224,8 +225,8 @@ type InvitationSummary struct {
|
||||
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 {
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
|
||||
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, functions: functions}
|
||||
if enc != nil {
|
||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||
s.dispatcher.Start(context.Background())
|
||||
|
||||
@@ -2167,6 +2167,182 @@ paths:
|
||||
$ref: "#/components/schemas/EmailLogListResponse"
|
||||
"401":
|
||||
$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:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -4213,3 +4389,95 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/EmailLogEntry"
|
||||
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,
|
||||
IntegrationsPage,
|
||||
VaultPage,
|
||||
FunctionsPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
IconDatabases,
|
||||
IconTelemetry,
|
||||
IconZap,
|
||||
IconTerminal,
|
||||
IconCollections,
|
||||
IconAudit,
|
||||
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-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-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-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") },
|
||||
@@ -1784,6 +1787,14 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "functions"}>
|
||||
<FunctionsPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconTelemetry,
|
||||
IconIntegrations,
|
||||
IconZap,
|
||||
IconTerminal,
|
||||
IconKey,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -33,6 +34,7 @@ export type ViewType =
|
||||
| "telemetry"
|
||||
| "integrations"
|
||||
| "automation"
|
||||
| "functions"
|
||||
| "vault"
|
||||
| "auth"
|
||||
| "audit"
|
||||
@@ -51,6 +53,7 @@ const projectNav: NavEntry[] = [
|
||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
||||
{ id: "functions", label: "Functions", shortLabel: "Funcs", icon: IconTerminal },
|
||||
{ id: "vault", label: "Vault", icon: IconKey },
|
||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||
@@ -99,6 +102,7 @@ const viewTitle: Record<ViewType, string> = {
|
||||
telemetry: "Telemetry",
|
||||
integrations: "Integrations",
|
||||
automation: "Automation",
|
||||
functions: "Functions",
|
||||
vault: "Vault",
|
||||
auth: "Authentication",
|
||||
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 { IntegrationsPage } from "./IntegrationsPage";
|
||||
export { VaultPage } from "./VaultPage";
|
||||
export { FunctionsPage } from "./FunctionsPage";
|
||||
export { AutomationPage } from "./AutomationPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
|
||||
@@ -37,6 +37,7 @@ export type { CreatedApiKey } from './models/CreatedApiKey';
|
||||
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
||||
export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest';
|
||||
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
||||
export { CreateFunctionRequest } from './models/CreateFunctionRequest';
|
||||
export { CreateInboundHookRequest } from './models/CreateInboundHookRequest';
|
||||
export { CreateIntegrationRequest } from './models/CreateIntegrationRequest';
|
||||
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
||||
@@ -71,6 +72,11 @@ export { EmailLogEntry } from './models/EmailLogEntry';
|
||||
export type { EmailLogListResponse } from './models/EmailLogListResponse';
|
||||
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
||||
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 { InboundHook } from './models/InboundHook';
|
||||
export type { InboundHookListResponse } from './models/InboundHookListResponse';
|
||||
@@ -88,6 +94,7 @@ export type { IntegrationListResponse } from './models/IntegrationListResponse';
|
||||
export type { IntegrationTestResult } from './models/IntegrationTestResult';
|
||||
export type { Invitation } from './models/Invitation';
|
||||
export type { InvitationID } from './models/InvitationID';
|
||||
export type { InvokeFunctionRequest } from './models/InvokeFunctionRequest';
|
||||
export type { JobID } from './models/JobID';
|
||||
export type { MeResponse } from './models/MeResponse';
|
||||
export type { MetricPoint } from './models/MetricPoint';
|
||||
@@ -132,6 +139,7 @@ export type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectReque
|
||||
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
|
||||
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
|
||||
export type { UpdateDocumentRequest } from './models/UpdateDocumentRequest';
|
||||
export type { UpdateFunctionRequest } from './models/UpdateFunctionRequest';
|
||||
export type { UpdateInstanceSettingRequest } from './models/UpdateInstanceSettingRequest';
|
||||
export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganizationMemberRoleRequest';
|
||||
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
|
||||
@@ -179,6 +187,7 @@ export { $CreatedApiKey } from './schemas/$CreatedApiKey';
|
||||
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
||||
export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest';
|
||||
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
||||
export { $CreateFunctionRequest } from './schemas/$CreateFunctionRequest';
|
||||
export { $CreateInboundHookRequest } from './schemas/$CreateInboundHookRequest';
|
||||
export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest';
|
||||
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
||||
@@ -213,6 +222,11 @@ export { $EmailLogEntry } from './schemas/$EmailLogEntry';
|
||||
export { $EmailLogListResponse } from './schemas/$EmailLogListResponse';
|
||||
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
||||
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 { $InboundHook } from './schemas/$InboundHook';
|
||||
export { $InboundHookListResponse } from './schemas/$InboundHookListResponse';
|
||||
@@ -230,6 +244,7 @@ export { $IntegrationListResponse } from './schemas/$IntegrationListResponse';
|
||||
export { $IntegrationTestResult } from './schemas/$IntegrationTestResult';
|
||||
export { $Invitation } from './schemas/$Invitation';
|
||||
export { $InvitationID } from './schemas/$InvitationID';
|
||||
export { $InvokeFunctionRequest } from './schemas/$InvokeFunctionRequest';
|
||||
export { $JobID } from './schemas/$JobID';
|
||||
export { $MeResponse } from './schemas/$MeResponse';
|
||||
export { $MetricPoint } from './schemas/$MetricPoint';
|
||||
@@ -274,6 +289,7 @@ export { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest
|
||||
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
|
||||
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
|
||||
export { $UpdateDocumentRequest } from './schemas/$UpdateDocumentRequest';
|
||||
export { $UpdateFunctionRequest } from './schemas/$UpdateFunctionRequest';
|
||||
export { $UpdateInstanceSettingRequest } from './schemas/$UpdateInstanceSettingRequest';
|
||||
export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizationMemberRoleRequest';
|
||||
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
|
||||
@@ -296,6 +312,7 @@ export { AutomationService } from './services/AutomationService';
|
||||
export { CollectionsService } from './services/CollectionsService';
|
||||
export { DatabasesService } from './services/DatabasesService';
|
||||
export { EmailService } from './services/EmailService';
|
||||
export { FunctionsService } from './services/FunctionsService';
|
||||
export { HealthService } from './services/HealthService';
|
||||
export { InboundService } from './services/InboundService';
|
||||
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