@protobase/server 0.68.5 → 0.69.0
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.
- package/dist/better-auth/auth-settings.d.ts +155 -28
- package/dist/better-auth/auth-settings.js +3 -2
- package/dist/better-auth/connections/connection-account-hooks.js +62 -0
- package/dist/better-auth/connections/connection-events.d.ts +11 -0
- package/dist/better-auth/connections/connection-events.js +16 -0
- package/dist/better-auth/connections/connection-oauth-provider.js +129 -0
- package/dist/better-auth/connections/connections-plugin.js +98 -0
- package/dist/better-auth/connections/declared-connections.d.ts +21 -0
- package/dist/better-auth/connections/declared-connections.js +54 -0
- package/dist/better-auth/connections/revoke-token.js +34 -0
- package/dist/better-auth/create-auth.d.ts +162 -17
- package/dist/better-auth/create-auth.js +59 -5
- package/dist/better-auth/refresh-refusals.js +43 -0
- package/dist/better-auth/staff-checks.js +1 -0
- package/dist/create-admin.js +11 -1
- package/dist/forms/rate-limit.js +33 -11
- package/dist/forms/routes.js +1 -1
- package/dist/functions/verify-signature.js +4 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -2
- package/dist/locale/en.js +3 -0
- package/dist/locale/nl.js +3 -0
- package/dist/mcp/mcp-tools.d.ts +2 -1
- package/dist/mcp/mcp-tools.js +4 -2
- package/dist/notifications/history-routes.js +1 -1
- package/dist/package.js +1 -1
- package/dist/personal-mail/linked-accounts.js +17 -13
- package/dist/types.d.ts +2 -0
- package/dist/webhooks/attempt.js +2 -2
- package/dist/webhooks/table/webhook-table.js +1 -1
- package/dist/workflows/connections/connection-accounts.js +18 -0
- package/dist/workflows/connections/connection-consent.js +49 -0
- package/dist/workflows/connections/connection-provider.js +10 -0
- package/dist/workflows/connections/connection-tokens.js +54 -0
- package/dist/workflows/connections/connection-triggers.js +37 -0
- package/dist/workflows/connections/connection-wiring.js +73 -0
- package/dist/workflows/connections/enabler-authority.js +35 -0
- package/dist/workflows/connections/enabler-mailbox.js +15 -0
- package/dist/workflows/connections/single-flight.js +17 -0
- package/dist/workflows/hooks/hook-auth.js +40 -0
- package/dist/workflows/hooks/hook-event.js +51 -0
- package/dist/workflows/hooks/hook-paths.d.ts +16 -0
- package/dist/workflows/hooks/hook-paths.js +32 -0
- package/dist/workflows/hooks/hook-route.js +99 -0
- package/dist/workflows/hooks/hook-runs.js +32 -0
- package/dist/workflows/hooks/hook-secrets.js +42 -0
- package/dist/workflows/hooks/hook-store.js +35 -0
- package/dist/workflows/hooks/hooks-sql.js +20 -0
- package/dist/workflows/hooks/issue-body.js +21 -0
- package/dist/workflows/hooks/issue-route.js +23 -0
- package/dist/workflows/hooks/issue-webhook.js +46 -0
- package/dist/workflows/hooks/read-body.js +32 -0
- package/dist/workflows/may-update-workflow.js +26 -0
- package/dist/workflows/mcp/webhook-tools.d.ts +9 -0
- package/dist/workflows/mcp/webhook-tools.js +103 -0
- package/dist/workflows/mcp/workflow-instructions.js +1 -1
- package/dist/workflows/mcp/workflow-read-tools.d.ts +3 -0
- package/dist/workflows/plugin-records.js +1 -1
- package/dist/workflows/plugin-services.js +1 -1
- package/dist/workflows/ready/ready-start.js +1 -1
- package/dist/workflows/run-files.js +7 -1
- package/dist/workflows/save-hook.js +7 -2
- package/dist/workflows/schema-sql.js +17 -3
- package/dist/workflows/schema.js +3 -1
- package/dist/workflows/start-workflow.d.ts +3 -1
- package/dist/workflows/start-workflow.js +10 -4
- package/dist/workflows/triggers/delete-hook.js +15 -0
- package/dist/workflows/triggers/plugin-call.js +45 -0
- package/dist/workflows/triggers/poll-job.js +120 -0
- package/dist/workflows/triggers/poll-result.js +32 -0
- package/dist/workflows/triggers/poll-schedule.js +32 -0
- package/dist/workflows/triggers/trigger-claim.js +81 -0
- package/dist/workflows/triggers/trigger-jobs.js +53 -7
- package/dist/workflows/triggers/trigger-start.js +112 -0
- package/dist/workflows/triggers/trigger-sync.js +15 -10
- package/dist/workflows/workflow-engine.js +57 -9
- package/dist/workflows/workflow-types.d.ts +11 -4
- package/dist/workflows/workflow-types.js +21 -15
- package/dist/write-shared.js +1 -0
- package/package.json +6 -6
- package/dist/better-auth/end-replaced-session.d.ts +0 -17
|
@@ -3,8 +3,8 @@ import { tenantScope } from "../tenant.js";
|
|
|
3
3
|
import { issueTicket } from "../files/ticket.js";
|
|
4
4
|
import { runBatch } from "../batch/run-batch.js";
|
|
5
5
|
import { assistantRecords, toolEntry } from "../assistant/records.js";
|
|
6
|
-
import { effectsStore } from "./effects-store.js";
|
|
7
6
|
import { runFileRef } from "./run-files.js";
|
|
7
|
+
import { effectsStore } from "./effects-store.js";
|
|
8
8
|
import { sql } from "kysely";
|
|
9
9
|
import { StepError } from "@protobase/workflows";
|
|
10
10
|
//#region src/workflows/plugin-records.ts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { pluginFetch } from "./plugin-fetch.js";
|
|
1
2
|
import { readRunFile, storeRunFile } from "./run-files.js";
|
|
2
3
|
import { pluginRecords } from "./plugin-records.js";
|
|
3
|
-
import { pluginFetch } from "./plugin-fetch.js";
|
|
4
4
|
//#region src/workflows/plugin-services.ts
|
|
5
5
|
/** The plugin context's `files`: those of the run alone, in the app's `private` file provider. */
|
|
6
6
|
const runFiles = (files, runId) => ({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HttpProblem } from "../../problem.js";
|
|
2
2
|
import { assistantRecords } from "../../assistant/records.js";
|
|
3
|
-
import { saverSession } from "../plugin-records.js";
|
|
4
3
|
import { startWorkflow } from "../start-workflow.js";
|
|
4
|
+
import { saverSession } from "../plugin-records.js";
|
|
5
5
|
import { triggerFailed, triggerRecovered } from "../triggers/trigger-row.js";
|
|
6
6
|
import { encodeKey } from "@protobase/schema/internal";
|
|
7
7
|
import { checkFilter, evaluateFilter, parseFilter } from "@protobase/schema";
|
|
@@ -62,5 +62,11 @@ const readRunFile = async (files, runId, value, field) => {
|
|
|
62
62
|
bytes
|
|
63
63
|
};
|
|
64
64
|
};
|
|
65
|
+
/** Removes a file a run stored, as when the run it was kept for turned out to be started already. */
|
|
66
|
+
const removeRunFile = async (files, value) => {
|
|
67
|
+
const ref = parseUri(value.uri);
|
|
68
|
+
if (!ref) throw new Error(`${value.uri} is not a file URI`);
|
|
69
|
+
await runStore(files).delete(ref.path);
|
|
70
|
+
};
|
|
65
71
|
//#endregion
|
|
66
|
-
export { isFileValue, readRunFile, runFileLimit, runFileRef, storeRunFile };
|
|
72
|
+
export { isFileValue, readRunFile, removeRunFile, runFileLimit, runFileRef, storeRunFile };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { badRequest } from "../problem.js";
|
|
2
2
|
import { syncTrigger } from "./triggers/trigger-sync.js";
|
|
3
|
+
import { saveConsent } from "./connections/connection-consent.js";
|
|
3
4
|
import { sql } from "kysely";
|
|
4
5
|
import { checkDefinition, problemText } from "@protobase/workflows";
|
|
5
6
|
//#region src/workflows/save-hook.ts
|
|
@@ -11,7 +12,7 @@ import { checkDefinition, problemText } from "@protobase/workflows";
|
|
|
11
12
|
* `definition` field's `errors` with the state and field in their `params`, for a form to show each on its step; the
|
|
12
13
|
* write rolls back.
|
|
13
14
|
*/
|
|
14
|
-
const workflowSaveHook = (schema, types, jobs) => async (event, trx) => {
|
|
15
|
+
const workflowSaveHook = (schema, types, jobs, consent) => async (event, trx) => {
|
|
15
16
|
if (event.resource.name !== "workflows" || !event.after) return;
|
|
16
17
|
if (event.operation !== "create" && event.operation !== "update") return;
|
|
17
18
|
const changed = event.operation === "create" || event.after.version !== event.before?.version;
|
|
@@ -35,7 +36,11 @@ const workflowSaveHook = (schema, types, jobs) => async (event, trx) => {
|
|
|
35
36
|
}
|
|
36
37
|
if (changed) await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
|
|
37
38
|
select w.id, w.version, w.definition, ${String(event.user.id)}, w.tenant_id from ${sql.id(schema, "workflows")} w where w.id = ${String(event.after.id)}`.execute(trx);
|
|
38
|
-
|
|
39
|
+
const id = String(event.after.id);
|
|
40
|
+
const enabled = event.after.enabled_version;
|
|
41
|
+
const enabledBy = enabled === null ? null : await consent(event.session, event.after.definition);
|
|
42
|
+
await saveConsent(trx, schema, id, enabled, enabledBy);
|
|
43
|
+
await syncTrigger(trx, schema, jobs, event.after, types.connections);
|
|
39
44
|
};
|
|
40
45
|
//#endregion
|
|
41
46
|
export { workflowSaveHook };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
2
|
/** The comment the schema's function carries, which the startup check reads. */
|
|
3
|
-
const workflowsComment = `Protobase workflows
|
|
3
|
+
const workflowsComment = `Protobase workflows 8`;
|
|
4
4
|
const commentVersion = (comment) => {
|
|
5
5
|
const match = comment?.match(/^Protobase workflows (\d+)$/);
|
|
6
6
|
return match ? Number(match[1]) : void 0;
|
|
@@ -83,7 +83,9 @@ const triggerStateBytes = 65536;
|
|
|
83
83
|
/**
|
|
84
84
|
* Which version of a workflow is on (`enabled_version`, null when off), and one row per trigger of an enabled version:
|
|
85
85
|
* its type and input, and the state its kind keeps. A change raises `generation`, so a job queued for an older one
|
|
86
|
-
* ends without acting. A
|
|
86
|
+
* ends without acting. A poll or renewal acts only as the job `poll_job_id` names, on the try `poll_attempt` claimed,
|
|
87
|
+
* so a try that ran past its timeout writes nothing once its retry claimed the trigger. A deleted workflow leaves its
|
|
88
|
+
* trigger without a workflow, which its delete hook stops. A trigger being stopped stays `stopping` until its stop job removes it. Written whole each time:
|
|
87
89
|
* every statement is idempotent.
|
|
88
90
|
*/
|
|
89
91
|
const triggersSql = (schema) => {
|
|
@@ -94,9 +96,11 @@ do $$ begin
|
|
|
94
96
|
alter table ${s}."workflows" add constraint "workflows_enabled_version" foreign key ("id", "enabled_version") references ${s}."versions" ("workflow_id", "version") deferrable initially deferred;
|
|
95
97
|
end if;
|
|
96
98
|
end $$;
|
|
99
|
+
-- Who turned the version on, signed in as themselves: the consent its runs sign in through their connections with.
|
|
100
|
+
alter table ${s}."versions" add column if not exists "enabled_by" text;
|
|
97
101
|
create table if not exists ${s}."triggers" (
|
|
98
102
|
"id" uuid primary key default gen_random_uuid(),
|
|
99
|
-
"workflow_id" uuid
|
|
103
|
+
"workflow_id" uuid references ${s}."workflows" ("id") on delete set null,
|
|
100
104
|
"version" integer not null,
|
|
101
105
|
"tenant_id" text,
|
|
102
106
|
"type" text not null,
|
|
@@ -107,12 +111,22 @@ create table if not exists ${s}."triggers" (
|
|
|
107
111
|
"cursor" jsonb check (octet_length("cursor"::text) <= ${triggerStateBytes}),
|
|
108
112
|
"store" jsonb check (octet_length("store"::text) <= ${triggerStateBytes}),
|
|
109
113
|
"poll_job_id" text,
|
|
114
|
+
"poll_attempt" integer,
|
|
110
115
|
"consecutive_failures" integer not null,
|
|
111
116
|
"last_error" jsonb,
|
|
112
117
|
"next_poll_at" timestamptz,
|
|
113
118
|
"created_at" timestamptz not null default now(),
|
|
114
119
|
"updated_at" timestamptz not null default now()
|
|
115
120
|
);
|
|
121
|
+
alter table ${s}."triggers" add column if not exists "poll_attempt" integer;
|
|
122
|
+
-- A deleted workflow's trigger stays, without its workflow, until its stop job unsubscribed it at its provider.
|
|
123
|
+
alter table ${s}."triggers" alter column "workflow_id" drop not null;
|
|
124
|
+
do $$ begin
|
|
125
|
+
if exists (select 1 from pg_constraint where conname = 'triggers_workflow_id_fkey' and confdeltype = 'c' and connamespace = ${`'${schema.replaceAll("'", "''")}'`}::regnamespace) then
|
|
126
|
+
alter table ${s}."triggers" drop constraint "triggers_workflow_id_fkey";
|
|
127
|
+
alter table ${s}."triggers" add constraint "triggers_workflow_id_fkey" foreign key ("workflow_id") references ${s}."workflows" ("id") on delete set null;
|
|
128
|
+
end if;
|
|
129
|
+
end $$;
|
|
116
130
|
create unique index if not exists "triggers_workflow" on ${s}."triggers" ("workflow_id") where "status" <> 'stopping';
|
|
117
131
|
create index if not exists "triggers_workflow_id" on ${s}."triggers" ("workflow_id");
|
|
118
132
|
create index if not exists "triggers_updated_at" on ${s}."triggers" ("updated_at");
|
package/dist/workflows/schema.js
CHANGED
|
@@ -2,6 +2,7 @@ import { quoteIdent } from "../changes/capture-sql.js";
|
|
|
2
2
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
3
3
|
import { workflowsSchema } from "./options.js";
|
|
4
4
|
import { commentVersion, effectsSql, functionsSql, indexesSql, tablesSql, triggersSql, workflowsTriggers } from "./schema-sql.js";
|
|
5
|
+
import { hooksSql } from "./hooks/hooks-sql.js";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
//#region src/workflows/schema.ts
|
|
7
8
|
const readWorkflowsSchema = async (db, schema) => {
|
|
@@ -15,7 +16,7 @@ const readWorkflowsSchema = async (db, schema) => {
|
|
|
15
16
|
const row = rows[0];
|
|
16
17
|
return {
|
|
17
18
|
tables: row.found,
|
|
18
|
-
current: row.found && row.trigger_rows && commentVersion(row.comment) ===
|
|
19
|
+
current: row.found && row.trigger_rows && commentVersion(row.comment) === 8 && row.triggers === workflowsTriggers.length
|
|
19
20
|
};
|
|
20
21
|
};
|
|
21
22
|
/**
|
|
@@ -31,6 +32,7 @@ const workflowsMigration = async (db, options = {}) => {
|
|
|
31
32
|
...found.tables ? [] : [tablesSql(schema)],
|
|
32
33
|
triggersSql(schema),
|
|
33
34
|
effectsSql(schema),
|
|
35
|
+
hooksSql(schema),
|
|
34
36
|
indexesSql(schema),
|
|
35
37
|
functionsSql(schema)
|
|
36
38
|
].join(";\n\n")};`;
|
|
@@ -19,10 +19,12 @@ export type StartWorkflowOptions = {
|
|
|
19
19
|
schema?: string;
|
|
20
20
|
/** The version to run, as a trigger runs the enabled one; default the workflow's current version. */
|
|
21
21
|
version?: number;
|
|
22
|
+
/** The run's id, when the caller kept something under it first, such as a webhook's large body; default a new one. */
|
|
23
|
+
id?: string;
|
|
22
24
|
};
|
|
23
25
|
/**
|
|
24
26
|
* Starts a run of a workflow from the app's own code, such as a job or a write hook, and returns the run's id. In
|
|
25
27
|
* `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
|
|
26
28
|
*/
|
|
27
|
-
export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version }: StartWorkflowOptions) => Promise<string>;
|
|
29
|
+
export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version, id: runId }: StartWorkflowOptions) => Promise<string>;
|
|
28
30
|
//#endregion
|
|
@@ -7,13 +7,13 @@ import { sql } from "kysely";
|
|
|
7
7
|
* Starts a run of a workflow from the app's own code, such as a job or a write hook, and returns the run's id. In
|
|
8
8
|
* `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
|
|
9
9
|
*/
|
|
10
|
-
const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version }) => {
|
|
10
|
+
const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version, id: runId }) => {
|
|
11
11
|
checkRunInput(input);
|
|
12
12
|
const job = currentJobs().definition(segmentJobName);
|
|
13
13
|
if (!job) throw new Error("Workflows are not running in this app: add `workflows: {}` to protobase.config.ts");
|
|
14
14
|
const runs = sql.id(schema, "runs");
|
|
15
|
-
const { rows } = await sql`insert into ${runs} (workflow_id, version, input, tenant_id, started_by, key)
|
|
16
|
-
values (${workflow}, ${version ?? null}, ${input === void 0 ? null : JSON.stringify(input)}::jsonb, ${tenant === void 0 ? null : String(tenant)}, ${startedBy ?? null}, ${key ?? null})
|
|
15
|
+
const { rows } = await sql`insert into ${runs} (id, workflow_id, version, input, tenant_id, started_by, key)
|
|
16
|
+
values (${runId === void 0 ? sql`gen_random_uuid()` : sql`${runId}::uuid`}, ${workflow}, ${version ?? null}, ${input === void 0 ? null : JSON.stringify(input)}::jsonb, ${tenant === void 0 ? null : String(tenant)}, ${startedBy ?? null}, ${key ?? null})
|
|
17
17
|
on conflict (workflow_id, key) do nothing returning id`.execute(db);
|
|
18
18
|
if (rows.length === 0) {
|
|
19
19
|
const existing = (await sql`select id, status from ${runs} where workflow_id = ${workflow} and key = ${key}`.execute(db)).rows[0];
|
|
@@ -24,5 +24,11 @@ const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, sche
|
|
|
24
24
|
await job.enqueue(db, { run: id }, { key: id });
|
|
25
25
|
return id;
|
|
26
26
|
};
|
|
27
|
+
/** Queues the first segment of runs a trigger inserted itself, in `db`'s transaction. */
|
|
28
|
+
const queueRuns = async (db, ids) => {
|
|
29
|
+
const job = currentJobs().definition(segmentJobName);
|
|
30
|
+
if (!job) throw new Error("Workflows are not running in this app: add `workflows: {}` to protobase.config.ts");
|
|
31
|
+
for (const id of ids) await job.enqueue(db, { run: id }, { key: id });
|
|
32
|
+
};
|
|
27
33
|
//#endregion
|
|
28
|
-
export { startWorkflow };
|
|
34
|
+
export { queueRuns, startWorkflow };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/workflows/triggers/delete-hook.ts
|
|
3
|
+
/**
|
|
4
|
+
* As a workflow is deleted, in the delete's transaction: its trigger, which the delete left without a workflow, stops at
|
|
5
|
+
* once, so no poll or call starts a run of it, and its stop job, queued in the same write, ends any subscription at its
|
|
6
|
+
* provider and then removes it. The delete removed its webhook URL already.
|
|
7
|
+
*/
|
|
8
|
+
const workflowDeleteHook = (schema, jobs) => async (event, trx) => {
|
|
9
|
+
if (event.resource.name !== "workflows" || event.operation !== "delete") return;
|
|
10
|
+
const { rows } = await sql`update ${sql.id(schema, "triggers")} set status = 'stopping', generation = generation + 1, updated_at = now()
|
|
11
|
+
where workflow_id is null and status <> 'stopping' returning id`.execute(trx);
|
|
12
|
+
for (const { id } of rows) await jobs.stop.enqueue(trx, { trigger: id });
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { workflowDeleteHook };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { pluginFetch } from "../plugin-fetch.js";
|
|
2
|
+
import { ConnectionError } from "@protobase/workflows/plugin";
|
|
3
|
+
//#region src/workflows/triggers/plugin-call.ts
|
|
4
|
+
/** The longest a plugin's error message is kept on its trigger, which the workflow's readers see. */
|
|
5
|
+
const messageLength = 1e3;
|
|
6
|
+
/**
|
|
7
|
+
* Runs a plugin's trigger code: any error it throws is the plugin's, which the trigger records rather than the job
|
|
8
|
+
* retrying it; a `ConnectionError('link_expired')` is a sign-in to renew. The message goes through `redact` and is cut
|
|
9
|
+
* to 1 000 characters, since the trigger's readers see it.
|
|
10
|
+
*/
|
|
11
|
+
const callPlugin = (work, redact) => Promise.resolve().then(work).then((value) => ({ value }), (error) => {
|
|
12
|
+
const message = redact(error instanceof Error ? error.message : String(error)).slice(0, messageLength);
|
|
13
|
+
if (error instanceof ConnectionError && error.code === "link_expired") return { expired: message };
|
|
14
|
+
if (error instanceof ConnectionError && error.code === "not_connected") return {
|
|
15
|
+
failed: message,
|
|
16
|
+
unconnected: true
|
|
17
|
+
};
|
|
18
|
+
return { failed: message };
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* `redact` after blanking out `values` that only this call knew, such as a webhook's URL key and secret, as written and
|
|
22
|
+
* URL-encoded, so a message that quotes them keeps neither.
|
|
23
|
+
*/
|
|
24
|
+
const redactAlso = (values, redact) => {
|
|
25
|
+
const blanked = [...new Set(values.flatMap((value) => [value, encodeURIComponent(value)]))].filter((value) => value.length > 0);
|
|
26
|
+
return (text) => redact(blanked.reduce((kept, value) => kept.replaceAll(value, "[redacted]"), text));
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* What one call of a trigger's hook gets: its input, its connection, the guarded `fetch`, and the job's signal, which
|
|
30
|
+
* aborts at its timeout; and `redact` for its outcome, which also blanks out each token its connection gave this call.
|
|
31
|
+
*/
|
|
32
|
+
const triggerCall = ({ allowPrivateAddresses, connect }, trigger, signal, redact) => {
|
|
33
|
+
const issued = [];
|
|
34
|
+
return {
|
|
35
|
+
context: {
|
|
36
|
+
input: trigger.input,
|
|
37
|
+
connection: connect?.(trigger, (token) => void issued.push(token)),
|
|
38
|
+
fetch: pluginFetch({ allowPrivateAddresses }),
|
|
39
|
+
signal
|
|
40
|
+
},
|
|
41
|
+
redact: (text) => redactAlso(issued, redact)(text)
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { callPlugin, redactAlso, triggerCall };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { defineJob } from "../../jobs/define-job.js";
|
|
2
|
+
import { callPlugin, triggerCall } 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 call = triggerCall(input, claimed, signal, redact);
|
|
50
|
+
const outcome = await callPlugin(() => type.poll({
|
|
51
|
+
...call.context,
|
|
52
|
+
cursor: claimed.cursor,
|
|
53
|
+
store: claimed.store,
|
|
54
|
+
limit,
|
|
55
|
+
due: new Date(due)
|
|
56
|
+
}), call.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 call = triggerCall(input, claimed, signal, redact);
|
|
94
|
+
const outcome = await callPlugin(() => renewal({
|
|
95
|
+
...call.context,
|
|
96
|
+
state: claimed.store
|
|
97
|
+
}), call.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, triggerCall } 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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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,40 @@ const triggerJobs = (input) => {
|
|
|
71
85
|
});
|
|
72
86
|
}
|
|
73
87
|
});
|
|
88
|
+
/**
|
|
89
|
+
* Ends a stopped webhook trigger's subscription at its provider: whether nothing is left there, so the trigger can go.
|
|
90
|
+
* Any error is tried again, as the job's, until the provider ends it. A sign-in the unsubscribe can no longer use, as
|
|
91
|
+
* once the workflow was turned off and so its connection with it, or once the provider refused it, cannot end it:
|
|
92
|
+
* the trigger then stays `stopping` with that error, as the record of a subscription the provider may still hold.
|
|
93
|
+
*/
|
|
94
|
+
const unsubscribe = async (db, trigger, signal) => {
|
|
95
|
+
const { rows } = await sql`select type, input, workflow_id, version, store, store is not null as subscribed
|
|
96
|
+
from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
|
|
97
|
+
const row = rows[0];
|
|
98
|
+
const type = row?.subscribed ? input.webhooks(row.type) : void 0;
|
|
99
|
+
if (!row || !type?.unsubscribe) return true;
|
|
100
|
+
const end = type.unsubscribe;
|
|
101
|
+
const call = triggerCall(input, row, signal, input.redact);
|
|
102
|
+
const outcome = await callPlugin(() => end({
|
|
103
|
+
...call.context,
|
|
104
|
+
state: row.store
|
|
105
|
+
}), call.redact);
|
|
106
|
+
if ("value" in outcome) return true;
|
|
107
|
+
if ("failed" in outcome && !outcome.unconnected) throw new Error(`The trigger's unsubscribe failed: ${outcome.failed}`);
|
|
108
|
+
const reason = "expired" in outcome ? outcome.expired : outcome.failed;
|
|
109
|
+
const error = JSON.stringify({
|
|
110
|
+
code: "expired" in outcome ? "link_expired" : "not_connected",
|
|
111
|
+
message: `The subscription could not be ended at the provider: ${reason}`,
|
|
112
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
113
|
+
});
|
|
114
|
+
await sql`update ${sql.id(schema, "triggers")} set last_error = ${error}::jsonb, updated_at = now() where id = ${trigger} and status = 'stopping'`.execute(db);
|
|
115
|
+
return false;
|
|
116
|
+
};
|
|
74
117
|
const stop = defineJob({
|
|
75
118
|
name: "workflows.trigger-stop",
|
|
76
|
-
|
|
119
|
+
timeout: "5 minutes",
|
|
120
|
+
run: async ({ trigger }, { db, signal }) => {
|
|
121
|
+
if (!await unsubscribe(db, trigger, signal)) return;
|
|
77
122
|
await sql`delete from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
|
|
78
123
|
}
|
|
79
124
|
});
|
|
@@ -103,7 +148,8 @@ const triggerJobs = (input) => {
|
|
|
103
148
|
jobs: [
|
|
104
149
|
start,
|
|
105
150
|
stop,
|
|
106
|
-
readyCheck
|
|
151
|
+
readyCheck,
|
|
152
|
+
...polls.jobs
|
|
107
153
|
],
|
|
108
154
|
start,
|
|
109
155
|
stop,
|