@protobase/server 0.68.5 → 0.68.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/create-admin.js +11 -1
  2. package/dist/forms/rate-limit.js +33 -11
  3. package/dist/forms/routes.js +1 -1
  4. package/dist/functions/verify-signature.js +4 -2
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +3 -2
  7. package/dist/locale/en.js +3 -0
  8. package/dist/locale/nl.js +3 -0
  9. package/dist/mcp/mcp-tools.d.ts +2 -1
  10. package/dist/mcp/mcp-tools.js +4 -2
  11. package/dist/package.js +1 -1
  12. package/dist/workflows/hooks/hook-auth.js +40 -0
  13. package/dist/workflows/hooks/hook-event.js +51 -0
  14. package/dist/workflows/hooks/hook-paths.d.ts +16 -0
  15. package/dist/workflows/hooks/hook-paths.js +32 -0
  16. package/dist/workflows/hooks/hook-route.js +99 -0
  17. package/dist/workflows/hooks/hook-runs.js +32 -0
  18. package/dist/workflows/hooks/hook-secrets.js +42 -0
  19. package/dist/workflows/hooks/hook-store.js +35 -0
  20. package/dist/workflows/hooks/hooks-sql.js +20 -0
  21. package/dist/workflows/hooks/issue-body.js +21 -0
  22. package/dist/workflows/hooks/issue-route.js +23 -0
  23. package/dist/workflows/hooks/issue-webhook.js +63 -0
  24. package/dist/workflows/hooks/read-body.js +32 -0
  25. package/dist/workflows/mcp/webhook-tools.d.ts +9 -0
  26. package/dist/workflows/mcp/webhook-tools.js +96 -0
  27. package/dist/workflows/mcp/workflow-instructions.js +1 -1
  28. package/dist/workflows/mcp/workflow-read-tools.d.ts +3 -0
  29. package/dist/workflows/plugin-records.js +1 -1
  30. package/dist/workflows/plugin-services.js +1 -1
  31. package/dist/workflows/ready/ready-start.js +1 -1
  32. package/dist/workflows/run-files.js +7 -1
  33. package/dist/workflows/schema-sql.js +15 -3
  34. package/dist/workflows/schema.js +3 -1
  35. package/dist/workflows/start-workflow.d.ts +3 -1
  36. package/dist/workflows/start-workflow.js +10 -4
  37. package/dist/workflows/triggers/delete-hook.js +15 -0
  38. package/dist/workflows/triggers/plugin-call.js +35 -0
  39. package/dist/workflows/triggers/poll-job.js +120 -0
  40. package/dist/workflows/triggers/poll-result.js +32 -0
  41. package/dist/workflows/triggers/poll-schedule.js +32 -0
  42. package/dist/workflows/triggers/trigger-claim.js +81 -0
  43. package/dist/workflows/triggers/trigger-jobs.js +37 -7
  44. package/dist/workflows/triggers/trigger-start.js +101 -0
  45. package/dist/workflows/workflow-engine.js +38 -5
  46. package/dist/workflows/workflow-types.d.ts +4 -1
  47. package/dist/workflows/workflow-types.js +9 -3
  48. package/package.json +6 -6
