@protobase/server 0.67.7 → 0.67.9

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.
@@ -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 };
@@ -0,0 +1,37 @@
1
+ import { betterAuthOwnerRoles } from "../webhooks/table/owner-roles.js";
2
+ import { runInputCheck } from "./input-limits.js";
3
+ import { workflowSecrets, workflowsSchema } from "./options.js";
4
+ import { workflowSaveHook } from "./save-hook.js";
5
+ import { workflowsRunner } from "./segment-job.js";
6
+ import { runStartHook } from "./start-hook.js";
7
+ import { stepTypeNames, stepTypes } from "./step-types.js";
8
+ //#region src/workflows/workflow-engine.ts
9
+ /**
10
+ * 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 job, whose step
12
+ * types act on records as each workflow's last editor (from Better Auth unless the options say otherwise).
13
+ */
14
+ const workflowEngine = (options, deps, auth) => {
15
+ const schema = workflowsSchema(options);
16
+ const roles = options.savedByRoles ?? (auth && betterAuthOwnerRoles(auth));
17
+ if (!roles) throw new Error("workflows act on records as each workflow's last editor, with their current roles: give the config `auth` (Better Auth), or `workflows.savedByRoles`");
18
+ const input = {
19
+ deps,
20
+ schema,
21
+ roles,
22
+ allowPrivateAddresses: Boolean(options.allowPrivateAddresses)
23
+ };
24
+ const secrets = workflowSecrets(options, globalThis.process?.env ?? {});
25
+ const { runner, job } = workflowsRunner({
26
+ schema,
27
+ tasks: (context) => stepTypes(input, context.mail),
28
+ secrets
29
+ });
30
+ return {
31
+ hooks: [workflowSaveHook(schema, stepTypeNames), runStartHook(runner, deps)],
32
+ writeChecks: [runInputCheck],
33
+ job
34
+ };
35
+ };
36
+ //#endregion
37
+ export { workflowEngine };