@protobase/server 0.67.12 → 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/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 +14 -19
- 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/run-files.js +3 -7
- package/dist/workflows/save-hook.js +15 -13
- package/dist/workflows/schema-sql.js +65 -3
- package/dist/workflows/schema.d.ts +2 -2
- package/dist/workflows/schema.js +6 -2
- 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 +25 -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
|
@@ -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 };
|
|
@@ -5,13 +5,14 @@ import { WorkflowsOptions } from "./options.js";
|
|
|
5
5
|
* over a `workflows` schema that `protobase migration` writes. Who may make, change and run them and read their runs
|
|
6
6
|
* is the resources' access (`workflows.create`, `workflow_runs.create`, `workflow_steps.read`, ...).
|
|
7
7
|
*/
|
|
8
|
-
export declare const workflowsModule: (options?: WorkflowsOptions) => {
|
|
8
|
+
export declare const workflowsModule: (options?: Pick<WorkflowsOptions, "schema">) => {
|
|
9
9
|
workflows: import("@protobase/schema").ResourceBuilder<{
|
|
10
10
|
readonly id: import("@protobase/schema").Field<string>;
|
|
11
11
|
readonly name: import("@protobase/schema").Field<string>;
|
|
12
12
|
readonly description: import("@protobase/schema").Field<string | null>;
|
|
13
13
|
readonly definition: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
14
14
|
readonly version: import("@protobase/schema").Field<number | null>;
|
|
15
|
+
readonly enabled_version: import("@protobase/schema").Field<number | null>;
|
|
15
16
|
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
16
17
|
readonly created_at: import("@protobase/schema").Field<string>;
|
|
17
18
|
readonly updated_at: import("@protobase/schema").Field<string>;
|
|
@@ -22,6 +23,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
|
|
|
22
23
|
readonly description: import("@protobase/schema").Field<string | null>;
|
|
23
24
|
readonly definition: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
24
25
|
readonly version: import("@protobase/schema").Field<number | null>;
|
|
26
|
+
readonly enabled_version: import("@protobase/schema").Field<number | null>;
|
|
25
27
|
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
26
28
|
readonly created_at: import("@protobase/schema").Field<string>;
|
|
27
29
|
readonly updated_at: import("@protobase/schema").Field<string>;
|
|
@@ -122,5 +124,27 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
|
|
|
122
124
|
readonly finished_at: import("@protobase/schema").Field<string>;
|
|
123
125
|
readonly duration_ms: import("@protobase/schema").Field<string | null>;
|
|
124
126
|
}, import("@protobase/schema").Ref<"id", string>>>;
|
|
127
|
+
workflowTriggers: import("@protobase/schema").ResourceBuilder<{
|
|
128
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
129
|
+
readonly workflow_id: import("@protobase/schema").Field<string | number>;
|
|
130
|
+
readonly version: import("@protobase/schema").Field<number>;
|
|
131
|
+
readonly type: import("@protobase/schema").Field<string>;
|
|
132
|
+
readonly status: import("@protobase/schema").Field<"active" | "failing" | "needs_sign_in" | "stopping">;
|
|
133
|
+
readonly last_error: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
134
|
+
readonly next_poll_at: import("@protobase/schema").Field<string | null>;
|
|
135
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
136
|
+
readonly updated_at: import("@protobase/schema").Field<string>;
|
|
137
|
+
}, import("@protobase/schema").Ref<"id", string>>;
|
|
138
|
+
workflowTriggersView: import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
|
|
139
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
140
|
+
readonly workflow_id: import("@protobase/schema").Field<string | number>;
|
|
141
|
+
readonly version: import("@protobase/schema").Field<number>;
|
|
142
|
+
readonly type: import("@protobase/schema").Field<string>;
|
|
143
|
+
readonly status: import("@protobase/schema").Field<"active" | "failing" | "needs_sign_in" | "stopping">;
|
|
144
|
+
readonly last_error: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
145
|
+
readonly next_poll_at: import("@protobase/schema").Field<string | null>;
|
|
146
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
147
|
+
readonly updated_at: import("@protobase/schema").Field<string>;
|
|
148
|
+
}, import("@protobase/schema").Ref<"id", string>>>;
|
|
125
149
|
};
|
|
126
150
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
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,10 +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.
|
|
48
|
-
"@protobase/query": "0.
|
|
49
|
-
"@protobase/schema": "0.
|
|
50
|
-
"@protobase/
|
|
47
|
+
"@protobase/layout": "0.68.0",
|
|
48
|
+
"@protobase/query": "0.68.0",
|
|
49
|
+
"@protobase/schema": "0.68.0",
|
|
50
|
+
"@protobase/workflow-plugins": "0.68.0",
|
|
51
|
+
"@protobase/workflows": "0.68.0",
|
|
51
52
|
"@react-email/render": "^2.1.0",
|
|
52
53
|
"@react-pdf/primitives": "^4.4.0",
|
|
53
54
|
"@react-pdf/renderer": "^4.9.0",
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { AddressRefusedError, pinnedRequest } from "../webhooks/table/pinned-request.js";
|
|
2
|
-
import { readLimited } from "../webhooks/table/read-limited.js";
|
|
3
|
-
import { isFileValue, readRunFile, storeRunFile } from "./run-files.js";
|
|
4
|
-
import { StepError } from "@protobase/workflows";
|
|
5
|
-
//#region src/workflows/http-step.ts
|
|
6
|
-
/** The most of an answer's body the `http` step reads; a larger one fails the step. */
|
|
7
|
-
const httpBodyLimit = 10485760;
|
|
8
|
-
/** How much of a failing answer's body its error keeps, after secrets are blanked out of it. */
|
|
9
|
-
const causeBodyLength = 200;
|
|
10
|
-
const methods = /* @__PURE__ */ new Set([
|
|
11
|
-
"GET",
|
|
12
|
-
"POST",
|
|
13
|
-
"PUT",
|
|
14
|
-
"PATCH",
|
|
15
|
-
"DELETE"
|
|
16
|
-
]);
|
|
17
|
-
const readings = /* @__PURE__ */ new Set([
|
|
18
|
-
"json",
|
|
19
|
-
"text",
|
|
20
|
-
"file"
|
|
21
|
-
]);
|
|
22
|
-
const invalid = (reason) => new StepError("Http.InvalidArguments", reason);
|
|
23
|
-
const isTextRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
24
|
-
const checked = (args) => {
|
|
25
|
-
if (typeof args.url !== "string" || !URL.canParse(args.url)) throw invalid("url must be an http or https URL");
|
|
26
|
-
const method = args.method === void 0 ? "GET" : String(args.method).toUpperCase();
|
|
27
|
-
if (!methods.has(method)) throw invalid(`method must be one of ${[...methods].join(", ")}`);
|
|
28
|
-
if (args.headers !== void 0 && !isTextRecord(args.headers)) throw invalid("headers must be an object of text values");
|
|
29
|
-
if (args.query !== void 0 && !isTextRecord(args.query)) throw invalid("query must be an object of text values");
|
|
30
|
-
const response = args.response ?? "json";
|
|
31
|
-
if (typeof response !== "string" || !readings.has(response)) throw invalid("response must be json, text or file");
|
|
32
|
-
const url = new URL(args.url);
|
|
33
|
-
for (const [name, value] of Object.entries(args.query ?? {})) url.searchParams.append(name, value);
|
|
34
|
-
return {
|
|
35
|
-
url,
|
|
36
|
-
method,
|
|
37
|
-
headers: new Headers(args.headers),
|
|
38
|
-
response
|
|
39
|
-
};
|
|
40
|
-
};
|
|
41
|
-
const answerBody = async (bytes, response, url, type, keep) => {
|
|
42
|
-
if (response === "file") return keep(bytes, decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf("/") + 1)), type?.split(";")[0]?.trim() || "application/octet-stream");
|
|
43
|
-
const text = new TextDecoder().decode(bytes);
|
|
44
|
-
if (response === "text") return text;
|
|
45
|
-
if (text.trim() === "") return null;
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(text);
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (!(error instanceof SyntaxError)) throw error;
|
|
50
|
-
throw new StepError("Http.InvalidBody", `${url.host} answered with a body that is not JSON; read it with "response": "text"`);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
/**
|
|
54
|
-
* The `app:http` step: one request to `url`, built at run time, through the pinned request, which resolves its host
|
|
55
|
-
* once and connects only to a public address it checked. It follows no redirect, reads at most `httpBodyLimit` of
|
|
56
|
-
* the answer, and fails with `Http.Status` on a status outside 2xx, `Http.Network` when no answer came.
|
|
57
|
-
*/
|
|
58
|
-
const httpStep = ({ allowPrivateAddresses = false, resolve, files = () => void 0 } = {}) => async (args, { runId, signal, log, redact }) => {
|
|
59
|
-
const given = args ?? {};
|
|
60
|
-
const { url, method, headers, response: reading } = checked(given);
|
|
61
|
-
let body;
|
|
62
|
-
if (given.body !== void 0 && method !== "GET") {
|
|
63
|
-
if (isFileValue(given.body)) {
|
|
64
|
-
const file = await readRunFile(files(), runId, given.body, "body");
|
|
65
|
-
body = file.bytes;
|
|
66
|
-
if (!headers.has("content-type")) headers.set("content-type", file.ref.type ?? "application/octet-stream");
|
|
67
|
-
} else if (typeof given.body === "string") body = given.body;
|
|
68
|
-
else {
|
|
69
|
-
body = JSON.stringify(given.body);
|
|
70
|
-
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const started = Date.now();
|
|
74
|
-
const answer = await pinnedRequest(url.href, {
|
|
75
|
-
method,
|
|
76
|
-
headers,
|
|
77
|
-
...body !== void 0 && { body },
|
|
78
|
-
signal
|
|
79
|
-
}, {
|
|
80
|
-
allowPrivateAddresses,
|
|
81
|
-
...resolve && { resolve }
|
|
82
|
-
}).catch((error) => {
|
|
83
|
-
if (error instanceof AddressRefusedError) throw new StepError("Http.AddressRefused", error.message);
|
|
84
|
-
if (signal.aborted) throw error;
|
|
85
|
-
throw new StepError("Http.Network", `No answer from ${url.host}: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
-
});
|
|
87
|
-
const bytes = await readLimited(answer, 10485761);
|
|
88
|
-
log(`${method} ${given.url}`.replace(/[?#].*$/, "") + `: ${answer.status} in ${Date.now() - started} ms`);
|
|
89
|
-
if (bytes.length > 10485760) throw new StepError("Http.ResponseTooLarge", `The answer's body is larger than ${httpBodyLimit / 1024 / 1024} MiB, the most the http step reads`);
|
|
90
|
-
if (answer.status < 200 || answer.status > 299) {
|
|
91
|
-
const excerpt = redact(new TextDecoder().decode(bytes)).slice(0, causeBodyLength);
|
|
92
|
-
throw new StepError("Http.Status", `${url.host} answered ${answer.status}${excerpt ? `: ${excerpt}` : ""}`);
|
|
93
|
-
}
|
|
94
|
-
const kept = Object.fromEntries([...answer.headers].filter(([name]) => name !== "set-cookie"));
|
|
95
|
-
const keep = (data, name, type) => storeRunFile(files(), runId, data, name, type);
|
|
96
|
-
return {
|
|
97
|
-
status: answer.status,
|
|
98
|
-
headers: kept,
|
|
99
|
-
body: await answerBody(bytes, reading, url, answer.headers.get("content-type"), keep)
|
|
100
|
-
};
|
|
101
|
-
};
|
|
102
|
-
//#endregion
|
|
103
|
-
export { httpBodyLimit, httpStep };
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { readRunFile, runFileLimit } from "./run-files.js";
|
|
2
|
-
import { StepError } from "@protobase/workflows";
|
|
3
|
-
const invalid = (reason) => new StepError("Mail.InvalidArguments", reason);
|
|
4
|
-
const line = (value, name) => {
|
|
5
|
-
if (typeof value !== "string" || value.trim() === "") throw invalid(`${name} must be text`);
|
|
6
|
-
if (/[\r\n]/.test(value)) throw invalid(`${name} must be one line`);
|
|
7
|
-
return value;
|
|
8
|
-
};
|
|
9
|
-
const recipients = (value) => {
|
|
10
|
-
const list = Array.isArray(value) ? value : [value];
|
|
11
|
-
if (list.length === 0 || list.length > 50) throw invalid(`to must be one address or a list of 1 to 50`);
|
|
12
|
-
return list.map((address, index) => {
|
|
13
|
-
const name = Array.isArray(value) ? `to[${index}]` : "to";
|
|
14
|
-
if (/[,;]/.test(line(address, name))) throw invalid(`${name} must be one address; give several as a list`);
|
|
15
|
-
return address;
|
|
16
|
-
}).join(", ");
|
|
17
|
-
};
|
|
18
|
-
const attachmentsOf = async (value, files, runId) => {
|
|
19
|
-
if (value === void 0 || value === null) return [];
|
|
20
|
-
if (!Array.isArray(value)) throw invalid("attachments must be a list of file values");
|
|
21
|
-
const attachments = [];
|
|
22
|
-
let total = 0;
|
|
23
|
-
for (const [index, item] of value.entries()) {
|
|
24
|
-
const { ref, bytes } = await readRunFile(files, runId, item, `attachments[${index}]`);
|
|
25
|
-
total += bytes.length;
|
|
26
|
-
if (total > 10485760) throw invalid(`attachments are larger than ${runFileLimit / 1024 / 1024} MiB together`);
|
|
27
|
-
attachments.push({
|
|
28
|
-
filename: ref.name,
|
|
29
|
-
content: bytes,
|
|
30
|
-
...ref.type && { contentType: ref.type }
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
return attachments;
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* The `app:mail.send` step: one message from the app's own sender, with the app's mailer; a workflow cannot set the
|
|
37
|
-
* sender. Its Message-ID comes from the step's idempotency key, the same for each try, so a receiver can tell a
|
|
38
|
-
* repeated try from a new mail.
|
|
39
|
-
*/
|
|
40
|
-
const mailStep = (mail, files) => async (args, { runId, idempotencyKey }) => {
|
|
41
|
-
const given = args ?? {};
|
|
42
|
-
const mailer = mail();
|
|
43
|
-
if (!mailer) throw new StepError("States.TaskFailed", "The app sends no mail: set its mailer, or PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM");
|
|
44
|
-
if (typeof given.text !== "string") throw invalid("text must be text");
|
|
45
|
-
if (given.html !== void 0 && typeof given.html !== "string") throw invalid("html must be text");
|
|
46
|
-
const messageId = `<${idempotencyKey.replace(/[^A-Za-z0-9.-]/g, ".")}@workflows.invalid>`;
|
|
47
|
-
await mailer.send({
|
|
48
|
-
to: recipients(given.to),
|
|
49
|
-
subject: line(given.subject, "subject"),
|
|
50
|
-
text: given.text,
|
|
51
|
-
...given.html !== void 0 && { html: given.html },
|
|
52
|
-
headers: { "Message-ID": messageId },
|
|
53
|
-
attachments: await attachmentsOf(given.attachments, files(), runId)
|
|
54
|
-
});
|
|
55
|
-
return { message_id: messageId };
|
|
56
|
-
};
|
|
57
|
-
//#endregion
|
|
58
|
-
export { mailStep };
|