@@ -0,0 +1,120 @@
1
+ import { defineJob } from "../../jobs/define-job.js";
2
+ import { callPlugin, triggerContext } from "./plugin-call.js";
3
+ import { pollProblem, stateProblem } from "./poll-result.js";
4
+ import { queueRuns } from "../start-workflow.js";
5
+ import { backoffPollAt, nextPollAt, pollEvery } from "./poll-schedule.js";
6
+ import { claimTrigger, saveNeedsSignIn, savePluginFailure, savePoll, saveRenewal, setNextJob } from "./trigger-claim.js";
7
+ //#region src/workflows/triggers/poll-job.ts
8
+ /** The plugin's most items a poll, as it declares them, never more than 100. */
9
+ const pollLimit = (type) => Math.min(type.limit ?? 100, 100);
10
+ /** How often a webhook trigger renews its subscription at its provider. */
11
+ const renewEvery = (type) => type.renewEvery ?? "1 day";
12
+ /**
13
+ * The jobs that poll a polling trigger and renew a webhook trigger's subscription at its provider, each on the trigger's
14
+ * claim (`claimTrigger`): only the job the trigger names runs it, so a stale or duplicate job does nothing. The plugin's
15
+ * error marks the trigger failing and backs off; an expired sign-in stops it, with every trigger on that connection,
16
+ * until its owner signs in again; any other error is thrown, and the job retries under its own id.
17
+ */
18
+ const pollJobs = (input) => {
19
+ const { schema, redact } = input;
20
+ const queueNext = async (trx, job, claim, due) => {
21
+ const id = await job.enqueue(trx, {
22
+ trigger: claim.trigger,
23
+ generation: claim.generation,
24
+ due: due.toISOString()
25
+ }, { runAt: due });
26
+ await setNextJob(trx, schema, claim.trigger, id);
27
+ };
28
+ const failed = (db, job, claim, message, nextAt) => db.transaction().execute(async (trx) => {
29
+ if (await savePluginFailure(trx, schema, claim, message, nextAt)) await queueNext(trx, job, claim, nextAt);
30
+ });
31
+ const poll = defineJob({
32
+ name: "workflows.poll",
33
+ timeout: "5 minutes",
34
+ retryDelay: "5 seconds",
35
+ run: async ({ trigger, generation, due }, { db, job, signal }) => {
36
+ const claim = {
37
+ trigger,
38
+ generation,
39
+ job: job.id,
40
+ attempt: job.attempt
41
+ };
42
+ const claimed = await claimTrigger(db, schema, claim);
43
+ const type = claimed && input.polling(claimed.type);
44
+ if (!claimed || !type) return;
45
+ const interval = await callPlugin(async () => pollEvery(type, claimed.input), redact);
46
+ if (!("value" in interval)) return failed(db, poll, claim, "expired" in interval ? interval.expired : interval.failed, backoffPollAt("1 minute", claimed.consecutive_failures + 1, /* @__PURE__ */ new Date()));
47
+ const every = interval.value;
48
+ const limit = pollLimit(type);
49
+ const context = triggerContext(input, claimed.input, signal);
50
+ const outcome = await callPlugin(() => type.poll({
51
+ ...context,
52
+ cursor: claimed.cursor,
53
+ store: claimed.store,
54
+ limit,
55
+ due: new Date(due)
56
+ }), redact);
57
+ const now = /* @__PURE__ */ new Date();
58
+ if ("expired" in outcome) return saveNeedsSignIn(db, schema, claim, claimed, outcome.expired);
59
+ const backoff = backoffPollAt(every, claimed.consecutive_failures + 1, now);
60
+ if ("failed" in outcome) return failed(db, poll, claim, outcome.failed, backoff);
61
+ const problem = pollProblem(outcome.value, limit);
62
+ if (problem) return failed(db, poll, claim, `The poll ${problem}`, backoff);
63
+ const { items, cursor, store } = outcome.value;
64
+ const dueAt = new Date(due);
65
+ const nextAt = nextPollAt(every, dueAt.getTime() > now.getTime() - 1e3 ? dueAt : now);
66
+ await db.transaction().execute(async (trx) => {
67
+ const runs = await savePoll(trx, schema, claim, items, {
68
+ cursor,
69
+ store: store === void 0 ? claimed.store : store,
70
+ nextAt
71
+ });
72
+ if (!runs) return;
73
+ await queueRuns(trx, runs);
74
+ await queueNext(trx, poll, claim, nextAt);
75
+ });
76
+ }
77
+ });
78
+ const renew = defineJob({
79
+ name: "workflows.renew",
80
+ timeout: "5 minutes",
81
+ retryDelay: "5 seconds",
82
+ run: async ({ trigger, generation }, { db, job, signal }) => {
83
+ const claim = {
84
+ trigger,
85
+ generation,
86
+ job: job.id,
87
+ attempt: job.attempt
88
+ };
89
+ const claimed = await claimTrigger(db, schema, claim);
90
+ const type = claimed && input.webhooks(claimed.type);
91
+ if (!claimed || !type?.renew) return;
92
+ const renewal = type.renew;
93
+ const context = triggerContext(input, claimed.input, signal);
94
+ const outcome = await callPlugin(() => renewal({
95
+ ...context,
96
+ state: claimed.store
97
+ }), redact);
98
+ const now = /* @__PURE__ */ new Date();
99
+ if ("expired" in outcome) return saveNeedsSignIn(db, schema, claim, claimed, outcome.expired);
100
+ const backoff = backoffPollAt("1 minute", claimed.consecutive_failures + 1, now);
101
+ if ("failed" in outcome) return failed(db, renew, claim, outcome.failed, backoff);
102
+ const problem = stateProblem(outcome.value);
103
+ if (problem) return failed(db, renew, claim, `The renewal ${problem}`, backoff);
104
+ const nextAt = nextPollAt(renewEvery(type), now);
105
+ await db.transaction().execute(async (trx) => {
106
+ if (await saveRenewal(trx, schema, claim, {
107
+ store: outcome.value,
108
+ nextAt
109
+ })) await queueNext(trx, renew, claim, nextAt);
110
+ });
111
+ }
112
+ });
113
+ return {
114
+ poll,
115
+ renew,
116
+ jobs: [poll, renew]
117
+ };
118
+ };
119
+ //#endregion
120
+ export { pollJobs, pollLimit, renewEvery };
@@ -0,0 +1,32 @@
1
+ import { triggerStateBytes } from "../schema-sql.js";
2
+ import { limits, nestingDepth } from "@protobase/workflows";
3
+ const jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value ?? null)).length;
4
+ /** What is wrong with one item's data as a run's input, as the run's own limits say. */
5
+ const inputProblem = (data) => {
6
+ if (nestingDepth(data, limits.inputDepth) > limits.inputDepth) return `nests deeper than ${limits.inputDepth} levels`;
7
+ if (jsonBytes(data) > limits.inputBytes) return `is over ${limits.inputBytes / 1024 / 1024} MiB as JSON`;
8
+ };
9
+ /** What is wrong with the items a plugin gave, each needing a key when `keyed`; `undefined` when they all fit. */
10
+ const itemsProblem = (items, most, keyed) => {
11
+ if (!Array.isArray(items)) return "gave no items array";
12
+ if (items.length > most) return `gave ${items.length} items, more than its limit of ${most}`;
13
+ for (const [index, item] of items.entries()) {
14
+ const { key, data } = item ?? {};
15
+ if ((keyed || key !== void 0) && (typeof key !== "string" || key.length === 0 || key.length > 200)) return `item ${index} has no key of 1 to 200 characters`;
16
+ const problem = inputProblem(data);
17
+ if (problem) return `item ${index}'s data ${problem}`;
18
+ }
19
+ };
20
+ /** What is wrong with a subscription's state a webhook plugin gave: over 64 KiB as JSON. */
21
+ const stateProblem = (state) => jsonBytes(state) > 65536 ? `gave a state over ${triggerStateBytes / 1024} KiB as JSON` : void 0;
22
+ /** What is wrong with a poll's result: its items, and its cursor and store over 64 KiB as JSON. */
23
+ const pollProblem = (result, limit) => {
24
+ if (typeof result !== "object" || result === null) return "gave no result";
25
+ const { items, cursor, store } = result;
26
+ const problem = itemsProblem(items, limit, true);
27
+ if (problem) return problem;
28
+ if (jsonBytes(cursor) > 65536) return `gave a cursor over ${triggerStateBytes / 1024} KiB as JSON`;
29
+ if (store !== void 0 && jsonBytes(store) > 65536) return `gave a store over ${triggerStateBytes / 1024} KiB as JSON`;
30
+ };
31
+ //#endregion
32
+ export { itemsProblem, pollProblem, stateProblem };
@@ -0,0 +1,32 @@
1
+ import { nextRun } from "../../jobs/schedule.js";
2
+ import { parseDuration } from "../../files/duration.js";
3
+ //#region src/workflows/triggers/poll-schedule.ts
4
+ /** The shortest time between two polls, whatever a plugin's `every` says. */
5
+ const shortestMs = 1e3;
6
+ /** The longest wait a failing trigger's backoff reaches. */
7
+ const longestBackoffMs = 36e5;
8
+ /** The base of a cron trigger's backoff, which has no interval of its own. */
9
+ const cronBackoffMs = 6e4;
10
+ /** How often a polling trigger polls for this input. */
11
+ const pollEvery = (type, input) => typeof type.every === "function" ? type.every(input) : type.every;
12
+ /** The time of the poll after one due at `after`: an interval later, or the cron's next time. */
13
+ const nextPollAt = (every, after) => {
14
+ if (typeof every === "string") return new Date(after.getTime() + Math.max(parseDuration(every, "every"), shortestMs));
15
+ return nextRun(every.cron, every.timezone ?? "UTC", after);
16
+ };
17
+ /** The first poll of a trigger that starts at `now`: at once on an interval, at the cron's next time otherwise. */
18
+ const firstPollAt = (every, now) => typeof every === "string" ? now : nextRun(every.cron, every.timezone ?? "UTC", now);
19
+ /**
20
+ * When a trigger that failed `failures` times in a row polls again: `every × 2^failures`, at most an hour and never
21
+ * sooner than its interval; a cron trigger at its first time after a minute × 2^failures, at most an hour, from now.
22
+ */
23
+ const backoffPollAt = (every, failures, now) => {
24
+ const factor = 2 ** Math.min(failures, 20);
25
+ if (typeof every === "string") {
26
+ const interval = Math.max(parseDuration(every, "every"), shortestMs);
27
+ return new Date(now.getTime() + Math.max(interval, Math.min(interval * factor, longestBackoffMs)));
28
+ }
29
+ return nextRun(every.cron, every.timezone ?? "UTC", new Date(now.getTime() + Math.min(cronBackoffMs * factor, longestBackoffMs)));
30
+ };
31
+ //#endregion
32
+ export { backoffPollAt, firstPollAt, nextPollAt, pollEvery };
@@ -0,0 +1,81 @@
1
+ import { sql } from "kysely";
2
+ //#region src/workflows/triggers/trigger-claim.ts
3
+ /**
4
+ * Claims the trigger for this try of the job its row names, in one statement: only while it runs on this generation,
5
+ * and only for a later try than the last that claimed it, so a retry of a job whose worker died takes its claim back,
6
+ * and a try that ran past its timeout no longer holds it. `undefined` when the trigger was stopped, changed or moved
7
+ * to another job.
8
+ */
9
+ const claimTrigger = async (db, schema, { trigger, generation, job, attempt }) => {
10
+ const { rows } = await sql`update ${sql.id(schema, "triggers")} set poll_attempt = ${attempt}
11
+ where id = ${trigger} and generation = ${generation} and poll_job_id = ${job} and status in ('active', 'failing')
12
+ and (poll_attempt is null or poll_attempt < ${attempt})
13
+ returning id, workflow_id, version, tenant_id, type, input, cursor, store, connection_id, consecutive_failures`.execute(db);
14
+ return rows[0];
15
+ };
16
+ const held = (schema, { trigger, generation, job, attempt }) => sql`select id, workflow_id, version, tenant_id from ${sql.id(schema, "triggers")}
17
+ where id = ${trigger} and generation = ${generation} and poll_job_id = ${job} and poll_attempt = ${attempt} and status in ('active', 'failing') for update`;
18
+ /**
19
+ * Saves a successful poll in one statement that locks the trigger row while this try holds its claim: the runs of its
20
+ * items, each once per workflow and key, and the new cursor and store, the trigger active again. The ids of the runs it
21
+ * started, or `undefined` when the claim was gone and nothing was written.
22
+ */
23
+ const savePoll = async (trx, schema, claim, items, { cursor, store, nextAt }) => {
24
+ const triggers = sql.id(schema, "triggers");
25
+ const { rows } = await sql`with t as (${held(schema, claim)}),
26
+ started as (insert into ${sql.id(schema, "runs")} (workflow_id, version, input, key, tenant_id, started_by)
27
+ select t.workflow_id, t.version, i.data, i.key, t.tenant_id, 'trigger:' || t.id from t cross join jsonb_to_recordset(${JSON.stringify(items)}::jsonb) as i(key text, data jsonb)
28
+ on conflict (workflow_id, key) do nothing returning id),
29
+ saved as (update ${triggers} set cursor = ${JSON.stringify(cursor ?? null)}::jsonb, store = ${JSON.stringify(store ?? null)}::jsonb, status = 'active',
30
+ consecutive_failures = 0, last_error = null, poll_attempt = null, next_poll_at = ${nextAt}, updated_at = now() from t where ${triggers}.id = t.id returning 1)
31
+ select (select count(*) from saved)::int as saved, coalesce((select json_agg(id) from started), '[]'::json) as runs`.execute(trx);
32
+ const row = rows[0];
33
+ return row.saved > 0 ? row.runs : void 0;
34
+ };
35
+ /** Saves a renewal's state while this try holds its claim; whether it did. */
36
+ const saveRenewal = async (trx, schema, claim, { store, nextAt }) => {
37
+ const triggers = sql.id(schema, "triggers");
38
+ const { rows } = await sql`with t as (${held(schema, claim)})
39
+ update ${triggers} set store = ${JSON.stringify(store ?? null)}::jsonb, status = 'active', consecutive_failures = 0, last_error = null, poll_attempt = null,
40
+ next_poll_at = ${nextAt}, updated_at = now() from t where ${triggers}.id = t.id returning 1`.execute(trx);
41
+ return rows.length > 0;
42
+ };
43
+ /** Marks the trigger failing with the plugin's error, while this try holds its claim; whether it did. */
44
+ const savePluginFailure = async (trx, schema, claim, message, nextAt) => {
45
+ const triggers = sql.id(schema, "triggers");
46
+ const error = JSON.stringify({
47
+ code: "plugin_error",
48
+ message,
49
+ at: (/* @__PURE__ */ new Date()).toISOString()
50
+ });
51
+ const { rows } = await sql`with t as (${held(schema, claim)})
52
+ update ${triggers} set status = 'failing', consecutive_failures = consecutive_failures + 1, last_error = ${error}::jsonb, poll_attempt = null,
53
+ next_poll_at = ${nextAt}, updated_at = now() from t where ${triggers}.id = t.id returning 1`.execute(trx);
54
+ return rows.length > 0;
55
+ };
56
+ /**
57
+ * Names the job that polls or renews the trigger next, on the row the transaction holds locked. The new job's tries
58
+ * count from one, so a claim an older job's try still holds is dropped with it.
59
+ */
60
+ const setNextJob = async (trx, schema, trigger, job) => {
61
+ await sql`update ${sql.id(schema, "triggers")} set poll_job_id = ${job}, poll_attempt = null where id = ${trigger}`.execute(trx);
62
+ };
63
+ /**
64
+ * The connection's sign-in expired: this trigger, while this try holds its claim, and every running trigger of the same
65
+ * tenant on the same connection wait for a new sign-in, with no next poll. A trigger without a connection is the only
66
+ * one it moves.
67
+ */
68
+ const saveNeedsSignIn = async (db, schema, claim, trigger, message) => {
69
+ const error = JSON.stringify({
70
+ code: "link_expired",
71
+ message,
72
+ at: (/* @__PURE__ */ new Date()).toISOString()
73
+ });
74
+ const { trigger: id, generation, job, attempt } = claim;
75
+ await sql`update ${sql.id(schema, "triggers")} set status = 'needs_sign_in', last_error = ${error}::jsonb, poll_job_id = null, poll_attempt = null, next_poll_at = null, updated_at = now()
76
+ where status in ('active', 'failing') and (
77
+ (id = ${id} and generation = ${generation} and poll_job_id = ${job} and poll_attempt = ${attempt})
78
+ or (${trigger.connection_id}::text is not null and connection_id = ${trigger.connection_id} and tenant_id is not distinct from ${trigger.tenant_id}))`.execute(db);
79
+ };
80
+ //#endregion
81
+ export { claimTrigger, saveNeedsSignIn, savePluginFailure, savePoll, saveRenewal, setNextJob };
@@ -1,7 +1,10 @@
1
1
  import { defineJob } from "../../jobs/define-job.js";
