@protobase/server 0.67.8 → 0.67.10
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/config-exports.d.ts +2 -1
- package/dist/config-exports.js +2 -1
- package/dist/create-admin.d.ts +4 -1
- package/dist/create-admin.js +11 -2
- package/dist/index.d.ts +7 -1
- package/dist/index.js +6 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.js +2 -1
- package/dist/mcp/mcp-tools.d.ts +8 -2
- package/dist/mcp/mcp-tools.js +10 -3
- package/dist/package.js +1 -1
- package/dist/webhooks/attempt.js +2 -2
- package/dist/webhooks/table/webhook-table.js +1 -1
- package/dist/workflows/http-step.js +103 -0
- package/dist/workflows/input-limits.js +22 -0
- package/dist/workflows/mail-step.js +58 -0
- package/dist/workflows/mcp/merge-patch.js +15 -0
- package/dist/workflows/mcp/type-catalog.js +144 -0
- package/dist/workflows/mcp/workflow-instructions.d.ts +7 -0
- package/dist/workflows/mcp/workflow-instructions.js +77 -0
- package/dist/workflows/mcp/workflow-read-tools.d.ts +6 -0
- package/dist/workflows/mcp/workflow-read-tools.js +209 -0
- package/dist/workflows/mcp/workflow-tool-args.js +35 -0
- package/dist/workflows/mcp/workflow-write-tools.d.ts +7 -0
- package/dist/workflows/mcp/workflow-write-tools.js +149 -0
- package/dist/workflows/options.d.ts +27 -0
- package/dist/workflows/options.js +19 -0
- package/dist/workflows/piece-step.js +11 -0
- package/dist/workflows/records-step.js +114 -0
- package/dist/workflows/run-files.js +70 -0
- package/dist/workflows/save-hook.js +20 -0
- package/dist/workflows/schema-sql.js +147 -0
- package/dist/workflows/schema.d.ts +11 -0
- package/dist/workflows/schema.js +41 -0
- package/dist/workflows/segment-job.js +51 -0
- package/dist/workflows/start-hook.js +26 -0
- package/dist/workflows/start-workflow.d.ts +26 -0
- package/dist/workflows/start-workflow.js +28 -0
- package/dist/workflows/step-types.js +34 -0
- package/dist/workflows/store.js +42 -0
- package/dist/workflows/workflow-engine.js +37 -0
- package/dist/workflows/workflows-module.d.ts +124 -0
- package/dist/workflows/workflows-module.js +11 -0
- package/package.json +5 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/workflows/options.ts
|
|
2
|
+
const schemaPattern = /^[a-z_][a-z0-9_]*$/;
|
|
3
|
+
const secretName = /^[A-Z_][A-Z0-9_]*$/;
|
|
4
|
+
/** The schema of the options, checked. */
|
|
5
|
+
const workflowsSchema = (options = {}) => {
|
|
6
|
+
const schema = options.schema ?? "workflows";
|
|
7
|
+
if (!schemaPattern.test(schema)) throw new Error(`Invalid workflows schema "${schema}": use lowercase letters, digits and "_"`);
|
|
8
|
+
if (schema.length > 63) throw new Error(`Invalid workflows schema "${schema}": at most 63 characters`);
|
|
9
|
+
return schema;
|
|
10
|
+
};
|
|
11
|
+
/** The secrets workflows may read, by name, from `env`; startup stops on a name that is invalid or unset. */
|
|
12
|
+
const workflowSecrets = (options, env) => Object.fromEntries((options.secrets ?? []).map((name) => {
|
|
13
|
+
if (!secretName.test(name)) throw new Error(`workflows.secrets: "${name}" is not an environment variable name; use capitals, digits and "_"`);
|
|
14
|
+
const value = env[name];
|
|
15
|
+
if (value === void 0 || value === "") throw new Error(`workflows.secrets names ${name}, which is not set`);
|
|
16
|
+
return [name, value];
|
|
17
|
+
}));
|
|
18
|
+
//#endregion
|
|
19
|
+
export { workflowSecrets, workflowsSchema };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { StepError } from "@protobase/workflows";
|
|
2
|
+
//#region src/workflows/piece-step.ts
|
|
3
|
+
/**
|
|
4
|
+
* The `app:piece` step, which a definition may name but which runs no piece yet: it fails with `Piece.Error` until the
|
|
5
|
+
* app's plugins give it a handler that sends through the plugin context's guarded fetch.
|
|
6
|
+
*/
|
|
7
|
+
const pieceStep = async () => {
|
|
8
|
+
throw new StepError("Piece.Error", "No piece runs in this app yet");
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { pieceStep };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { tenantScope } from "../tenant.js";
|
|
3
|
+
import { issueTicket } from "../files/ticket.js";
|
|
4
|
+
import { assistantRecords, toolEntry } from "../assistant/records.js";
|
|
5
|
+
import { runFileRef } from "./run-files.js";
|
|
6
|
+
import { sql } from "kysely";
|
|
7
|
+
import { StepError } from "@protobase/workflows";
|
|
8
|
+
//#region src/workflows/records-step.ts
|
|
9
|
+
const ticketSeconds = 60;
|
|
10
|
+
/**
|
|
11
|
+
* The session a run's records steps act in: the user who saved the version the run pinned, with the roles they hold
|
|
12
|
+
* now in the run's tenant, so a workflow never does more than its last editor may do today.
|
|
13
|
+
*/
|
|
14
|
+
const saverSession = async ({ deps, schema, roles }, runId) => {
|
|
15
|
+
const { rows } = await sql`select v.saved_by, r.tenant_id from ${sql.id(schema, "runs")} r
|
|
16
|
+
join ${sql.id(schema, "versions")} v on v.workflow_id = r.workflow_id and v.version = r.version where r.id = ${runId}`.execute(deps().db);
|
|
17
|
+
const row = rows[0];
|
|
18
|
+
if (!row?.saved_by) throw new StepError("Records.Refused", "The run's version was not saved by a user, so its steps act on no records");
|
|
19
|
+
const tenant = row.tenant_id ?? void 0;
|
|
20
|
+
const held = await roles({
|
|
21
|
+
id: row.saved_by,
|
|
22
|
+
...tenant !== void 0 && { tenant }
|
|
23
|
+
});
|
|
24
|
+
if (held.length === 0) throw new StepError("Records.Refused", "The workflow's last editor holds no role here any more, so its steps act on no records");
|
|
25
|
+
return {
|
|
26
|
+
user: {
|
|
27
|
+
id: row.saved_by,
|
|
28
|
+
roles: held
|
|
29
|
+
},
|
|
30
|
+
...tenant !== void 0 && {
|
|
31
|
+
tenant,
|
|
32
|
+
organization: { id: tenant }
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const withTickets = async (deps, session, resource, data, runId) => {
|
|
37
|
+
const entry = toolEntry(deps, resource);
|
|
38
|
+
const result = { ...data };
|
|
39
|
+
for (const [name, value] of Object.entries(data)) {
|
|
40
|
+
const field = entry.model.fields[name];
|
|
41
|
+
if (field?.type === "decimal" && typeof value === "number") result[name] = String(value);
|
|
42
|
+
if (!field?.file || value === null || typeof value !== "object") continue;
|
|
43
|
+
if (!deps.files) throw new StepError("States.TaskFailed", "The app keeps no files");
|
|
44
|
+
const ref = runFileRef(runId, value, `data.${name}`);
|
|
45
|
+
const scope = {
|
|
46
|
+
tenant: String(tenantScope(entry, session)?.tenantValue ?? ""),
|
|
47
|
+
resource: entry.name,
|
|
48
|
+
field: name,
|
|
49
|
+
user: String(session.user.id)
|
|
50
|
+
};
|
|
51
|
+
result[name] = await issueTicket(deps.files.signer, {
|
|
52
|
+
provider: ref.provider,
|
|
53
|
+
path: ref.path,
|
|
54
|
+
name: ref.name,
|
|
55
|
+
...ref.size !== void 0 && { size: ref.size }
|
|
56
|
+
}, {}, scope, Math.floor(Date.now() / 1e3) + ticketSeconds);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
const text = (value, name) => {
|
|
61
|
+
if (typeof value !== "string" || value === "") throw new StepError("Records.InvalidArguments", `${name} must be text`);
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
const keyOf = (value) => {
|
|
65
|
+
if (typeof value === "string" || typeof value === "number") return value;
|
|
66
|
+
if (Array.isArray(value) && value.every((part) => typeof part === "string" || typeof part === "number")) return value;
|
|
67
|
+
throw new StepError("Records.InvalidArguments", "key must be text, a number or a list of them");
|
|
68
|
+
};
|
|
69
|
+
const rowOf = (value, name) => {
|
|
70
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new StepError("Records.InvalidArguments", `${name} must be an object of field values`);
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
const refusedAs = (error) => {
|
|
74
|
+
if (error instanceof HttpProblem) throw new StepError("Records.Refused", `${error.status} ${error.title}: ${error.detail}`);
|
|
75
|
+
throw error;
|
|
76
|
+
};
|
|
77
|
+
const run = async (input, operation, args, { runId }) => {
|
|
78
|
+
const resource = text(args.resource, "resource");
|
|
79
|
+
const deps = input.deps();
|
|
80
|
+
const session = await saverSession(input, runId);
|
|
81
|
+
const records = assistantRecords(deps, session);
|
|
82
|
+
if (operation === "get") return (await records.get(resource, keyOf(args.key))).record;
|
|
83
|
+
if (operation === "update") return (await records.update(resource, keyOf(args.key), await withTickets(deps, session, resource, rowOf(args.data, "data"), runId))).record;
|
|
84
|
+
if (operation === "list") return { records: (await records.list(resource, {
|
|
85
|
+
...args.filter !== void 0 && { filter: text(args.filter, "filter") },
|
|
86
|
+
...args.order_by !== void 0 && { order_by: text(args.order_by, "order_by") },
|
|
87
|
+
...args.limit !== void 0 && { page_size: Number(args.limit) }
|
|
88
|
+
})).items.map(({ etag: _, permissions: __, ...record }) => record) };
|
|
89
|
+
if (Array.isArray(args.data)) {
|
|
90
|
+
const operations = [];
|
|
91
|
+
for (const [index, item] of args.data.entries()) operations.push({
|
|
92
|
+
op: "create",
|
|
93
|
+
resource,
|
|
94
|
+
data: await withTickets(deps, session, resource, rowOf(item, `data[${index}]`), runId)
|
|
95
|
+
});
|
|
96
|
+
return (await records.batch(operations)).flatMap((result) => result.op === "create" ? [result.record] : []);
|
|
97
|
+
}
|
|
98
|
+
return (await records.create(resource, await withTickets(deps, session, resource, rowOf(args.data, "data"), runId))).record;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* The `app:records.*` steps: `create`, `update`, `get` and `list` through the app's API as the workflow's last editor,
|
|
102
|
+
* so its access rules, tenant scope, validation and write hooks apply as they do to that user's own requests.
|
|
103
|
+
*/
|
|
104
|
+
const recordsSteps = (input) => {
|
|
105
|
+
const step = (operation) => (args, context) => run(input, operation, rowOf(args ?? {}, "Arguments"), context).catch(refusedAs);
|
|
106
|
+
return {
|
|
107
|
+
"app:records.create": step("create"),
|
|
108
|
+
"app:records.update": step("update"),
|
|
109
|
+
"app:records.get": step("get"),
|
|
110
|
+
"app:records.list": step("list")
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
//#endregion
|
|
114
|
+
export { recordsSteps };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cleanName, formatUri, parseUri } from "../files/uri.js";
|
|
2
|
+
import { readLimited } from "../webhooks/table/read-limited.js";
|
|
3
|
+
import { StepError } from "@protobase/workflows";
|
|
4
|
+
//#region src/workflows/run-files.ts
|
|
5
|
+
/** The most bytes of one file a workflow stores or reads, as the `http` step reads at most. */
|
|
6
|
+
const runFileLimit = 10485760;
|
|
7
|
+
/** The provider a run's files are kept in. */
|
|
8
|
+
const provider = "private";
|
|
9
|
+
const prefix = (runId) => `workflows/${runId}/`;
|
|
10
|
+
const runStore = (files) => {
|
|
11
|
+
const kept = files?.providers[provider];
|
|
12
|
+
if (!kept) throw new StepError("States.TaskFailed", `The app keeps no files: workflows store theirs with the "${provider}" file provider`);
|
|
13
|
+
if (kept.public) throw new StepError("States.TaskFailed", `The "${provider}" file provider is public, so workflows keep no files in it: anyone could read them`);
|
|
14
|
+
return kept.store;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Stores bytes a step of the run got, such as an answer's body, as a file of the run's own, under a name of the
|
|
18
|
+
* runner's choosing: no part of its path comes from the workflow or the answer.
|
|
19
|
+
*/
|
|
20
|
+
const storeRunFile = async (files, runId, bytes, name, type) => {
|
|
21
|
+
const store = runStore(files);
|
|
22
|
+
const path = `${prefix(runId)}${crypto.randomUUID()}`;
|
|
23
|
+
await store.put(path, new Blob([bytes]).stream());
|
|
24
|
+
const clean = cleanName(name);
|
|
25
|
+
return {
|
|
26
|
+
uri: formatUri({
|
|
27
|
+
provider,
|
|
28
|
+
path,
|
|
29
|
+
name: clean,
|
|
30
|
+
size: bytes.length
|
|
31
|
+
}),
|
|
32
|
+
name: clean,
|
|
33
|
+
type,
|
|
34
|
+
size: bytes.length
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/** Whether a value has a file value's shape, all four fields; an object with only a `uri` is other data. */
|
|
38
|
+
const isFileValue = (value) => {
|
|
39
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
40
|
+
const { uri, name, type, size } = value;
|
|
41
|
+
return typeof uri === "string" && typeof name === "string" && typeof type === "string" && typeof size === "number";
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The file a file value names, when it is one the run stored itself; a step fails on any other, so a workflow cannot
|
|
45
|
+
* mail, send or attach a file of the app's by naming its URI.
|
|
46
|
+
*/
|
|
47
|
+
const runFileRef = (runId, value, field) => {
|
|
48
|
+
const ref = isFileValue(value) ? parseUri(value.uri) : void 0;
|
|
49
|
+
if (!ref) throw new StepError("States.TaskFailed", `${field} is not a file value ({ uri, name, type, size })`);
|
|
50
|
+
if (ref.provider !== provider || !ref.path.startsWith(prefix(runId))) throw new StepError("States.TaskFailed", `${field} is not a file this run stored; a step takes only the files of its own run`);
|
|
51
|
+
const type = value.type;
|
|
52
|
+
return {
|
|
53
|
+
...ref,
|
|
54
|
+
...typeof type === "string" && { type }
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/** The bytes of a file the run stored itself, read up to `runFileLimit`. */
|
|
58
|
+
const readRunFile = async (files, runId, value, field) => {
|
|
59
|
+
const ref = runFileRef(runId, value, field);
|
|
60
|
+
const stream = await runStore(files).read(ref.path);
|
|
61
|
+
if (!stream) throw new StepError("States.TaskFailed", `${field} names a file that is gone`);
|
|
62
|
+
const bytes = await readLimited(new Response(stream), 10485761);
|
|
63
|
+
if (bytes.length > 10485760) throw new StepError("States.TaskFailed", `${field} is larger than ${runFileLimit / 1024 / 1024} MiB`);
|
|
64
|
+
return {
|
|
65
|
+
ref,
|
|
66
|
+
bytes
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { isFileValue, readRunFile, runFileLimit, runFileRef, storeRunFile };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
import { checkDefinition, problemText } from "@protobase/workflows";
|
|
4
|
+
//#region src/workflows/save-hook.ts
|
|
5
|
+
/**
|
|
6
|
+
* Checks a workflow's definition on every save that changes it, against the language's schema and the named-reads
|
|
7
|
+
* profile, with the step types the app has, and keeps it as the version the table's trigger numbered, saved by the
|
|
8
|
+
* caller. A refusal is a 400 problem naming each state and field, also listed as `problems`; the write rolls back.
|
|
9
|
+
*/
|
|
10
|
+
const workflowSaveHook = (schema, stepTypes) => async (event, trx) => {
|
|
11
|
+
if (event.resource.name !== "workflows" || !event.after) return;
|
|
12
|
+
if (event.operation !== "create" && event.operation !== "update") return;
|
|
13
|
+
if (event.operation === "update" && event.after.version === event.before?.version) return;
|
|
14
|
+
const problems = checkDefinition(event.after.definition, { resources: stepTypes });
|
|
15
|
+
if (problems.length > 0) throw badRequest("invalid-workflow", `The workflow cannot be saved: ${problems.map(problemText).join("; ")}`, { problems });
|
|
16
|
+
await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
|
|
17
|
+
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);
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { workflowSaveHook };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
/** The comment the schema's function carries, which the startup check reads. */
|
|
3
|
+
const workflowsComment = `Protobase workflows 3`;
|
|
4
|
+
const commentVersion = (comment) => {
|
|
5
|
+
const match = comment?.match(/^Protobase workflows (\d+)$/);
|
|
6
|
+
return match ? Number(match[1]) : void 0;
|
|
7
|
+
};
|
|
8
|
+
const ms = (from, to) => `(floor(extract(epoch from ("${to}" - "${from}")) * 1000)::bigint)`;
|
|
9
|
+
/**
|
|
10
|
+
* The workflows, each version of their definitions as it was saved, their runs and every step of a run. A run pins
|
|
11
|
+
* the version it started on, and a run with a key is started once per workflow and key; a step is saved once per scope and number, which is what keeps a repeated segment from
|
|
12
|
+
* saving it twice.
|
|
13
|
+
*/
|
|
14
|
+
const tablesSql = (schema) => {
|
|
15
|
+
const s = quoteIdent(schema);
|
|
16
|
+
return `create table if not exists ${s}."workflows" (
|
|
17
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
18
|
+
"name" text not null,
|
|
19
|
+
"description" text,
|
|
20
|
+
"definition" jsonb not null,
|
|
21
|
+
"version" integer,
|
|
22
|
+
"tenant_id" text,
|
|
23
|
+
"created_at" timestamptz not null default now(),
|
|
24
|
+
"updated_at" timestamptz not null default now()
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
create table if not exists ${s}."versions" (
|
|
28
|
+
"workflow_id" uuid not null references ${s}."workflows" ("id") on delete cascade,
|
|
29
|
+
"version" integer not null,
|
|
30
|
+
"definition" jsonb not null,
|
|
31
|
+
"saved_by" text,
|
|
32
|
+
"tenant_id" text,
|
|
33
|
+
"saved_at" timestamptz not null default now(),
|
|
34
|
+
primary key ("workflow_id", "version")
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
create table if not exists ${s}."runs" (
|
|
38
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
39
|
+
"workflow_id" uuid not null references ${s}."workflows" ("id") on delete cascade,
|
|
40
|
+
"version" integer,
|
|
41
|
+
"input" jsonb,
|
|
42
|
+
"key" text,
|
|
43
|
+
"status" text not null default 'queued' check ("status" in ('queued', 'running', 'waiting', 'succeeded', 'failed')),
|
|
44
|
+
"output" jsonb,
|
|
45
|
+
"error" text,
|
|
46
|
+
"cause" text,
|
|
47
|
+
"resume_at" timestamptz,
|
|
48
|
+
"started_by" text,
|
|
49
|
+
"tenant_id" text,
|
|
50
|
+
"created_at" timestamptz not null default now(),
|
|
51
|
+
"finished_at" timestamptz,
|
|
52
|
+
"duration_ms" bigint generated always as (${ms("created_at", "finished_at")}) stored,
|
|
53
|
+
foreign key ("workflow_id", "version") references ${s}."versions" ("workflow_id", "version")
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
create table if not exists ${s}."steps" (
|
|
57
|
+
"id" bigint generated always as identity primary key,
|
|
58
|
+
"run_id" uuid not null references ${s}."runs" ("id") on delete cascade,
|
|
59
|
+
"tenant_id" text,
|
|
60
|
+
"scope" text not null,
|
|
61
|
+
"seq" integer not null,
|
|
62
|
+
"state" text not null,
|
|
63
|
+
"type" text not null,
|
|
64
|
+
"status" text not null check ("status" in ('succeeded', 'retrying', 'caught', 'failed')),
|
|
65
|
+
"attempt" integer not null,
|
|
66
|
+
"input" jsonb,
|
|
67
|
+
"output" jsonb,
|
|
68
|
+
"assigned" jsonb,
|
|
69
|
+
"next" text,
|
|
70
|
+
"resume_at" timestamptz,
|
|
71
|
+
"error" text,
|
|
72
|
+
"cause" text,
|
|
73
|
+
"logs" jsonb not null,
|
|
74
|
+
"started_at" timestamptz not null,
|
|
75
|
+
"finished_at" timestamptz not null,
|
|
76
|
+
"duration_ms" bigint generated always as (${ms("started_at", "finished_at")}) stored,
|
|
77
|
+
unique ("run_id", "scope", "seq")
|
|
78
|
+
)`;
|
|
79
|
+
};
|
|
80
|
+
/** An index for each filter and sort the API takes, and pg_trgm ones for the search over names. */
|
|
81
|
+
const indexesSql = (schema) => {
|
|
82
|
+
const s = quoteIdent(schema);
|
|
83
|
+
return `create extension if not exists pg_trgm;
|
|
84
|
+
create index if not exists "workflows_tenant" on ${s}."workflows" ("tenant_id");
|
|
85
|
+
create index if not exists "workflows_name" on ${s}."workflows" ("name");
|
|
86
|
+
create index if not exists "workflows_updated_at" on ${s}."workflows" ("updated_at");
|
|
87
|
+
create index if not exists "workflows_name_search" on ${s}."workflows" using gin ("name" gin_trgm_ops);
|
|
88
|
+
create index if not exists "versions_tenant" on ${s}."versions" ("tenant_id");
|
|
89
|
+
alter table ${s}."runs" add column if not exists "key" text;
|
|
90
|
+
create unique index if not exists "runs_key" on ${s}."runs" ("workflow_id", "key");
|
|
91
|
+
create index if not exists "runs_workflow" on ${s}."runs" ("workflow_id", "created_at");
|
|
92
|
+
create index if not exists "runs_tenant" on ${s}."runs" ("tenant_id");
|
|
93
|
+
create index if not exists "runs_status" on ${s}."runs" ("status");
|
|
94
|
+
create index if not exists "runs_created_at" on ${s}."runs" ("created_at");
|
|
95
|
+
create index if not exists "steps_tenant" on ${s}."steps" ("tenant_id");
|
|
96
|
+
create index if not exists "steps_status" on ${s}."steps" ("status");
|
|
97
|
+
create index if not exists "steps_state" on ${s}."steps" ("state")`;
|
|
98
|
+
};
|
|
99
|
+
/** The triggers `functionsSql` makes, which the startup check finds on their tables. */
|
|
100
|
+
const workflowsTriggers = ["workflow_version", "run_version"];
|
|
101
|
+
/**
|
|
102
|
+
* A workflow numbers its next version as a save changes its definition, in the row the write returns, so its ETag is
|
|
103
|
+
* the saved row's; the save hook keeps that version's definition and who saved it. A run takes its workflow's current
|
|
104
|
+
* 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.
|
|
106
|
+
*/
|
|
107
|
+
const functionsSql = (schema) => {
|
|
108
|
+
const s = quoteIdent(schema);
|
|
109
|
+
return `create or replace function ${s}."workflow_version"() returns trigger
|
|
110
|
+
language plpgsql as $$
|
|
111
|
+
begin
|
|
112
|
+
if tg_op = 'INSERT' then
|
|
113
|
+
new.version := 1;
|
|
114
|
+
return new;
|
|
115
|
+
end if;
|
|
116
|
+
-- The write holds the row, so a save in another transaction numbers its version after this one.
|
|
117
|
+
new.version := case when new.definition is distinct from old.definition then coalesce(old.version, 0) + 1 else old.version end;
|
|
118
|
+
new.updated_at := now();
|
|
119
|
+
return new;
|
|
120
|
+
end
|
|
121
|
+
$$;
|
|
122
|
+
drop trigger if exists "workflow_version" on ${s}."workflows";
|
|
123
|
+
create trigger "workflow_version" before insert or update on ${s}."workflows" for each row execute function ${s}."workflow_version"();
|
|
124
|
+
|
|
125
|
+
create or replace function ${s}."run_version"() returns trigger
|
|
126
|
+
language plpgsql as $$
|
|
127
|
+
declare
|
|
128
|
+
found record;
|
|
129
|
+
begin
|
|
130
|
+
select w.id, w.version, w.tenant_id into found from ${s}."workflows" w where w.id = new.workflow_id;
|
|
131
|
+
-- Another tenant's workflow is refused as one that is not there, so a start tells nothing of other tenants.
|
|
132
|
+
if found.id is null or new.tenant_id is distinct from found.tenant_id then
|
|
133
|
+
raise exception 'There is no workflow % here', new.workflow_id using errcode = '23503';
|
|
134
|
+
end if;
|
|
135
|
+
if found.version is null then
|
|
136
|
+
raise exception 'Workflow % has no saved version', new.workflow_id using errcode = '23503';
|
|
137
|
+
end if;
|
|
138
|
+
new.version := found.version;
|
|
139
|
+
return new;
|
|
140
|
+
end
|
|
141
|
+
$$;
|
|
142
|
+
comment on function ${s}."run_version"() is '${workflowsComment}';
|
|
143
|
+
drop trigger if exists "run_version" on ${s}."runs";
|
|
144
|
+
create trigger "run_version" before insert on ${s}."runs" for each row execute function ${s}."run_version"()`;
|
|
145
|
+
};
|
|
146
|
+
//#endregion
|
|
147
|
+
export { commentVersion, functionsSql, indexesSql, tablesSql, workflowsComment, workflowsTriggers };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { WorkflowsOptions } from "./options.js";
|
|
2
|
+
import { Db } from "@protobase/query";
|
|
3
|
+
//#region src/workflows/schema.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The SQL that brings the `workflows` schema to what `workflows` needs: its tables, indexes and the triggers that number a
|
|
6
|
+
* workflow's versions and pin a run's; `undefined` when current. Tables are never dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare const workflowsMigration: (db: Db, options?: WorkflowsOptions) => Promise<string | undefined>;
|
|
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>;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { migrationCommand } from "../changes/capture-schema.js";
|
|
3
|
+
import { workflowsSchema } from "./options.js";
|
|
4
|
+
import { commentVersion, functionsSql, indexesSql, tablesSql, workflowsTriggers } from "./schema-sql.js";
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
//#region src/workflows/schema.ts
|
|
7
|
+
const readWorkflowsSchema = async (db, schema) => {
|
|
8
|
+
const steps = `${quoteIdent(schema)}."steps"`;
|
|
9
|
+
const { rows } = await sql`select to_regclass(${steps}) is not null as found,
|
|
10
|
+
(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
|
+
(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
|
+
where n.nspname = ${schema} and not t.tgisinternal and t.tgname = any(${workflowsTriggers})) as triggers`.execute(db);
|
|
13
|
+
const row = rows[0];
|
|
14
|
+
return {
|
|
15
|
+
tables: row.found,
|
|
16
|
+
current: row.found && commentVersion(row.comment) === 3 && row.triggers === workflowsTriggers.length
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The SQL that brings the `workflows` schema to what `workflows` needs: its tables, indexes and the triggers that number a
|
|
21
|
+
* workflow's versions and pin a run's; `undefined` when current. Tables are never dropped.
|
|
22
|
+
*/
|
|
23
|
+
const workflowsMigration = async (db, options = {}) => {
|
|
24
|
+
const schema = workflowsSchema(options);
|
|
25
|
+
const found = await readWorkflowsSchema(db, schema);
|
|
26
|
+
if (found.current) return void 0;
|
|
27
|
+
return `${[
|
|
28
|
+
`create schema if not exists ${quoteIdent(schema)}`,
|
|
29
|
+
...found.tables ? [] : [tablesSql(schema)],
|
|
30
|
+
indexesSql(schema),
|
|
31
|
+
functionsSql(schema)
|
|
32
|
+
].join(";\n\n")};`;
|
|
33
|
+
};
|
|
34
|
+
/** Refuses to go on while the `workflows` schema lacks what `workflows` needs; the project's migrations add it. */
|
|
35
|
+
const checkWorkflowsSchema = async (db, options = {}) => {
|
|
36
|
+
const schema = workflowsSchema(options);
|
|
37
|
+
if ((await readWorkflowsSchema(db, schema)).current) return;
|
|
38
|
+
throw new Error(`The ${schema} schema is missing or behind this version of Protobase. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { checkWorkflowsSchema, workflowsMigration };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineJob } from "../jobs/define-job.js";
|
|
2
|
+
import { postgresRunStore } from "./store.js";
|
|
3
|
+
import { createRunner } from "@protobase/workflows";
|
|
4
|
+
//#region src/workflows/segment-job.ts
|
|
5
|
+
/** The job that runs one segment of a workflow run. */
|
|
6
|
+
const segmentJobName = "workflows.segment";
|
|
7
|
+
/**
|
|
8
|
+
* The runner on the app's jobs: each segment is a `workflows.segment` job, kept once per run while it waits. Its
|
|
9
|
+
* timeout is above a segment's time plus the longest step's, so a job that times out is a worker that died, and its
|
|
10
|
+
* next try takes the run up from its last saved step.
|
|
11
|
+
*/
|
|
12
|
+
const workflowsRunner = ({ schema, tasks, secrets }) => {
|
|
13
|
+
let job;
|
|
14
|
+
const store = postgresRunStore(schema);
|
|
15
|
+
const queue = { schedule: async (db, run, at) => void await job.enqueue(db, { run }, {
|
|
16
|
+
key: run,
|
|
17
|
+
...at && { runAt: at }
|
|
18
|
+
}) };
|
|
19
|
+
const runner = createRunner({
|
|
20
|
+
store,
|
|
21
|
+
queue,
|
|
22
|
+
tasks: {},
|
|
23
|
+
secrets
|
|
24
|
+
});
|
|
25
|
+
job = defineJob({
|
|
26
|
+
name: segmentJobName,
|
|
27
|
+
run: ({ run }, context) => createRunner({
|
|
28
|
+
store,
|
|
29
|
+
queue,
|
|
30
|
+
tasks: tasks(context),
|
|
31
|
+
secrets
|
|
32
|
+
}).segment(context.db, run, context.signal).catch(async (error) => {
|
|
33
|
+
if (context.job.attempt >= context.job.maxAttempts) await store.update(context.db, run, {
|
|
34
|
+
status: "failed",
|
|
35
|
+
error: "States.Runtime",
|
|
36
|
+
cause: `The run's segment failed ${context.job.attempt} times in a row`
|
|
37
|
+
});
|
|
38
|
+
throw error;
|
|
39
|
+
}),
|
|
40
|
+
maxAttempts: 10,
|
|
41
|
+
retryDelay: "5 seconds",
|
|
42
|
+
timeout: "10 minutes",
|
|
43
|
+
concurrency: 5
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
runner,
|
|
47
|
+
job
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { segmentJobName, workflowsRunner };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { notFound } from "../problem.js";
|
|
2
|
+
import { requestAccess, requireOperation } from "../request-access.js";
|
|
3
|
+
import { deletedRows } from "../params.js";
|
|
4
|
+
import { fetchReadable } from "../fetch-readable.js";
|
|
5
|
+
import { checkRunInput } from "./input-limits.js";
|
|
6
|
+
//#region src/workflows/start-hook.ts
|
|
7
|
+
/**
|
|
8
|
+
* 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), and queues the run's first
|
|
10
|
+
* segment, which runs once the write commits. The resource records who started it, as the run's author.
|
|
11
|
+
*/
|
|
12
|
+
const runStartHook = (runner, deps) => async (event, trx) => {
|
|
13
|
+
if (event.resource.name !== "workflow_runs" || event.operation !== "create" || !event.after) return;
|
|
14
|
+
checkRunInput(event.after.input);
|
|
15
|
+
const entry = deps().registry.find("workflows");
|
|
16
|
+
const access = await requestAccess(deps(), entry, {
|
|
17
|
+
user: event.user,
|
|
18
|
+
...event.tenant !== void 0 && { tenant: event.tenant }
|
|
19
|
+
});
|
|
20
|
+
requireOperation(access, "read");
|
|
21
|
+
const key = access.full.fields[access.full.primaryKey[0]];
|
|
22
|
+
if (!await fetchReadable(trx, access, [String(event.after.workflow_id)], deletedRows(access.full, false), { fields: [key] })) throw notFound(`No ${entry.name} with this key`);
|
|
23
|
+
await runner.start(trx, String(event.after.id));
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { runStartHook };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { TenantValue } from "../types.js";
|
|
2
|
+
import { Db } from "@protobase/query";
|
|
3
|
+
//#region src/workflows/start-workflow.d.ts
|
|
4
|
+
export type StartWorkflowOptions = {
|
|
5
|
+
/** The workflow's id. */
|
|
6
|
+
workflow: string;
|
|
7
|
+
/** What its states read as `$states.context.Execution.Input`; at most 1 MiB as JSON, nested at most 64 deep. */
|
|
8
|
+
input?: unknown;
|
|
9
|
+
/** The workflow's tenant, in an app with tenants. */
|
|
10
|
+
tenant?: TenantValue;
|
|
11
|
+
/** Who or what started it, as the run records it. */
|
|
12
|
+
startedBy?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Starts it once per workflow and key, such as `orders:42` for what a trigger found ready: a start with a key the
|
|
15
|
+
* workflow has a run for already starts nothing and returns that run's id.
|
|
16
|
+
*/
|
|
17
|
+
key?: string;
|
|
18
|
+
/** The schema of `workflows`, when the config names another. Default `workflows`. */
|
|
19
|
+
schema?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* 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
|
+
* `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
|
|
24
|
+
*/
|
|
25
|
+
export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema }: StartWorkflowOptions) => Promise<string>;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { currentJobs } from "../jobs/current.js";
|
|
2
|
+
import { checkRunInput } from "./input-limits.js";
|
|
3
|
+
import { segmentJobName } from "./segment-job.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/workflows/start-workflow.ts
|
|
6
|
+
/**
|
|
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
|
+
* `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
|
|
9
|
+
*/
|
|
10
|
+
const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows" }) => {
|
|
11
|
+
checkRunInput(input);
|
|
12
|
+
const job = currentJobs().definition(segmentJobName);
|
|
13
|
+
if (!job) throw new Error("Workflows are not running in this app: add `workflows: {}` to protobase.config.ts");
|
|
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})
|
|
17
|
+
on conflict (workflow_id, key) do nothing returning id`.execute(db);
|
|
18
|
+
if (rows.length === 0) {
|
|
19
|
+
const existing = (await sql`select id, status from ${runs} where workflow_id = ${workflow} and key = ${key}`.execute(db)).rows[0];
|
|
20
|
+
if (existing.status === "queued") await job.enqueue(db, { run: existing.id }, { key: existing.id });
|
|
21
|
+
return existing.id;
|
|
22
|
+
}
|
|
23
|
+
const id = rows[0].id;
|
|
24
|
+
await job.enqueue(db, { run: id }, { key: id });
|
|
25
|
+
return id;
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { startWorkflow };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { httpStep } from "./http-step.js";
|
|
2
|
+
import { mailStep } from "./mail-step.js";
|
|
3
|
+
import { pieceStep } from "./piece-step.js";
|
|
4
|
+
import { recordsSteps } from "./records-step.js";
|
|
5
|
+
//#region src/workflows/step-types.ts
|
|
6
|
+
/** The step types a Task names as its `Resource`, as the language report lists them. */
|
|
7
|
+
const stepTypeNames = [
|
|
8
|
+
"app:http",
|
|
9
|
+
"app:records.create",
|
|
10
|
+
"app:records.update",
|
|
11
|
+
"app:records.get",
|
|
12
|
+
"app:records.list",
|
|
13
|
+
"app:mail.send",
|
|
14
|
+
"app:piece"
|
|
15
|
+
];
|
|
16
|
+
/** The step types of one segment, which sends mail with the mailer of the job that runs it. */
|
|
17
|
+
const stepTypes = ({ deps, schema, roles, allowPrivateAddresses }, mail) => {
|
|
18
|
+
const files = () => deps().files;
|
|
19
|
+
return {
|
|
20
|
+
"app:http": httpStep({
|
|
21
|
+
allowPrivateAddresses,
|
|
22
|
+
files
|
|
23
|
+
}),
|
|
24
|
+
...recordsSteps({
|
|
25
|
+
deps,
|
|
26
|
+
schema,
|
|
27
|
+
roles
|
|
28
|
+
}),
|
|
29
|
+
"app:mail.send": mailStep(() => mail, files),
|
|
30
|
+
"app:piece": pieceStep
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { stepTypeNames, stepTypes };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/workflows/store.ts
|
|
3
|
+
const json = (value) => value === void 0 ? null : JSON.stringify(value);
|
|
4
|
+
/** The runner's store over the `workflows` schema: runs with their pinned version, and their steps. */
|
|
5
|
+
const postgresRunStore = (schema) => {
|
|
6
|
+
const runs = sql.id(schema, "runs");
|
|
7
|
+
const versions = sql.id(schema, "versions");
|
|
8
|
+
const steps = sql.id(schema, "steps");
|
|
9
|
+
return {
|
|
10
|
+
load: async (db, id) => {
|
|
11
|
+
const { rows } = await sql`select r.id, v.definition, r.input, r.status, r.created_at as "startedAt"
|
|
12
|
+
from ${runs} r join ${versions} v on v.workflow_id = r.workflow_id and v.version = r.version where r.id = ${id}`.execute(db);
|
|
13
|
+
return rows[0];
|
|
14
|
+
},
|
|
15
|
+
steps: async (db, id) => {
|
|
16
|
+
const { rows } = await sql`select scope, seq, state, status, attempt, output, assigned, next, resume_at as "resumeAt", error, cause
|
|
17
|
+
from ${steps} where run_id = ${id} order by scope, seq`.execute(db);
|
|
18
|
+
return rows;
|
|
19
|
+
},
|
|
20
|
+
save: async (db, id, step) => {
|
|
21
|
+
const { rows } = await sql`insert into ${steps}
|
|
22
|
+
(run_id, tenant_id, scope, seq, state, type, status, attempt, input, output, assigned, next, resume_at, error, cause, logs, started_at, finished_at)
|
|
23
|
+
select r.id, r.tenant_id, ${step.scope}, ${step.seq}, ${step.state}, ${step.type}, ${step.status}, ${step.attempt}, ${json(step.input)}::jsonb,
|
|
24
|
+
${json(step.output)}::jsonb, ${json(step.assigned)}::jsonb, ${step.next}, ${step.resumeAt ?? null}, ${step.error ?? null}, ${step.cause ?? null},
|
|
25
|
+
${JSON.stringify(step.logs)}::jsonb, ${step.startedAt}, ${step.finishedAt}
|
|
26
|
+
from ${runs} r where r.id = ${id}
|
|
27
|
+
on conflict (run_id, scope, seq) do nothing returning id`.execute(db);
|
|
28
|
+
return rows.length > 0;
|
|
29
|
+
},
|
|
30
|
+
update: async (db, id, change) => {
|
|
31
|
+
const ended = change.status === "succeeded" || change.status === "failed";
|
|
32
|
+
await sql`update ${runs} set status = ${change.status},
|
|
33
|
+
resume_at = ${change.status === "waiting" ? change.resumeAt : null},
|
|
34
|
+
output = ${change.status === "succeeded" ? json(change.output) : null}::jsonb,
|
|
35
|
+
error = ${change.status === "failed" ? change.error : null}, cause = ${change.status === "failed" ? change.cause : null},
|
|
36
|
+
finished_at = ${ended ? sql`now()` : sql`null`}
|
|
37
|
+
where id = ${id} and status not in ('succeeded', 'failed')`.execute(db);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { postgresRunStore };
|