feat: Function triggers — schedule, inbound hook, domain events

Migration 00016: function_id on scheduled_jobs/inbound_hooks,
event_pattern on functions, 'event' in the run-trigger enum.

- Jobs deliver to a function instead of HTTP when function_id is set;
  the run's status_code mirrors the process exit code. Clearing the
  field returns the job to URL delivery.
- Inbound hooks gain mode "function" — received body becomes stdin
  payload, acked immediately, run recorded async.
- event_pattern (document.*, *, exact type) fires the function detached
  on matching publishEvent — never blocks realtime/webhook fan-out.
- One executeFunction path serves manual/schedule/hook/event; run rows
  record the trigger source.
- Backend image now ships bun 1.3.14 so Functions work under compose.

Dashboard: job target picker, hook function mode, event-pattern field.

Verified live: job run → success (status_code 0), hook POST → run,
document.created → event run with correct payload.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 20:28:04 +02:00
1 parent c094afad65
commit a84049b8fc
40 files changed
+716 -157

No files matched your search

+64 -14
View File
@@ -1,9 +1,11 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
import {
AutomationService,
FunctionsService,
OpenAPI,
type ScheduledJob,
type ScheduledJobRun,
type Function as PrimoraFunction,
} from "@primora/api-client";
import { fetchApiToken } from "../lib/auth-client";
import { demoService } from "../lib/demo-mode";
@@ -63,7 +65,8 @@ export function AutomationPage(props: AutomationPageProps) {
const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({});
const [expandedJob, setExpandedJob] = createSignal<string>();
const [showCreate, setShowCreate] = createSignal(false);
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url" as "url" | "function", function_id: "" });
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
const [createdSecret, setCreatedSecret] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [busy, setBusy] = createSignal<string>();
@@ -82,6 +85,10 @@ export function AutomationPage(props: AutomationPageProps) {
try {
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
setJobs(res.items ?? []);
if (!props.demoMode) {
const fr = await FunctionsService.listFunctions({ projectId: props.projectID });
setFunctions(fr.items ?? []);
}
} catch (e) {
setError(err(e));
} finally {
@@ -199,14 +206,15 @@ export function AutomationPage(props: AutomationPageProps) {
requestBody: {
name: form.name.trim(),
schedule: form.schedule.trim(),
url: form.url.trim(),
url: form.target === "url" ? form.url.trim() : undefined,
function_id: form.target === "function" ? form.function_id : undefined,
secret: form.secret.trim() || undefined,
payload,
enabled: form.enabled,
},
});
if (created.secret) setCreatedSecret(created.secret);
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url", function_id: "" });
setShowCreate(false);
}, "Schedule created");
};
@@ -335,7 +343,9 @@ export function AutomationPage(props: AutomationPageProps) {
<code class="text-xs">{job.schedule}</code>
</td>
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
{job.url}
{job.function_id
? `ƒ ${functions().find((f) => f.id === job.function_id)?.name ?? "function"}`
: job.url}
</td>
<td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
<td class="hidden md:table-cell">
@@ -545,16 +555,56 @@ export function AutomationPage(props: AutomationPageProps) {
</p>
</div>
<div>
<Input
label="Target URL"
value={jobForm().url}
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
placeholder="https://api.example.com/jobs/rollup"
required
/>
<p class="label-hint">
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
</p>
<span class="label">Target</span>
<div class="flex gap-2 mb-2">
<button
type="button"
class={`btn btn-sm ${jobForm().target === "url" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setJobForm((c) => ({ ...c, target: "url" }))}
>
Webhook URL
</button>
<button
type="button"
class={`btn btn-sm ${jobForm().target === "function" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setJobForm((c) => ({ ...c, target: "function" }))}
>
Function
</button>
</div>
<Show
when={jobForm().target === "function"}
fallback={
<>
<Input
label="Target URL"
value={jobForm().url}
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
placeholder="https://api.example.com/jobs/rollup"
required
/>
<p class="label-hint">
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
</p>
</>
}
>
<select
class="input"
value={jobForm().function_id}
onChange={(e) => setJobForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
required
>
<option value="">Select a function…</option>
<For each={functions()}>
{(f) => <option value={f.id}>{f.name} ({f.runtime})</option>}
</For>
</select>
<p class="label-hint">
The function receives the resolved payload as JSON. Runs appear under the function's
history.
</p>
</Show>
</div>
<Textarea
label="Payload (optional JSON)"
+45 -5
View File
@@ -42,7 +42,9 @@ export function FunctionsPage(props: FunctionsPageProps) {
const [error, setError] = createSignal("");
const [busy, setBusy] = createSignal<string>();
const [showCreate, setShowCreate] = createSignal(false);
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun" });
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun", event_pattern: "" });
const [eventPattern, setEventPattern] = createSignal("");
const [patternDirty, setPatternDirty] = createSignal(false);
const refresh = async () => {
if (!props.projectID) return;
@@ -71,6 +73,8 @@ export function FunctionsPage(props: FunctionsPageProps) {
const select = async (fn: Function) => {
setSelected(fn);
setCodeDirty(false);
setEventPattern(fn.event_pattern ?? "");
setPatternDirty(false);
setLastRun(undefined);
if (props.demoMode) {
setCode(STARTER_CODE);
@@ -94,10 +98,15 @@ export function FunctionsPage(props: FunctionsPageProps) {
try {
const fn = await FunctionsService.createFunction({
projectId: props.projectID,
requestBody: { name: createForm().name, runtime: createForm().runtime as CreateFunctionRequest.runtime, code: STARTER_CODE },
requestBody: {
name: createForm().name,
runtime: createForm().runtime as CreateFunctionRequest.runtime,
code: STARTER_CODE,
event_pattern: createForm().event_pattern.trim() || undefined,
},
});
setShowCreate(false);
setCreateForm({ name: "", runtime: "bun" });
setCreateForm({ name: "", runtime: "bun", event_pattern: "" });
setMessage(`Function "${fn.name}" created.`);
await refresh();
await select(fn);
@@ -113,8 +122,15 @@ export function FunctionsPage(props: FunctionsPageProps) {
if (!fn || !props.projectID || props.demoMode) return;
setBusy("save");
try {
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { code: code() } });
await FunctionsService.updateFunction({
projectId: props.projectID,
functionId: fn.id,
requestBody: { code: code(), event_pattern: eventPattern().trim() },
});
setCodeDirty(false);
setPatternDirty(false);
setSelected({ ...fn, event_pattern: eventPattern().trim() });
setFunctions((list) => list.map((f) => (f.id === fn.id ? { ...f, event_pattern: eventPattern().trim() } : f)));
setMessage(`Saved "${fn.name}".`);
} catch (e) {
setError(err(e));
@@ -286,10 +302,13 @@ export function FunctionsPage(props: FunctionsPageProps) {
<div class="flex items-center justify-between" style="width:100%">
<span class="card-header-title">
{fn().name} <Badge variant="neutral">{fn().runtime}</Badge>
<Show when={fn().event_pattern}>
<Badge variant="warning">on {fn().event_pattern}</Badge>
</Show>
</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()}>
<button class="btn btn-secondary btn-sm" disabled={(!codeDirty() && !patternDirty()) || 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()}>
@@ -299,6 +318,21 @@ export function FunctionsPage(props: FunctionsPageProps) {
</div>
</div>
</div>
<div style="padding:1rem 1.25rem 0">
<Input
label="Event trigger (optional)"
placeholder='document.* · * · deploy.marker'
value={eventPattern()}
onInput={(e) => { setEventPattern(e.currentTarget.value); setPatternDirty(true); }}
disabled={!props.canManage || props.demoMode}
/>
<p class="text-text-3" style="font-size:0.78rem;margin-top:0.3rem">
Runs whenever a project event matching this pattern is published — e.g.{" "}
<code>document.*</code>, <code>object.created</code>, or <code>*</code> for all. The
function receives {"{ event, data, occurred_at }"}. Schedules and inbound hooks can
also target functions.
</p>
</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>
@@ -393,6 +427,12 @@ export function FunctionsPage(props: FunctionsPageProps) {
<option value="bun">bun</option>
<option value="deno">deno</option>
</Select>
<Input
label="Event trigger (optional)"
placeholder="document.*"
value={createForm().event_pattern}
onInput={(e) => setCreateForm((f) => ({ ...f, event_pattern: e.currentTarget.value }))}
/>
<p class="text-text-3" style="font-size:0.8rem">
Starts from a stdin-echo template — edit the code after creating.
</p>
+25 -4
View File
@@ -4,11 +4,13 @@ import {
AutomationService,
CreateInboundHookRequest,
CreateIntegrationRequest,
FunctionsService,
InboundService,
IntegrationsService,
UpsertAlertRuleRequest,
WebhooksService,
type AlertRule,
type Function as PrimoraFunction,
type InboundHook,
type ScheduledJob,
type Integration,
@@ -74,7 +76,8 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
const [showCreateAlert, setShowCreateAlert] = createSignal(false);
const [showCreateInbound, setShowCreateInbound] = createSignal(false);
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", secret: "" });
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", function_id: "", secret: "" });
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
const [copiedUrl, setCopiedUrl] = createSignal("");
const [alertForm, setAlertForm] = createSignal({
name: "",
@@ -97,18 +100,20 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
setLoading(true);
setError("");
try {
const [int, hooks, rules, inbound, jobs] = await Promise.all([
const [int, hooks, rules, inbound, jobs, fns] = await Promise.all([
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : InboundService.listInboundHooks({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : AutomationService.listScheduledJobs({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : FunctionsService.listFunctions({ projectId: props.projectID }),
]);
setIntegrations(int.items ?? []);
setWebhooks(hooks.items ?? []);
setAlertRules(rules.items ?? []);
setInboundHooks(inbound.items ?? []);
setScheduledJobs(jobs.items ?? []);
setFunctions(fns.items ?? []);
} catch (e) {
setError(err(e));
} finally {
@@ -268,11 +273,12 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
name: form.name.trim(),
mode: form.mode,
job_id: form.mode === CreateInboundHookRequest.mode.JOB ? form.job_id : undefined,
function_id: form.mode === CreateInboundHookRequest.mode.FUNCTION ? form.function_id : undefined,
secret: form.secret.trim() || undefined,
},
});
setShowCreateInbound(false);
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", secret: "" });
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", function_id: "", secret: "" });
}, "Inbound hook created");
};
@@ -706,7 +712,9 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
<tr>
<td class="font-medium">{hook.name}</td>
<td>
<Badge variant="neutral">{hook.mode === "job" ? "job trigger" : "event"}</Badge>
<Badge variant="neutral">
{hook.mode === "job" ? "job trigger" : hook.mode === "function" ? `ƒ ${functions().find((f) => f.id === hook.function_id)?.name ?? "function"}` : "event"}
</Badge>
<Show when={!hook.enabled}>
{" "}<Badge variant="warning">disabled</Badge>
</Show>
@@ -943,8 +951,21 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
options={[
{ value: "event", label: "Publish inbound.received event" },
{ value: "job", label: "Trigger a scheduled job" },
{ value: "function", label: "Invoke a function" },
]}
/>
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.FUNCTION}>
<Select
label="Function"
value={inboundForm().function_id}
onChange={(e) => setInboundForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
options={[
{ value: "", label: "Select a function…" },
...functions().map((f) => ({ value: f.id, label: f.name })),
]}
/>
<p class="text-text-2 text-xs">The received body becomes the function's stdin payload.</p>
</Show>
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.JOB}>
<Select
label="Job"