2
+ import { callPlugin, triggerContext } from "./plugin-call.js";
2
3
  import { readyAt, readyWhen, resolveTimeTerms } from "../ready/ready-when.js";
3
4
  import { activeTriggers, currentTrigger, triggerFailed } from "./trigger-row.js";
4
5
  import { orRefusal, readyContext, startIfReady } from "../ready/ready-start.js";
6
+ import { pollJobs } from "./poll-job.js";
7
+ import { triggerStarts } from "./trigger-start.js";
5
8
  import { encodeKey } from "@protobase/schema/internal";
6
9
  import { printFilter } from "@protobase/schema";
7
10
  import { sql } from "kysely";
@@ -10,12 +13,15 @@ import { sql } from "kysely";
10
13
  const passSize = 100;
11
14
  /**
12
15
  * The jobs that run triggers on every server process, so turning a workflow on, changing or turning it off needs no
13
- * restart: `workflows.trigger-start` (for a ready trigger, one pass over the records that already match, 100 per job),
14
- * `workflows.trigger-stop` (removes a stopped trigger), and `workflows.ready-check` (starts a run of one record when it
15
- * is ready, at the time its time terms say). Each acts only on the generation it was queued for.
16
+ * restart: `workflows.trigger-start` (for a ready trigger, one pass over the records that already match, 100 per job;
17
+ * for a polling one its first poll; for a subscribing webhook one its subscription), `workflows.trigger-stop`
18
+ * (unsubscribes a stopped webhook trigger at its provider and removes it), `workflows.ready-check` (starts a run of one
19
+ * record when it is ready, at the time its time terms say), and the poll and renewal jobs. Each acts only on the
20
+ * generation it was queued for.
16
21
  */
