@protobase/server 0.67.11 → 0.68.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/batch/run-batch.d.ts +1 -0
- package/dist/batch/run-batch.js +6 -4
- package/dist/create-admin.js +7 -4
- package/dist/idempotency.d.ts +1 -0
- package/dist/idempotency.js +12 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/mcp/mcp-tools.d.ts +4 -2
- package/dist/mcp/mcp-tools.js +9 -3
- package/dist/package.js +1 -1
- package/dist/webhooks/attempt.js +2 -2
- package/dist/webhooks/table/address-check.js +1 -1
- package/dist/webhooks/table/webhook-table.js +1 -1
- package/dist/webhooks/webhooks.js +2 -1
- package/dist/workflows/effects-store.js +18 -0
- package/dist/workflows/input-limits.js +4 -2
- package/dist/workflows/mcp/workflow-instructions.js +1 -1
- package/dist/workflows/mcp/workflow-read-tools.d.ts +11 -1
- package/dist/workflows/mcp/workflow-read-tools.js +16 -19
- package/dist/workflows/mcp/workflow-write-tools.js +12 -3
- package/dist/workflows/options.d.ts +6 -0
- package/dist/workflows/plugin-fetch.js +40 -0
- package/dist/workflows/plugin-records.d.ts +10 -0
- package/dist/workflows/plugin-records.js +120 -0
- package/dist/workflows/plugin-services.d.ts +13 -0
- package/dist/workflows/plugin-services.js +32 -0
- package/dist/workflows/ready/ready-check.js +62 -0
- package/dist/workflows/ready/ready-start.js +104 -0
- package/dist/workflows/ready/ready-when.js +101 -0
- package/dist/workflows/rerun.js +36 -0
- package/dist/workflows/run-files.js +3 -7
- package/dist/workflows/save-hook.js +15 -13
- package/dist/workflows/schema-sql.js +72 -4
- package/dist/workflows/schema.d.ts +2 -2
- package/dist/workflows/schema.js +6 -2
- package/dist/workflows/start-hook.js +9 -3
- package/dist/workflows/start-workflow.d.ts +3 -1
- package/dist/workflows/start-workflow.js +3 -3
- package/dist/workflows/triggers/trigger-jobs.js +114 -0
- package/dist/workflows/triggers/trigger-row.js +36 -0
- package/dist/workflows/triggers/trigger-sync.js +48 -0
- package/dist/workflows/workflow-engine.js +17 -5
- package/dist/workflows/workflow-types-route.js +22 -0
- package/dist/workflows/workflow-types.d.ts +22 -0
- package/dist/workflows/workflow-types.js +63 -0
- package/dist/workflows/workflows-module.d.ts +27 -1
- package/package.json +6 -5
- package/dist/workflows/http-step.js +0 -103
- package/dist/workflows/mail-step.js +0 -58
- package/dist/workflows/mcp/type-catalog.js +0 -144
- package/dist/workflows/piece-step.js +0 -11
- package/dist/workflows/records-step.js +0 -114
- package/dist/workflows/step-types.js +0 -27
|
@@ -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 6`;
|
|
4
4
|
const commentVersion = (comment) => {
|
|
5
5
|
const match = comment?.match(/^Protobase workflows (\d+)$/);
|
|
6
6
|
return match ? Number(match[1]) : void 0;
|
|
@@ -46,6 +46,7 @@ create table if not exists ${s}."runs" (
|
|
|
46
46
|
"cause" text,
|
|
47
47
|
"resume_at" timestamptz,
|
|
48
48
|
"started_by" text,
|
|
49
|
+
"rerun_of" uuid references ${s}."runs" ("id") on delete set null,
|
|
49
50
|
"tenant_id" text,
|
|
50
51
|
"created_at" timestamptz not null default now(),
|
|
51
52
|
"finished_at" timestamptz,
|
|
@@ -77,6 +78,61 @@ create table if not exists ${s}."steps" (
|
|
|
77
78
|
unique ("run_id", "scope", "seq")
|
|
78
79
|
)`;
|
|
79
80
|
};
|
|
81
|
+
/** The most bytes a trigger's cursor or store holds as JSON, so a plugin cannot grow them without limit. */
|
|
82
|
+
const triggerStateBytes = 65536;
|
|
83
|
+
/**
|
|
84
|
+
* Which version of a workflow is on (`enabled_version`, null when off), and one row per trigger of an enabled version:
|
|
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 trigger being stopped stays `stopping` until its stop job removes it. Written whole each time:
|
|
87
|
+
* every statement is idempotent.
|
|
88
|
+
*/
|
|
89
|
+
const triggersSql = (schema) => {
|
|
90
|
+
const s = quoteIdent(schema);
|
|
91
|
+
return `alter table ${s}."workflows" add column if not exists "enabled_version" integer;
|
|
92
|
+
do $$ begin
|
|
93
|
+
if not exists (select 1 from pg_constraint where conname = 'workflows_enabled_version' and connamespace = ${`'${schema.replaceAll("'", "''")}'`}::regnamespace) then
|
|
94
|
+
alter table ${s}."workflows" add constraint "workflows_enabled_version" foreign key ("id", "enabled_version") references ${s}."versions" ("workflow_id", "version") deferrable initially deferred;
|
|
95
|
+
end if;
|
|
96
|
+
end $$;
|
|
97
|
+
create table if not exists ${s}."triggers" (
|
|
98
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
99
|
+
"workflow_id" uuid not null references ${s}."workflows" ("id") on delete cascade,
|
|
100
|
+
"version" integer not null,
|
|
101
|
+
"tenant_id" text,
|
|
102
|
+
"type" text not null,
|
|
103
|
+
"input" jsonb not null,
|
|
104
|
+
"connection_id" text,
|
|
105
|
+
"status" text not null check ("status" in ('active', 'failing', 'needs_sign_in', 'stopping')),
|
|
106
|
+
"generation" integer not null,
|
|
107
|
+
"cursor" jsonb check (octet_length("cursor"::text) <= ${triggerStateBytes}),
|
|
108
|
+
"store" jsonb check (octet_length("store"::text) <= ${triggerStateBytes}),
|
|
109
|
+
"poll_job_id" text,
|
|
110
|
+
"consecutive_failures" integer not null,
|
|
111
|
+
"last_error" jsonb,
|
|
112
|
+
"next_poll_at" timestamptz,
|
|
113
|
+
"created_at" timestamptz not null default now(),
|
|
114
|
+
"updated_at" timestamptz not null default now()
|
|
115
|
+
);
|
|
116
|
+
create unique index if not exists "triggers_workflow" on ${s}."triggers" ("workflow_id") where "status" <> 'stopping';
|
|
117
|
+
create index if not exists "triggers_workflow_id" on ${s}."triggers" ("workflow_id");
|
|
118
|
+
create index if not exists "triggers_updated_at" on ${s}."triggers" ("updated_at");
|
|
119
|
+
create index if not exists "triggers_status_type" on ${s}."triggers" ("status", "type");
|
|
120
|
+
create index if not exists "triggers_connection" on ${s}."triggers" ("connection_id");
|
|
121
|
+
create index if not exists "triggers_tenant" on ${s}."triggers" ("tenant_id")`;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* What a step that changes records kept under its idempotency key, written with its records, so a retry of the step
|
|
125
|
+
* after they committed writes nothing and returns them. Its rows go with their run.
|
|
126
|
+
*/
|
|
127
|
+
const effectsSql = (schema) => {
|
|
128
|
+
const s = quoteIdent(schema);
|
|
129
|
+
return `create table if not exists ${s}."effects" (
|
|
130
|
+
"run_id" uuid not null references ${s}."runs" ("id") on delete cascade,
|
|
131
|
+
"key" text not null,
|
|
132
|
+
"result" jsonb,
|
|
133
|
+
primary key ("run_id", "key")
|
|
134
|
+
)`;
|
|
135
|
+
};
|
|
80
136
|
/** An index for each filter and sort the API takes, and pg_trgm ones for the search over names. */
|
|
81
137
|
const indexesSql = (schema) => {
|
|
82
138
|
const s = quoteIdent(schema);
|
|
@@ -88,6 +144,8 @@ create index if not exists "workflows_name_search" on ${s}."workflows" using gin
|
|
|
88
144
|
create index if not exists "versions_tenant" on ${s}."versions" ("tenant_id");
|
|
89
145
|
alter table ${s}."runs" add column if not exists "key" text;
|
|
90
146
|
create unique index if not exists "runs_key" on ${s}."runs" ("workflow_id", "key");
|
|
147
|
+
alter table ${s}."runs" add column if not exists "rerun_of" uuid references ${s}."runs" ("id") on delete set null;
|
|
148
|
+
create index if not exists "runs_rerun_of" on ${s}."runs" ("rerun_of");
|
|
91
149
|
create index if not exists "runs_workflow" on ${s}."runs" ("workflow_id", "created_at");
|
|
92
150
|
create index if not exists "runs_tenant" on ${s}."runs" ("tenant_id");
|
|
93
151
|
create index if not exists "runs_status" on ${s}."runs" ("status");
|
|
@@ -102,7 +160,7 @@ const workflowsTriggers = ["workflow_version", "run_version"];
|
|
|
102
160
|
* A workflow numbers its next version as a save changes its definition, in the row the write returns, so its ETag is
|
|
103
161
|
* the saved row's; the save hook keeps that version's definition and who saved it. A run takes its workflow's current
|
|
104
162
|
* version as it is created, and refuses a workflow of another tenant as one that is not there, and one that has no
|
|
105
|
-
* saved version.
|
|
163
|
+
* saved version. A re-run takes the input of the run it re-runs, which the start hook checks.
|
|
106
164
|
*/
|
|
107
165
|
const functionsSql = (schema) => {
|
|
108
166
|
const s = quoteIdent(schema);
|
|
@@ -115,6 +173,10 @@ begin
|
|
|
115
173
|
end if;
|
|
116
174
|
-- The write holds the row, so a save in another transaction numbers its version after this one.
|
|
117
175
|
new.version := case when new.definition is distinct from old.definition then coalesce(old.version, 0) + 1 else old.version end;
|
|
176
|
+
-- An enabled workflow stays enabled on its new version; the save hook moves its trigger along.
|
|
177
|
+
if new.version is distinct from old.version and old.enabled_version is not null and new.enabled_version is not distinct from old.enabled_version then
|
|
178
|
+
new.enabled_version := new.version;
|
|
179
|
+
end if;
|
|
118
180
|
new.updated_at := now();
|
|
119
181
|
return new;
|
|
120
182
|
end
|
|
@@ -135,7 +197,13 @@ begin
|
|
|
135
197
|
if found.version is null then
|
|
136
198
|
raise exception 'Workflow % has no saved version', new.workflow_id using errcode = '23503';
|
|
137
199
|
end if;
|
|
138
|
-
|
|
200
|
+
-- A trigger starts a run of the version that is enabled, which it gives; any other start runs the current one.
|
|
201
|
+
if new.version is null then
|
|
202
|
+
new.version := found.version;
|
|
203
|
+
end if;
|
|
204
|
+
if new.rerun_of is not null then
|
|
205
|
+
select r.input into new.input from ${s}."runs" r where r.id = new.rerun_of and r.workflow_id = new.workflow_id;
|
|
206
|
+
end if;
|
|
139
207
|
return new;
|
|
140
208
|
end
|
|
141
209
|
$$;
|
|
@@ -144,4 +212,4 @@ drop trigger if exists "run_version" on ${s}."runs";
|
|
|
144
212
|
create trigger "run_version" before insert on ${s}."runs" for each row execute function ${s}."run_version"()`;
|
|
145
213
|
};
|
|
146
214
|
//#endregion
|
|
147
|
-
export { commentVersion, functionsSql, indexesSql, tablesSql, workflowsComment, workflowsTriggers };
|
|
215
|
+
export { commentVersion, effectsSql, functionsSql, indexesSql, tablesSql, triggerStateBytes, triggersSql, workflowsComment, workflowsTriggers };
|
|
@@ -5,7 +5,7 @@ import { Db } from "@protobase/query";
|
|
|
5
5
|
* The SQL that brings the `workflows` schema to what `workflows` needs: its tables, indexes and the triggers that number a
|
|
6
6
|
* workflow's versions and pin a run's; `undefined` when current. Tables are never dropped.
|
|
7
7
|
*/
|
|
8
|
-
export declare const workflowsMigration: (db: Db, options?: WorkflowsOptions) => Promise<string | undefined>;
|
|
8
|
+
export declare const workflowsMigration: (db: Db, options?: Pick<WorkflowsOptions, "schema">) => Promise<string | undefined>;
|
|
9
9
|
/** Refuses to go on while the `workflows` schema lacks what `workflows` needs; the project's migrations add it. */
|
|
10
|
-
export declare const checkWorkflowsSchema: (db: Db, options?: WorkflowsOptions) => Promise<void>;
|
|
10
|
+
export declare const checkWorkflowsSchema: (db: Db, options?: Pick<WorkflowsOptions, "schema">) => Promise<void>;
|
|
11
11
|
//#endregion
|
package/dist/workflows/schema.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
2
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
3
3
|
import { workflowsSchema } from "./options.js";
|
|
4
|
-
import { commentVersion, functionsSql, indexesSql, tablesSql, workflowsTriggers } from "./schema-sql.js";
|
|
4
|
+
import { commentVersion, effectsSql, functionsSql, indexesSql, tablesSql, triggersSql, workflowsTriggers } from "./schema-sql.js";
|
|
5
5
|
import { sql } from "kysely";
|
|
6
6
|
//#region src/workflows/schema.ts
|
|
7
7
|
const readWorkflowsSchema = async (db, schema) => {
|
|
8
8
|
const steps = `${quoteIdent(schema)}."steps"`;
|
|
9
|
+
const triggerRows = `${quoteIdent(schema)}."triggers"`;
|
|
9
10
|
const { rows } = await sql`select to_regclass(${steps}) is not null as found,
|
|
11
|
+
to_regclass(${triggerRows}) is not null as trigger_rows,
|
|
10
12
|
(select obj_description(p.oid, 'pg_proc') from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = ${schema} and p.proname = 'run_version') as comment,
|
|
11
13
|
(select count(*)::int from pg_trigger t join pg_class c on c.oid = t.tgrelid join pg_namespace n on n.oid = c.relnamespace
|
|
12
14
|
where n.nspname = ${schema} and not t.tgisinternal and t.tgname = any(${workflowsTriggers})) as triggers`.execute(db);
|
|
13
15
|
const row = rows[0];
|
|
14
16
|
return {
|
|
15
17
|
tables: row.found,
|
|
16
|
-
current: row.found && commentVersion(row.comment) ===
|
|
18
|
+
current: row.found && row.trigger_rows && commentVersion(row.comment) === 6 && row.triggers === workflowsTriggers.length
|
|
17
19
|
};
|
|
18
20
|
};
|
|
19
21
|
/**
|
|
@@ -27,6 +29,8 @@ const workflowsMigration = async (db, options = {}) => {
|
|
|
27
29
|
return `${[
|
|
28
30
|
`create schema if not exists ${quoteIdent(schema)}`,
|
|
29
31
|
...found.tables ? [] : [tablesSql(schema)],
|
|
32
|
+
triggersSql(schema),
|
|
33
|
+
effectsSql(schema),
|
|
30
34
|
indexesSql(schema),
|
|
31
35
|
functionsSql(schema)
|
|
32
36
|
].join(";\n\n")};`;
|
|
@@ -3,13 +3,15 @@ import { requestAccess, requireOperation } from "../request-access.js";
|
|
|
3
3
|
import { deletedRows } from "../params.js";
|
|
4
4
|
import { fetchReadable } from "../fetch-readable.js";
|
|
5
5
|
import { checkRunInput } from "./input-limits.js";
|
|
6
|
+
import { keepEarlierSteps } from "./rerun.js";
|
|
6
7
|
//#region src/workflows/start-hook.ts
|
|
7
8
|
/**
|
|
8
9
|
* Starts a run as it is created through the API, in the write's transaction: it refuses an input over its limits and
|
|
9
|
-
* a workflow the caller cannot read in their tenant (404, as the API answers for it),
|
|
10
|
-
* segment, which runs once the write commits. The resource
|
|
10
|
+
* a workflow the caller cannot read in their tenant (404, as the API answers for it), takes a re-run up after the steps
|
|
11
|
+
* it keeps of the run it re-runs, and queues the run's first segment, which runs once the write commits. The resource
|
|
12
|
+
* records who started it, as the run's author.
|
|
11
13
|
*/
|
|
12
|
-
const runStartHook = (runner, deps) => async (event, trx) => {
|
|
14
|
+
const runStartHook = (runner, deps, schema) => async (event, trx) => {
|
|
13
15
|
if (event.resource.name !== "workflow_runs" || event.operation !== "create" || !event.after) return;
|
|
14
16
|
checkRunInput(event.after.input);
|
|
15
17
|
const entry = deps().registry.find("workflows");
|
|
@@ -20,6 +22,10 @@ const runStartHook = (runner, deps) => async (event, trx) => {
|
|
|
20
22
|
requireOperation(access, "read");
|
|
21
23
|
const key = access.full.fields[access.full.primaryKey[0]];
|
|
22
24
|
if (!await fetchReadable(trx, access, [String(event.after.workflow_id)], deletedRows(access.full, false), { fields: [key] })) throw notFound(`No ${entry.name} with this key`);
|
|
25
|
+
if (event.after.rerun_of) await keepEarlierSteps(trx, deps(), schema, {
|
|
26
|
+
...event,
|
|
27
|
+
after: event.after
|
|
28
|
+
});
|
|
23
29
|
await runner.start(trx, String(event.after.id));
|
|
24
30
|
};
|
|
25
31
|
//#endregion
|
|
@@ -17,10 +17,12 @@ export type StartWorkflowOptions = {
|
|
|
17
17
|
key?: string;
|
|
18
18
|
/** The schema of `workflows`, when the config names another. Default `workflows`. */
|
|
19
19
|
schema?: string;
|
|
20
|
+
/** The version to run, as a trigger runs the enabled one; default the workflow's current version. */
|
|
21
|
+
version?: number;
|
|
20
22
|
};
|
|
21
23
|
/**
|
|
22
24
|
* 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
|
|
23
25
|
* `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
|
|
24
26
|
*/
|
|
25
|
-
export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema }: StartWorkflowOptions) => Promise<string>;
|
|
27
|
+
export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version }: StartWorkflowOptions) => Promise<string>;
|
|
26
28
|
//#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" }) => {
|
|
10
|
+
const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version }) => {
|
|
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, input, tenant_id, started_by, key)
|
|
16
|
-
values (${workflow}, ${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} (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})
|
|
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];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { defineJob } from "../../jobs/define-job.js";
|
|
2
|
+
import { readyAt, readyWhen, resolveTimeTerms } from "../ready/ready-when.js";
|
|
3
|
+
import { activeTriggers, currentTrigger, triggerFailed } from "./trigger-row.js";
|
|
4
|
+
import { orRefusal, readyContext, startIfReady } from "../ready/ready-start.js";
|
|
5
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
6
|
+
import { printFilter } from "@protobase/schema";
|
|
7
|
+
import { sql } from "kysely";
|
|
8
|
+
//#region src/workflows/triggers/trigger-jobs.ts
|
|
9
|
+
/** Records one enable pass reads per job. */
|
|
10
|
+
const passSize = 100;
|
|
11
|
+
/**
|
|
12
|
+
* 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
|
+
*/
|
|
17
|
+
const triggerJobs = (input) => {
|
|
18
|
+
const { schema } = input;
|
|
19
|
+
const readyCheck = defineJob({
|
|
20
|
+
name: "workflows.ready-check",
|
|
21
|
+
timeout: "5 minutes",
|
|
22
|
+
run: async ({ trigger: id, generation, key }, { db }) => {
|
|
23
|
+
const trigger = await currentTrigger(db, schema, id, generation);
|
|
24
|
+
if (!trigger) return;
|
|
25
|
+
if (await startIfReady(input, db, trigger, key)) await start.enqueue(db, {
|
|
26
|
+
trigger: id,
|
|
27
|
+
generation
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
const queueCheck = async (db, trigger, primaryKey, timeTerms, record) => {
|
|
32
|
+
const at = readyAt(timeTerms, record, /* @__PURE__ */ new Date());
|
|
33
|
+
if (!at) return;
|
|
34
|
+
const key = Object.fromEntries(primaryKey.map((name) => [name, record[name]]));
|
|
35
|
+
const recordKey = encodeKey(primaryKey.map((name) => record[name]));
|
|
36
|
+
await readyCheck.enqueue(db, {
|
|
37
|
+
trigger: trigger.id,
|
|
38
|
+
generation: trigger.generation,
|
|
39
|
+
key
|
|
40
|
+
}, {
|
|
41
|
+
runAt: at,
|
|
42
|
+
key: `ready:${trigger.id}:${trigger.generation}:${recordKey}:${at.toISOString()}`
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const start = defineJob({
|
|
46
|
+
name: "workflows.trigger-start",
|
|
47
|
+
timeout: "5 minutes",
|
|
48
|
+
run: async ({ trigger: id, generation, page_token }, { db }) => {
|
|
49
|
+
const trigger = await currentTrigger(db, schema, id, generation);
|
|
50
|
+
if (!trigger || !input.declared(trigger.type)) return;
|
|
51
|
+
const found = await orRefusal(readyContext(input, trigger));
|
|
52
|
+
if ("refused" in found) return triggerFailed(db, schema, trigger, "refused", found.refused);
|
|
53
|
+
if (!found.value) return;
|
|
54
|
+
const { spec, model, records } = found.value;
|
|
55
|
+
const split = readyWhen(spec.when);
|
|
56
|
+
if (!split.ok) return;
|
|
57
|
+
const filter = split.when.filter && printFilter(split.when.filter);
|
|
58
|
+
const listed = await orRefusal(records.list(spec.resource, {
|
|
59
|
+
...filter && { filter },
|
|
60
|
+
page_size: passSize,
|
|
61
|
+
...page_token && { page_token }
|
|
62
|
+
}));
|
|
63
|
+
if ("refused" in listed) return triggerFailed(db, schema, trigger, "refused", listed.refused);
|
|
64
|
+
const page = listed.value;
|
|
65
|
+
const timeTerms = resolveTimeTerms(model, split.when.timeTerms);
|
|
66
|
+
for (const record of page.items) await queueCheck(db, trigger, model.primaryKey, timeTerms, record);
|
|
67
|
+
if (page.next_page_token) await start.enqueue(db, {
|
|
68
|
+
trigger: id,
|
|
69
|
+
generation,
|
|
70
|
+
page_token: page.next_page_token
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const stop = defineJob({
|
|
75
|
+
name: "workflows.trigger-stop",
|
|
76
|
+
run: async ({ trigger }, { db }) => {
|
|
77
|
+
await sql`delete from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Routes each dispatched change to the ready triggers on its resource and tenant: it queues the check of the record at
|
|
82
|
+
* the time it becomes ready, in the dispatch's transaction. No sweep runs: a record becomes ready by a write, or at a
|
|
83
|
+
* time a write set.
|
|
84
|
+
*/
|
|
85
|
+
const onChanges = async (db, changes) => {
|
|
86
|
+
const types = [...input.readyTypes];
|
|
87
|
+
if (types.length === 0) return;
|
|
88
|
+
const triggers = await activeTriggers(db, schema, types);
|
|
89
|
+
for (const trigger of triggers) {
|
|
90
|
+
const spec = input.declared(trigger.type)?.ready(trigger.input);
|
|
91
|
+
const model = spec && input.deps().registry.find(spec.resource)?.model;
|
|
92
|
+
const split = spec && readyWhen(spec.when);
|
|
93
|
+
if (!spec || !model || !split?.ok) continue;
|
|
94
|
+
const timeTerms = resolveTimeTerms(model, split.when.timeTerms);
|
|
95
|
+
for (const change of changes) {
|
|
96
|
+
if (change.resource !== spec.resource || !change.after) continue;
|
|
97
|
+
if (String(change.tenant ?? "") !== String(trigger.tenant_id ?? "")) continue;
|
|
98
|
+
await queueCheck(db, trigger, model.primaryKey, timeTerms, change.after);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
jobs: [
|
|
104
|
+
start,
|
|
105
|
+
stop,
|
|
106
|
+
readyCheck
|
|
107
|
+
],
|
|
108
|
+
start,
|
|
109
|
+
stop,
|
|
110
|
+
onChanges
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
//#endregion
|
|
114
|
+
export { triggerJobs };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/workflows/triggers/trigger-row.ts
|
|
3
|
+
/**
|
|
4
|
+
* The trigger with this id while it is running on this generation, with who saved its version: `undefined` once it
|
|
5
|
+
* was changed, stopped or removed, so a job queued for it before then does nothing.
|
|
6
|
+
*/
|
|
7
|
+
const currentTrigger = async (db, schema, id, generation) => {
|
|
8
|
+
const { rows } = await sql`select t.id, t.workflow_id, t.version, t.tenant_id, t.type, t.input, t.generation, t.status, v.saved_by
|
|
9
|
+
from ${sql.id(schema, "triggers")} t join ${sql.id(schema, "versions")} v on v.workflow_id = t.workflow_id and v.version = t.version
|
|
10
|
+
where t.id = ${id} and t.generation = ${generation} and t.status in ('active', 'failing')`.execute(db);
|
|
11
|
+
return rows[0];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The running triggers of these types, failing ones included so a write can show they work again; each dispatch of
|
|
15
|
+
* changes reads them, and the `(status, type)` index serves it.
|
|
16
|
+
*/
|
|
17
|
+
const activeTriggers = async (db, schema, types) => (await sql`select id, workflow_id, version, tenant_id, type, input, generation, status from ${sql.id(schema, "triggers")}
|
|
18
|
+
where status in ('active', 'failing') and type = any(${types})`.execute(db)).rows;
|
|
19
|
+
/** Marks a trigger failing with an error its owner sees; the message names no secret. */
|
|
20
|
+
const triggerFailed = async (db, schema, { id, generation, version }, code, message) => {
|
|
21
|
+
await sql`update ${sql.id(schema, "triggers")} set status = 'failing', consecutive_failures = consecutive_failures + 1,
|
|
22
|
+
last_error = ${JSON.stringify({
|
|
23
|
+
code,
|
|
24
|
+
message,
|
|
25
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
26
|
+
})}::jsonb, updated_at = now()
|
|
27
|
+
where id = ${id} and generation = ${generation} and version = ${version}`.execute(db);
|
|
28
|
+
};
|
|
29
|
+
/** Marks a failing trigger active again once it read a record; whether this call was the one that did. */
|
|
30
|
+
const triggerRecovered = async (db, schema, { id, generation, version }) => {
|
|
31
|
+
const { rows } = await sql`update ${sql.id(schema, "triggers")} set status = 'active', consecutive_failures = 0, last_error = null, updated_at = now()
|
|
32
|
+
where id = ${id} and generation = ${generation} and version = ${version} and status = 'failing' returning id`.execute(db);
|
|
33
|
+
return rows.length > 0;
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { activeTriggers, currentTrigger, triggerFailed, triggerRecovered };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { badRequest } from "../../problem.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/workflows/triggers/trigger-sync.ts
|
|
4
|
+
const triggerOf = (definition) => {
|
|
5
|
+
const [type, input] = Object.entries(definition.trigger)[0];
|
|
6
|
+
return type === "manual" ? void 0 : {
|
|
7
|
+
type,
|
|
8
|
+
input
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Keeps a workflow's trigger row in step with its enabled version, in the write's transaction, queueing its jobs in that
|
|
13
|
+
* same write: turning it on inserts a running trigger and queues its start; a new version with the same type and input
|
|
14
|
+
* keeps the row, so its state carries over, and passes over the records again; any other change, or turning it off, stops the old row and starts a new one.
|
|
15
|
+
* Only the current version can be turned on.
|
|
16
|
+
*/
|
|
17
|
+
const syncTrigger = async (trx, schema, jobs, workflow) => {
|
|
18
|
+
const enabled = workflow.enabled_version;
|
|
19
|
+
if (enabled !== null && enabled !== workflow.version) throw badRequest("invalid-workflow", `Only the current version can be enabled: set enabled_version to ${String(workflow.version)}, or clear it to turn the workflow off`);
|
|
20
|
+
const wanted = enabled === null ? void 0 : triggerOf(workflow.definition);
|
|
21
|
+
const triggers = sql.id(schema, "triggers");
|
|
22
|
+
const id = String(workflow.id);
|
|
23
|
+
const { rows } = await sql`select id, generation, version, (type = ${wanted?.type ?? null} and input = ${JSON.stringify(wanted?.input ?? null)}::jsonb and connection_id is null) as same
|
|
24
|
+
from ${triggers} where workflow_id = ${id} and status <> 'stopping'`.execute(trx);
|
|
25
|
+
const existing = rows[0];
|
|
26
|
+
if (existing?.same) {
|
|
27
|
+
if (existing.version === enabled) return;
|
|
28
|
+
await sql`update ${triggers} set version = ${enabled}, status = 'active', consecutive_failures = 0, last_error = null, updated_at = now() where id = ${existing.id}`.execute(trx);
|
|
29
|
+
await jobs.start.enqueue(trx, {
|
|
30
|
+
trigger: existing.id,
|
|
31
|
+
generation: existing.generation
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (existing) {
|
|
36
|
+
await sql`update ${triggers} set status = 'stopping', generation = generation + 1, updated_at = now() where id = ${existing.id}`.execute(trx);
|
|
37
|
+
await jobs.stop.enqueue(trx, { trigger: existing.id });
|
|
38
|
+
}
|
|
39
|
+
if (!wanted) return;
|
|
40
|
+
const trigger = (await sql`insert into ${triggers} (workflow_id, version, tenant_id, type, input, status, generation, consecutive_failures)
|
|
41
|
+
values (${id}, ${enabled}, ${workflow.tenant_id ?? null}, ${wanted.type}, ${JSON.stringify(wanted.input)}::jsonb, 'active', 1, 0) returning id`.execute(trx)).rows[0].id;
|
|
42
|
+
await jobs.start.enqueue(trx, {
|
|
43
|
+
trigger,
|
|
44
|
+
generation: 1
|
|
45
|
+
}, { key: `trigger:${trigger}` });
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { syncTrigger };
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { stepTypeNames, stepTypes } from "./step-types.js";
|
|
2
1
|
import { betterAuthOwnerRoles } from "../webhooks/table/owner-roles.js";
|
|
3
2
|
import { runInputCheck } from "./input-limits.js";
|
|
4
3
|
import { workflowSecrets, workflowsSchema } from "./options.js";
|
|
5
4
|
import { workflowSaveHook } from "./save-hook.js";
|
|
6
5
|
import { workflowsRunner } from "./segment-job.js";
|
|
7
6
|
import { runStartHook } from "./start-hook.js";
|
|
7
|
+
import { triggerJobs } from "./triggers/trigger-jobs.js";
|
|
8
|
+
import { workflowTypes } from "./workflow-types.js";
|
|
8
9
|
//#region src/workflows/workflow-engine.ts
|
|
9
10
|
/**
|
|
10
11
|
* What `createAdmin` needs for `workflows`: the hooks that check saves and start runs, the check of a run's input before
|
|
11
|
-
* it is validated, and the segment
|
|
12
|
+
* it is validated, the app's trigger and step types, the segment and trigger jobs, the routing of changes to
|
|
13
|
+
* ready triggers, and the segment job, whose step
|
|
12
14
|
* types act on records as each workflow's last editor (from Better Auth unless the options say otherwise).
|
|
13
15
|
*/
|
|
14
16
|
const workflowEngine = (options, deps, auth) => {
|
|
@@ -22,15 +24,25 @@ const workflowEngine = (options, deps, auth) => {
|
|
|
22
24
|
allowPrivateAddresses: Boolean(options.allowPrivateAddresses)
|
|
23
25
|
};
|
|
24
26
|
const secrets = workflowSecrets(options, globalThis.process?.env ?? {});
|
|
27
|
+
const types = workflowTypes(options.plugins, (name) => deps().registry.find(name)?.model);
|
|
25
28
|
const { runner, job } = workflowsRunner({
|
|
26
29
|
schema,
|
|
27
|
-
tasks: (context) =>
|
|
30
|
+
tasks: (context) => types.tasks(input, context.mail),
|
|
28
31
|
secrets
|
|
29
32
|
});
|
|
33
|
+
const triggers = triggerJobs({
|
|
34
|
+
deps,
|
|
35
|
+
schema,
|
|
36
|
+
roles,
|
|
37
|
+
declared: (type) => types.ready.get(type),
|
|
38
|
+
readyTypes: [...types.ready.keys()].filter((id) => types.check.triggers[id] !== void 0)
|
|
39
|
+
});
|
|
30
40
|
return {
|
|
31
|
-
hooks: [workflowSaveHook(schema,
|
|
41
|
+
hooks: [workflowSaveHook(schema, types.check, triggers), runStartHook(runner, deps, schema)],
|
|
32
42
|
writeChecks: [runInputCheck],
|
|
33
|
-
job
|
|
43
|
+
jobs: [job, ...triggers.jobs],
|
|
44
|
+
onChanges: triggers.onChanges,
|
|
45
|
+
types
|
|
34
46
|
};
|
|
35
47
|
};
|
|
36
48
|
//#endregion
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { requestAccess, requireOperation } from "../request-access.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { typeCatalog } from "@protobase/workflows";
|
|
4
|
+
//#region src/workflows/workflow-types-route.ts
|
|
5
|
+
/**
|
|
6
|
+
* The app's trigger and step types, `GET /` at `workflowTypesPath`, as `list_workflow_types` lists them: the editor
|
|
7
|
+
* draws its menus, forms and check from them. Only for a caller who may read the app's workflows, refused as reading a
|
|
8
|
+
* workflow is.
|
|
9
|
+
*/
|
|
10
|
+
const workflowTypesRoute = (deps, types) => {
|
|
11
|
+
const catalog = typeCatalog(types);
|
|
12
|
+
const app = new Hono();
|
|
13
|
+
app.get("/", async (c) => {
|
|
14
|
+
const entry = deps.registry.find("workflows");
|
|
15
|
+
if (!entry) throw new Error("The workflow types are served only in an app with workflows");
|
|
16
|
+
requireOperation(await requestAccess(deps, entry, c.get("session")), "read");
|
|
17
|
+
return c.json(catalog);
|
|
18
|
+
});
|
|
19
|
+
return app;
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { workflowTypesRoute };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Mailer } from "../mail/mail-message.js";
|
|
2
|
+
import { ServicesInput } from "./plugin-services.js";
|
|
3
|
+
import { ResourceModel } from "@protobase/schema";
|
|
4
|
+
import { CheckOptions, LoadedPlugin, TaskHandlers } from "@protobase/workflows";
|
|
5
|
+
import { Plugin, ReadyTrigger } from "@protobase/workflows/plugin";
|
|
6
|
+
//#region src/workflows/workflow-types.d.ts
|
|
7
|
+
type Models = (name: string) => ResourceModel | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* The trigger and step types of an app: the built-in plugin, `app`, then the app's own, all through the same loader and
|
|
10
|
+
* interface. `types` is the list the MCP tools and the editor read; `check` is what a save checks a definition against,
|
|
11
|
+
* the available types only, and `checkWith` the same check against other resources, such as those a caller may read.
|
|
12
|
+
*/
|
|
13
|
+
export declare const workflowTypes: (loaded?: readonly LoadedPlugin[], models?: Models) => {
|
|
14
|
+
plugins: Plugin[];
|
|
15
|
+
types: import("@protobase/workflows").TypeEntry[];
|
|
16
|
+
check: CheckOptions;
|
|
17
|
+
checkWith: (against: Models) => CheckOptions;
|
|
18
|
+
tasks: (input: ServicesInput, mail: Mailer | undefined) => TaskHandlers;
|
|
19
|
+
ready: Map<string, ReadyTrigger>;
|
|
20
|
+
};
|
|
21
|
+
export type WorkflowTypes = ReturnType<typeof workflowTypes>;
|
|
22
|
+
//#endregion
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { pluginServices } from "./plugin-services.js";
|
|
2
|
+
import { readyProblems } from "./ready/ready-check.js";
|
|
3
|
+
import { loadPlugins, stepHandlers, triggerChecks, typeList } from "@protobase/workflows";
|
|
4
|
+
import { ConnectionError } from "@protobase/workflows/plugin";
|
|
5
|
+
import { appPlugin } from "@protobase/workflow-plugins";
|
|
6
|
+
//#region src/workflows/workflow-types.ts
|
|
7
|
+
const unavailable = (type) => {
|
|
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
|
+
};
|
|
11
|
+
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
|
+
const requiredInputs = (input) => Array.isArray(input.required) ? input.required : [];
|
|
13
|
+
/** 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
|
+
/**
|
|
16
|
+
* 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
|
+
* interface. `types` is the list the MCP tools and the editor read; `check` is what a save checks a definition against,
|
|
18
|
+
* the available types only, and `checkWith` the same check against other resources, such as those a caller may read.
|
|
19
|
+
*/
|
|
20
|
+
const workflowTypes = (loaded = [], models = () => void 0) => {
|
|
21
|
+
const plugins = loadPlugins([{
|
|
22
|
+
plugin: appPlugin,
|
|
23
|
+
source: "@protobase/workflow-plugins"
|
|
24
|
+
}, ...loaded]);
|
|
25
|
+
const types = typeList(plugins, unavailable);
|
|
26
|
+
const available = new Set(types.filter((type) => type.kind !== "step" && type.available).map((type) => type.id));
|
|
27
|
+
const ready = readyTriggers(plugins);
|
|
28
|
+
const steps = types.filter((type) => type.kind === "step" && type.available);
|
|
29
|
+
const reasons = Object.fromEntries(types.flatMap((type) => type.reason === void 0 ? [] : [[type.id, type.reason]]));
|
|
30
|
+
const own = triggerChecks(plugins);
|
|
31
|
+
const checkWith = (against) => {
|
|
32
|
+
const checks = Object.entries(own).map(([id, check]) => {
|
|
33
|
+
const declared = ready.get(id);
|
|
34
|
+
if (!declared) return [id, check];
|
|
35
|
+
return [id, (input) => {
|
|
36
|
+
const problems = check(input);
|
|
37
|
+
return problems.length > 0 ? problems : readyProblems(against, declared.ready(input));
|
|
38
|
+
}];
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
resources: steps.map((type) => type.id),
|
|
42
|
+
required: Object.fromEntries(steps.map((type) => [type.id, requiredInputs(type.input)])),
|
|
43
|
+
triggers: Object.fromEntries(checks.filter(([id]) => available.has(id))),
|
|
44
|
+
unavailable: reasons
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const check = checkWith(models);
|
|
48
|
+
/** The step types of one segment, which sends mail with the mailer of the job that runs it. */
|
|
49
|
+
const tasks = (input, mail) => stepHandlers(plugins, {
|
|
50
|
+
services: pluginServices(input, mail),
|
|
51
|
+
connection: noConnection
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
plugins,
|
|
55
|
+
types,
|
|
56
|
+
check,
|
|
57
|
+
checkWith,
|
|
58
|
+
tasks,
|
|
59
|
+
ready
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { readyTriggers, workflowTypes };
|