17
22
  const triggerJobs = (input) => {
18
23
  const { schema } = input;
24
+ const polls = pollJobs(input);
19
25
  const readyCheck = defineJob({
20
26
  name: "workflows.ready-check",
21
27
  timeout: "5 minutes",
@@ -42,12 +48,20 @@ const triggerJobs = (input) => {
42
48
  key: `ready:${trigger.id}:${trigger.generation}:${recordKey}:${at.toISOString()}`
43
49
  });
44
50
  };
51
+ const starts = triggerStarts(input, polls);
45
52
  const start = defineJob({
46
53
  name: "workflows.trigger-start",
47
54
  timeout: "5 minutes",
48
- run: async ({ trigger: id, generation, page_token }, { db }) => {
55
+ run: async ({ trigger: id, generation, page_token }, { db, signal }) => {
49
56
  const trigger = await currentTrigger(db, schema, id, generation);
50
- if (!trigger || !input.declared(trigger.type)) return;
57
+ if (!trigger) return;
58
+ const restart = async (later, at) => void await start.enqueue(later, {
59
+ trigger: id,
60
+ generation
61
+ }, { runAt: at });
62
+ if (input.polling(trigger.type)) return starts.startPolling(db, trigger, signal, restart);
63
+ if (input.webhooks(trigger.type)) return starts.startWebhook(db, trigger, signal, restart);
64
+ if (!input.declared(trigger.type)) return;
51
65
  const found = await orRefusal(readyContext(input, trigger));
52
66
  if ("refused" in found) return triggerFailed(db, schema, trigger, "refused", found.refused);
53
67
  if (!found.value) return;
@@ -71,9 +85,24 @@ const triggerJobs = (input) => {
71
85
  });
72
86
  }
73
87
  });
88
+ const unsubscribe = async (db, trigger, signal) => {
89
+ const { rows } = await sql`select type, input, store, store is not null as subscribed
90
+ from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
91
+ const row = rows[0];
92
+ const type = row?.subscribed ? input.webhooks(row.type) : void 0;
93
+ if (!row || !type?.unsubscribe) return;
94
+ const end = type.unsubscribe;
95
+ const outcome = await callPlugin(() => end({
96
+ ...triggerContext(input, row.input, signal),
97
+ state: row.store
98
+ }), input.redact);
99
+ if ("failed" in outcome) throw new Error(`The trigger's unsubscribe failed: ${outcome.failed}`);
100
+ };
74
101
  const stop = defineJob({
75
102
  name: "workflows.trigger-stop",
76
- run: async ({ trigger }, { db }) => {
103
+ timeout: "5 minutes",
104
+ run: async ({ trigger }, { db, signal }) => {
105
+ await unsubscribe(db, trigger, signal);
77
106
  await sql`delete from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
78
107
  }
79
108
  });
@@ -103,7 +132,8 @@ const triggerJobs = (input) => {
103
132
  jobs: [
104
133
  start,
105
134
  stop,
106
- readyCheck
135
+ readyCheck,
136
+ ...polls.jobs
107
137
  ],
108
138
  start,
109
139
  stop,
@@ -0,0 +1,101 @@
1
+ import { callPlugin, redactAlso, triggerContext } from "./plugin-call.js";
2
+ import { stateProblem } from "./poll-result.js";
3
+ import { hookMode } from "../hooks/hook-secrets.js";
4
+ import { hookPath } from "../hooks/hook-paths.js";
5
+ import { saveHook } from "../hooks/hook-store.js";
6
+ import { backoffPollAt, firstPollAt, nextPollAt, pollEvery } from "./poll-schedule.js";
7
+ import { setNextJob } from "./trigger-claim.js";
8
+ import { renewEvery } from "./poll-job.js";
9
+ import { sql } from "kysely";
10
+ //#region src/workflows/triggers/trigger-start.ts
11
+ const stateOf = async (db, schema, { id, generation }) => (await sql`select cursor, cursor is not null as has_cursor, store is not null as has_store, consecutive_failures from ${sql.id(schema, "triggers")}
12
+ where id = ${id} and generation = ${generation} and status in ('active', 'failing')`.execute(db)).rows[0];
13
+ /**
14
+ * Records a start the plugin refused: an expired sign-in waits for a new one; any other error marks the trigger failing
15
+ * and tries the start again after the backoff.
16
+ */
17
+ const startFailed = async (db, schema, trigger, outcome, failures, restart) => {
18
+ const expired = "expired" in outcome;
19
+ const error = JSON.stringify({
20
+ code: expired ? "link_expired" : "plugin_error",
21
+ message: expired ? outcome.expired : outcome.failed,
22
+ at: (/* @__PURE__ */ new Date()).toISOString()
23
+ });
24
+ const { rows } = await sql`update ${sql.id(schema, "triggers")} set status = ${expired ? "needs_sign_in" : "failing"}, consecutive_failures = consecutive_failures + 1,
25
+ last_error = ${error}::jsonb, updated_at = now() where id = ${trigger.id} and generation = ${trigger.generation} and status in ('active', 'failing') returning 1`.execute(db);
26
+ if (rows.length > 0 && !expired) await restart(db, backoffPollAt("1 minute", failures + 1, /* @__PURE__ */ new Date()));
27
+ };
28
+ const saveStart = (db, schema, trigger, column, value, nextAt, next) => db.transaction().execute(async (trx) => {
29
+ const { rows } = await sql`update ${sql.id(schema, "triggers")} set ${sql.ref(column)} = coalesce(${sql.ref(column)}, ${JSON.stringify(value ?? null)}::jsonb), status = 'active', consecutive_failures = 0, last_error = null, next_poll_at = ${nextAt ?? null}, updated_at = now()
30
+ where id = ${trigger.id} and generation = ${trigger.generation} and status in ('active', 'failing') returning 1`.execute(trx);
31
+ if (rows.length === 0 || !next) return;
32
+ await setNextJob(trx, schema, trigger.id, await next(trx));
33
+ });
34
+ /**
35
+ * Starts a polling or subscribing webhook trigger: the plugin's `start` or `subscribe` runs only for a trigger that has
36
+ * no cursor or subscription yet, so a new version on the same input carries on where the last one was; then its first
37
+ * poll or renewal is queued, whose new job id leaves any older one with nothing to do.
38
+ */
39
+ const triggerStarts = (input, jobs) => {
40
+ const { schema, redact } = input;
41
+ const startPolling = async (db, trigger, signal, restart) => {
42
+ const type = input.polling(trigger.type);
43
+ const state = type && await stateOf(db, schema, trigger);
44
+ if (!type || !state) return;
45
+ const first = await callPlugin(async () => firstPollAt(pollEvery(type, trigger.input), /* @__PURE__ */ new Date()), redact);
46
+ if (!("value" in first)) return startFailed(db, schema, trigger, first, state.consecutive_failures, restart);
47
+ const due = first.value;
48
+ let cursor = state.cursor;
49
+ if (!state.has_cursor) {
50
+ const outcome = await callPlugin(() => type.start(triggerContext(input, trigger.input, signal)), redact);
51
+ if (!("value" in outcome)) return startFailed(db, schema, trigger, outcome, state.consecutive_failures, restart);
52
+ const problem = stateProblem(outcome.value);
53
+ if (problem) return startFailed(db, schema, trigger, { failed: `The start ${problem}` }, state.consecutive_failures, restart);
54
+ cursor = outcome.value;
55
+ }
56
+ await saveStart(db, schema, trigger, "cursor", cursor, due, (trx) => jobs.poll.enqueue(trx, {
57
+ trigger: trigger.id,
58
+ generation: trigger.generation,
59
+ due: due.toISOString()
60
+ }, { runAt: due }));
61
+ };
62
+ const startWebhook = async (db, trigger, signal, restart) => {
63
+ const type = input.webhooks(trigger.type);
64
+ const state = type?.subscribe && await stateOf(db, schema, trigger);
65
+ const mode = hookMode(trigger.input);
66
+ if (!type?.subscribe || !state || state.has_store) return;
67
+ const origin = input.origin();
68
+ if (!origin || !mode) return startFailed(db, schema, trigger, { failed: origin ? "The trigger names an auth mode the app does not know" : "The app has no public address to subscribe with: give its auth a baseURL" }, state.consecutive_failures, restart);
69
+ const { key, secret } = await saveHook(db, schema, {
70
+ workflow: trigger.workflow_id,
71
+ tenant: trigger.tenant_id,
72
+ mode
73
+ });
74
+ const subscribe = type.subscribe;
75
+ const hidden = redactAlso([
76
+ key,
77
+ secret,
78
+ secret.replace(/^whsec_/, "")
79
+ ], redact);
80
+ const outcome = await callPlugin(() => subscribe({
81
+ ...triggerContext(input, trigger.input, signal),
82
+ url: `${origin}${hookPath(input.systemPath(), key)}`,
83
+ secret
84
+ }), hidden);
85
+ if (!("value" in outcome)) return startFailed(db, schema, trigger, outcome, state.consecutive_failures, restart);
86
+ const problem = stateProblem(outcome.value);
87
+ if (problem) return startFailed(db, schema, trigger, { failed: `The subscription ${problem}` }, state.consecutive_failures, restart);
88
+ const renewal = type.renew && nextPollAt(renewEvery(type), /* @__PURE__ */ new Date());
89
+ await saveStart(db, schema, trigger, "store", outcome.value, renewal, renewal && ((trx) => jobs.renew.enqueue(trx, {
90
+ trigger: trigger.id,
91
+ generation: trigger.generation,
92
+ due: renewal.toISOString()
93
+ }, { runAt: renewal })));
94
+ };
95
+ return {
96
+ startPolling,
97
+ startWebhook
98
+ };
99
+ };
100
+ //#endregion
101
+ export { triggerStarts };
@@ -4,8 +4,12 @@ import { workflowSecrets, workflowsSchema } from "./options.js";
4
4
  import { workflowSaveHook } from "./save-hook.js";
5
5
  import { workflowsRunner } from "./segment-job.js";
6
6
  import { runStartHook } from "./start-hook.js";
7
+ import { hookRoute } from "./hooks/hook-route.js";
8
+ import { issueRoute } from "./hooks/issue-route.js";
9
+ import { workflowDeleteHook } from "./triggers/delete-hook.js";
7
10
  import { triggerJobs } from "./triggers/trigger-jobs.js";
8
11
  import { workflowTypes } from "./workflow-types.js";
12
+ import { secretRedactor } from "@protobase/workflows";
9
13
  //#region src/workflows/workflow-engine.ts
10
14
  /**
11
15
  * What `createAdmin` needs for `workflows`: the hooks that check saves and start runs, the check of a run's input before
@@ -30,15 +34,44 @@ const workflowEngine = (options, deps, auth) => {
30
34
  tasks: (context) => types.tasks(input, context.mail),
31
35
  secrets
32
36
  });
37
+ const redactor = secretRedactor(Object.values(secrets));
38
+ const usable = (found) => (type) => types.check.triggers[type] ? found.get(type) : void 0;
33
39
  const triggers = triggerJobs({
34
- deps,
35
- schema,
36
- roles,
40
+ ...input,
37
41
  declared: (type) => types.ready.get(type),
38
- readyTypes: [...types.ready.keys()].filter((id) => types.check.triggers[id] !== void 0)
42
+ readyTypes: [...types.ready.keys()].filter((id) => types.check.triggers[id] !== void 0),
43
+ polling: usable(types.polling),
44
+ webhooks: usable(types.webhooks),
45
+ redact: redactor.text,
46
+ origin: () => auth?.origins[0],
47
+ systemPath: () => deps().systemPath
39
48
  });
49
+ const webhooks = usable(types.webhooks);
50
+ const issue = {
51
+ deps,
52
+ schema,
53
+ webhooks,
54
+ origin: () => auth?.origins[0]
55
+ };
40
56
  return {
41
- hooks: [workflowSaveHook(schema, types.check, triggers), runStartHook(runner, deps, schema)],
57
+ routes: {
58
+ hooks: () => hookRoute({
59
+ db: deps().db,
60
+ schema,
61
+ webhooks,
62
+ webhookTypes: () => [...types.webhooks.keys()].filter((id) => webhooks(id)),
63
+ files: () => deps().files,
64
+ report: (error) => deps().report(error),
65
+ redact: redactor.text
66
+ }),
67
+ issue: () => issueRoute(issue)
68
+ },
69
+ issue,
70
+ hooks: [
71
+ workflowSaveHook(schema, types.check, triggers),
72
+ workflowDeleteHook(schema, triggers),
73
+ runStartHook(runner, deps, schema)
74
+ ],
42
75
  writeChecks: [runInputCheck],
43
76
  jobs: [job, ...triggers.jobs],
44
77
  onChanges: triggers.onChanges,
@@ -2,7 +2,7 @@ import { Mailer } from "../mail/mail-message.js";
2
2
  import { ServicesInput } from "./plugin-services.js";
3
3
  import { ResourceModel } from "@protobase/schema";
4
4
  import { CheckOptions, LoadedPlugin, TaskHandlers } from "@protobase/workflows";
5
- import { Plugin, ReadyTrigger } from "@protobase/workflows/plugin";
5
+ import { Plugin, PollingTrigger, ReadyTrigger, WebhookTrigger } from "@protobase/workflows/plugin";
6
6
  //#region src/workflows/workflow-types.d.ts
7
7
  type Models = (name: string) => ResourceModel | undefined;
8
8
  /**
@@ -17,6 +17,9 @@ export declare const workflowTypes: (loaded?: readonly LoadedPlugin[], models?:
17
17
  checkWith: (against: Models) => CheckOptions;
18
18
  tasks: (input: ServicesInput, mail: Mailer | undefined) => TaskHandlers;
19
19
  ready: Map<string, ReadyTrigger>;
20
+ polling: Map<string, PollingTrigger>;
21
+ webhooks: Map<string, WebhookTrigger>;
22
+ available: (id: string) => boolean;
20
23
  };
21
24
  export type WorkflowTypes = ReturnType<typeof workflowTypes>;
22
25
  //#endregion
@@ -6,12 +6,13 @@ import { appPlugin } from "@protobase/workflow-plugins";
6
6
  //#region src/workflows/workflow-types.ts
7
7
  const unavailable = (type) => {
8
8
  if (type.connection) return "It signs in through a connection, which this app cannot make yet";
9
- if (type.kind === "polling" || type.kind === "webhook") return `This app does not run ${type.kind} triggers yet`;
10
9
  };
11
10
  const noConnection = (stepId, name) => ({ accessToken: () => Promise.reject(new ConnectionError("not_connected", `${stepId} signs in through "${name}", and this workflow has no sign-in for it`)) });
12
11
  const requiredInputs = (input) => Array.isArray(input.required) ? input.required : [];
12
+ /** The triggers of `plugins` of one kind, by id. */
13
+ const triggersOfKind = (plugins, kind) => new Map(plugins.flatMap((plugin) => Object.entries(plugin.triggers ?? {}).flatMap(([key, type]) => type.kind === kind ? [[`${plugin.name}:${key}`, type]] : [])));
13
14
  /** The ready triggers of `plugins`, by id. */
14
- const readyTriggers = (plugins) => new Map(plugins.flatMap((plugin) => Object.entries(plugin.triggers ?? {}).flatMap(([key, type]) => type.kind === "ready" ? [[`${plugin.name}:${key}`, type]] : [])));
15
+ const readyTriggers = (plugins) => triggersOfKind(plugins, "ready");
15
16
  /**
16
17
  * The trigger and step types of an app: the built-in plugin, `app`, then the app's own, all through the same loader and
17
18
  * interface. `types` is the list the MCP tools and the editor read; `check` is what a save checks a definition against,
@@ -25,6 +26,8 @@ const workflowTypes = (loaded = [], models = () => void 0) => {
25
26
  const types = typeList(plugins, unavailable);
26
27
  const available = new Set(types.filter((type) => type.kind !== "step" && type.available).map((type) => type.id));
27
28
  const ready = readyTriggers(plugins);
29
+ const polling = triggersOfKind(plugins, "polling");
30
+ const webhooks = triggersOfKind(plugins, "webhook");
28
31
  const steps = types.filter((type) => type.kind === "step" && type.available);
29
32
  const reasons = Object.fromEntries(types.flatMap((type) => type.reason === void 0 ? [] : [[type.id, type.reason]]));
30
33
  const own = triggerChecks(plugins);
@@ -56,7 +59,10 @@ const workflowTypes = (loaded = [], models = () => void 0) => {
56
59
  check,
57
60
  checkWith,
58
61
  tasks,
59
- ready
62
+ ready,
63
+ polling,
64
+ webhooks,
65
+ available: (id) => available.has(id)
60
66
  };
61
67
  };
62
68
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.68.5",
3
+ "version": "0.68.6",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,11 +44,11 @@
44
44
  "@better-auth/sso": "^1.7.12",
45
45
  "@livekit/protocol": "1.53.1",
46
46
  "@modelcontextprotocol/server": "^2.3.1",
47
- "@protobase/layout": "0.68.5",
48
- "@protobase/query": "0.68.5",
49
- "@protobase/schema": "0.68.5",
50
- "@protobase/workflow-plugins": "0.68.5",
51
- "@protobase/workflows": "0.68.5",
47
+ "@protobase/layout": "0.68.6",
48
+ "@protobase/query": "0.68.6",
49
+ "@protobase/schema": "0.68.6",
50
+ "@protobase/workflow-plugins": "0.68.6",
51
+ "@protobase/workflows": "0.68.6",
52
52
  "@react-email/render": "^2.1.0",
53
53
  "@react-pdf/primitives": "^4.4.0",
54
54
  "@react-pdf/renderer": "^4.9.0",