@protobase/server 0.67.10 → 0.67.12

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/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.67.10";
2
+ var version = "0.67.12";
3
3
  //#endregion
4
4
  export { version };
@@ -12,11 +12,13 @@ const checkRunInput = (input) => {
12
12
  };
13
13
  /**
14
14
  * The same limits on a run's input as the API sent it, before the body is validated, whose walk of a deeply nested
15
- * value would overflow the call stack.
15
+ * value would overflow the call stack; a re-run takes the input of the run it re-runs, so it is given none.
16
16
  */
17
17
  const runInputCheck = async ({ resource, operation, body }) => {
18
18
  if (resource !== "workflow_runs" || operation !== "create" || typeof body !== "object" || body === null) return;
19
- checkRunInput(body.input);
19
+ const { input, rerun_of } = body;
20
+ if (rerun_of != null && input != null) throw badRequest("rerun-input", "A re-run runs on the input of the run it re-runs: give it no input");
21
+ checkRunInput(input);
20
22
  };
21
23
  //#endregion
22
24
  export { checkRunInput, runInputCheck };
@@ -121,6 +121,8 @@ const runFields = [
121
121
  "id",
122
122
  "workflow_id",
123
123
  "version",
124
+ "rerun_of",
125
+ "started_by",
124
126
  "status",
125
127
  "input",
126
128
  "output",
@@ -108,12 +108,16 @@ const updateWorkflowTool = (input = {}) => defineTool({
108
108
  /** Starts a run of a workflow with an input once the user approves it. */
109
109
  const runWorkflowTool = (input = {}) => defineTool({
110
110
  name: "run_workflow",
111
- description: `Starts a run of a workflow's current version with an input, which its states read as $states.context.Execution.Input, and returns JSON { run_id, status }. The run goes on in the background, doing what its steps do (requests, mail, record writes) as the workflow's last editor: read it with get_workflow_run until its status is succeeded or failed. The user approves each run in their client first.${onlyNote(input)}`,
111
+ description: `Starts a run of a workflow's current version with an input, which its states read as $states.context.Execution.Input, and returns JSON { run_id, status }. With rerun_of, the id of a failed run of the workflow, it re-runs that run instead: on its input, keeping the steps before the one that failed, and going on from that step on the current version, so fix the workflow first. The run goes on in the background, doing what its steps do (requests, mail, record writes) as the workflow's last editor: read it with get_workflow_run until its status is succeeded or failed. The user approves each run in their client first.${onlyNote(input)}`,
112
112
  parameters: {
113
113
  type: "object",
114
114
  properties: {
115
115
  id: idParameter,
116
- input: { description: "The run's input, any JSON value, at most 1 MiB; an empty object by default." }
116
+ input: { description: "The run's input, any JSON value, at most 1 MiB; an empty object by default. Not with rerun_of." },
117
+ rerun_of: {
118
+ type: "string",
119
+ description: "The id of a failed run of this workflow to re-run from its failed step."
120
+ }
117
121
  },
118
122
  required: ["id"]
119
123
  },
@@ -126,7 +130,12 @@ const runWorkflowTool = (input = {}) => defineTool({
126
130
  if (!id) return "Pass { \"id\": \"<id>\", \"input\": { ... } }.";
127
131
  const refusal = outside(input, "workflow_runs");
128
132
  if (refusal) return refusal;
129
- const data = {
133
+ const rerun = textOf(args.rerun_of);
134
+ if (rerun && args.input !== void 0) return "A re-run runs on the input of the run it re-runs: pass rerun_of without input.";
135
+ const data = rerun ? {
136
+ workflow_id: id,
137
+ rerun_of: rerun
138
+ } : {
130
139
  workflow_id: id,
131
140
  input: args.input ?? {}
132
141
  };
@@ -0,0 +1,36 @@
1
+ import { badRequest, 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 { sql } from "kysely";
6
+ import { rerunSteps } from "@protobase/workflows";
7
+ //#region src/workflows/rerun.ts
8
+ /**
9
+ * Starts a re-run where the failed run it re-runs left off: refuses a run the caller cannot read (404), one of another
10
+ * workflow and one that did not fail, and copies the steps a re-run keeps to the new run, in one statement, so the
11
+ * runner takes it up after them on the workflow's version now. The table's trigger gave it the earlier run's input.
12
+ */
13
+ const keepEarlierSteps = async (trx, deps, schema, event) => {
14
+ const run = String(event.after.id);
15
+ const earlier = String(event.after.rerun_of);
16
+ const access = await requestAccess(deps, deps.registry.find("workflow_runs"), {
17
+ user: event.user,
18
+ ...event.tenant !== void 0 && { tenant: event.tenant }
19
+ });
20
+ requireOperation(access, "read");
21
+ const found = await fetchReadable(trx, access, [earlier], deletedRows(access.full, false), { fields: [access.full.fields.workflow_id, access.full.fields.status] });
22
+ if (!found || found.record.workflow_id !== event.after.workflow_id) throw notFound("No run of this workflow to re-run with this key");
23
+ if (found.record.status !== "failed") throw badRequest("run-not-failed", `Only a failed run is re-run; this one is ${String(found.record.status)}`);
24
+ const { rows: versions } = await sql`select v.definition from ${sql.id(schema, "runs")} r
25
+ join ${sql.id(schema, "versions")} v on v.workflow_id = r.workflow_id and v.version = r.version where r.id = ${run}`.execute(trx);
26
+ const steps = sql.id(schema, "steps");
27
+ const { rows } = await sql`select id, scope, seq, state, status, next from ${steps} where run_id = ${earlier}`.execute(trx);
28
+ const kept = rerunSteps(versions[0].definition.stateMachine, rows);
29
+ if ("problem" in kept) throw badRequest("rerun-refused", `The run cannot be re-run on the workflow as it is now: ${kept.problem}`);
30
+ if (kept.steps.length === 0) return;
31
+ const columns = sql.raw("scope, seq, state, type, status, attempt, input, output, assigned, next, resume_at, error, cause, logs, started_at, finished_at");
32
+ await sql`insert into ${steps} (run_id, tenant_id, ${columns}) select ${run}, tenant_id, ${columns} from ${steps}
33
+ where id in (${sql.join(kept.steps.map((step) => step.id))})`.execute(trx);
34
+ };
35
+ //#endregion
36
+ export { keepEarlierSteps };
@@ -1,18 +1,37 @@
1
1
  import { badRequest } from "../problem.js";
2
+ import { requiredStepInputs } from "@protobase/schema/internal";
2
3
  import { sql } from "kysely";
3
4
  import { checkDefinition, problemText } from "@protobase/workflows";
4
5
  //#region src/workflows/save-hook.ts
5
6
  /**
6
7
  * Checks a workflow's definition on every save that changes it, against the language's schema and the named-reads
7
8
  * 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
+ * caller. A refusal is a 400 problem naming each state and field, also listed as `problems`, and as the `definition`
10
+ * field's `errors` with the state and field in their `params`, for a form to show each on its step; the write rolls back.
9
11
  */
10
12
  const workflowSaveHook = (schema, stepTypes) => async (event, trx) => {
11
13
  if (event.resource.name !== "workflows" || !event.after) return;
12
14
  if (event.operation !== "create" && event.operation !== "update") return;
13
15
  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
+ const problems = checkDefinition(event.after.definition, {
17
+ resources: stepTypes,
18
+ required: requiredStepInputs
19
+ });
20
+ if (problems.length > 0) {
21
+ const errors = problems.map((problem) => ({
22
+ code: "invalid-workflow",
23
+ field: "definition",
24
+ message: problemText(problem),
25
+ params: {
26
+ state: problem.state,
27
+ field: problem.field
28
+ }
29
+ }));
30
+ throw badRequest("invalid-workflow", `The workflow cannot be saved: ${problems.map(problemText).join("; ")}`, {
31
+ problems,
32
+ errors
33
+ });
34
+ }
16
35
  await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
17
36
  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
37
  };
@@ -1,6 +1,6 @@
1
1
  import { quoteIdent } from "../changes/capture-sql.js";
2
2
  /** The comment the schema's function carries, which the startup check reads. */
3
- const workflowsComment = `Protobase workflows 3`;
3
+ const workflowsComment = `Protobase workflows 4`;
4
4
  const commentVersion = (comment) => {
5
5
  const match = comment?.match(/^Protobase workflows (\d+)$/);
6
6
  return match ? Number(match[1]) : void 0;
@@ -46,6 +46,7 @@ create table if not exists ${s}."runs" (
46
46
  "cause" text,
47
47
  "resume_at" timestamptz,
48
48
  "started_by" text,
49
+ "rerun_of" uuid references ${s}."runs" ("id") on delete set null,
49
50
  "tenant_id" text,
50
51
  "created_at" timestamptz not null default now(),
51
52
  "finished_at" timestamptz,
@@ -88,6 +89,8 @@ create index if not exists "workflows_name_search" on ${s}."workflows" using gin
88
89
  create index if not exists "versions_tenant" on ${s}."versions" ("tenant_id");
89
90
  alter table ${s}."runs" add column if not exists "key" text;
90
91
  create unique index if not exists "runs_key" on ${s}."runs" ("workflow_id", "key");
92
+ alter table ${s}."runs" add column if not exists "rerun_of" uuid references ${s}."runs" ("id") on delete set null;
93
+ create index if not exists "runs_rerun_of" on ${s}."runs" ("rerun_of");
91
94
  create index if not exists "runs_workflow" on ${s}."runs" ("workflow_id", "created_at");
92
95
  create index if not exists "runs_tenant" on ${s}."runs" ("tenant_id");
93
96
  create index if not exists "runs_status" on ${s}."runs" ("status");
@@ -102,7 +105,7 @@ const workflowsTriggers = ["workflow_version", "run_version"];
102
105
  * A workflow numbers its next version as a save changes its definition, in the row the write returns, so its ETag is
103
106
  * the saved row's; the save hook keeps that version's definition and who saved it. A run takes its workflow's current
104
107
  * 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.
108
+ * saved version. A re-run takes the input of the run it re-runs, which the start hook checks.
106
109
  */
107
110
  const functionsSql = (schema) => {
108
111
  const s = quoteIdent(schema);
@@ -136,6 +139,9 @@ begin
136
139
  raise exception 'Workflow % has no saved version', new.workflow_id using errcode = '23503';
137
140
  end if;
138
141
  new.version := found.version;
142
+ if new.rerun_of is not null then
143
+ select r.input into new.input from ${s}."runs" r where r.id = new.rerun_of and r.workflow_id = new.workflow_id;
144
+ end if;
139
145
  return new;
140
146
  end
141
147
  $$;
@@ -13,7 +13,7 @@ const readWorkflowsSchema = async (db, schema) => {
13
13
  const row = rows[0];
14
14
  return {
15
15
  tables: row.found,
16
- current: row.found && commentVersion(row.comment) === 3 && row.triggers === workflowsTriggers.length
16
+ current: row.found && commentVersion(row.comment) === 4 && row.triggers === workflowsTriggers.length
17
17
  };
18
18
  };
19
19
  /**
@@ -3,13 +3,15 @@ import { requestAccess, requireOperation } from "../request-access.js";
3
3
  import { deletedRows } from "../params.js";
4
4
  import { fetchReadable } from "../fetch-readable.js";
5
5
  import { checkRunInput } from "./input-limits.js";
6
+ import { keepEarlierSteps } from "./rerun.js";
6
7
  //#region src/workflows/start-hook.ts
7
8
  /**
8
9
  * Starts a run as it is created through the API, in the write's transaction: it refuses an input over its limits and
9
- * a workflow the caller cannot read in their tenant (404, as the API answers for it), 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.
10
+ * a workflow the caller cannot read in their tenant (404, as the API answers for it), takes a re-run up after the steps
11
+ * it keeps of the run it re-runs, and queues the run's first segment, which runs once the write commits. The resource
12
+ * records who started it, as the run's author.
11
13
  */
12
- const runStartHook = (runner, deps) => async (event, trx) => {
14
+ const runStartHook = (runner, deps, schema) => async (event, trx) => {
13
15
  if (event.resource.name !== "workflow_runs" || event.operation !== "create" || !event.after) return;
14
16
  checkRunInput(event.after.input);
15
17
  const entry = deps().registry.find("workflows");
@@ -20,6 +22,10 @@ const runStartHook = (runner, deps) => async (event, trx) => {
20
22
  requireOperation(access, "read");
21
23
  const key = access.full.fields[access.full.primaryKey[0]];
22
24
  if (!await fetchReadable(trx, access, [String(event.after.workflow_id)], deletedRows(access.full, false), { fields: [key] })) throw notFound(`No ${entry.name} with this key`);
25
+ if (event.after.rerun_of) await keepEarlierSteps(trx, deps(), schema, {
26
+ ...event,
27
+ after: event.after
28
+ });
23
29
  await runner.start(trx, String(event.after.id));
24
30
  };
25
31
  //#endregion
@@ -2,17 +2,10 @@ import { httpStep } from "./http-step.js";
2
2
  import { mailStep } from "./mail-step.js";
3
3
  import { pieceStep } from "./piece-step.js";
4
4
  import { recordsSteps } from "./records-step.js";
5
+ import { workflowStepTypes } from "@protobase/schema/internal";
5
6
  //#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
- ];
7
+ /** The step types a Task names as its `Resource`, as the editor offers them. */
8
+ const stepTypeNames = Object.keys(workflowStepTypes);
16
9
  /** The step types of one segment, which sends mail with the mailer of the job that runs it. */
17
10
  const stepTypes = ({ deps, schema, roles, allowPrivateAddresses }, mail) => {
18
11
  const files = () => deps().files;
@@ -17,15 +17,18 @@ const postgresRunStore = (schema) => {
17
17
  from ${steps} where run_id = ${id} order by scope, seq`.execute(db);
18
18
  return rows;
19
19
  },
20
- save: async (db, id, step) => {
20
+ save: async (db, id, records) => {
21
+ const row = (step) => sql`(${step.scope}, ${step.seq}::integer, ${step.state}, ${step.type}, ${step.status}, ${step.attempt}::integer,
22
+ ${json(step.input)}::jsonb, ${json(step.output)}::jsonb, ${json(step.assigned)}::jsonb, ${step.next}, ${step.resumeAt ?? null}::timestamptz,
23
+ ${step.error ?? null}, ${step.cause ?? null}, ${JSON.stringify(step.logs)}::jsonb, ${step.startedAt}::timestamptz, ${step.finishedAt}::timestamptz)`;
21
24
  const { rows } = await sql`insert into ${steps}
22
25
  (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}
26
+ select r.id, r.tenant_id, s.*
27
+ from ${runs} r, (values ${sql.join(records.map(row))})
28
+ as s (scope, seq, state, type, status, attempt, input, output, assigned, next, resume_at, error, cause, logs, started_at, finished_at)
29
+ where r.id = ${id}
27
30
  on conflict (run_id, scope, seq) do nothing returning id`.execute(db);
28
- return rows.length > 0;
31
+ return rows.length === records.length;
29
32
  },
30
33
  update: async (db, id, change) => {
31
34
  const ended = change.status === "succeeded" || change.status === "failed";
@@ -28,7 +28,7 @@ const workflowEngine = (options, deps, auth) => {
28
28
  secrets
29
29
  });
30
30
  return {
31
- hooks: [workflowSaveHook(schema, stepTypeNames), runStartHook(runner, deps)],
31
+ hooks: [workflowSaveHook(schema, stepTypeNames), runStartHook(runner, deps, schema)],
32
32
  writeChecks: [runInputCheck],
33
33
  job
34
34
  };
@@ -54,6 +54,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
54
54
  readonly cause: import("@protobase/schema").Field<string | null>;
55
55
  readonly resume_at: import("@protobase/schema").Field<string | null>;
56
56
  readonly started_by: import("@protobase/schema").Field<string | null>;
57
+ readonly rerun_of: import("@protobase/schema").Field<string | number | null>;
57
58
  readonly tenant_id: import("@protobase/schema").Field<string | null>;
58
59
  readonly created_at: import("@protobase/schema").Field<string>;
59
60
  readonly finished_at: import("@protobase/schema").Field<string | null>;
@@ -71,6 +72,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
71
72
  readonly cause: import("@protobase/schema").Field<string | null>;
72
73
  readonly resume_at: import("@protobase/schema").Field<string | null>;
73
74
  readonly started_by: import("@protobase/schema").Field<string | null>;
75
+ readonly rerun_of: import("@protobase/schema").Field<string | number | null>;
74
76
  readonly tenant_id: import("@protobase/schema").Field<string | null>;
75
77
  readonly created_at: import("@protobase/schema").Field<string>;
76
78
  readonly finished_at: import("@protobase/schema").Field<string | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.67.10",
3
+ "version": "0.67.12",
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,10 @@
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.67.10",
48
- "@protobase/query": "0.67.10",
49
- "@protobase/schema": "0.67.10",
50
- "@protobase/workflows": "0.67.10",
47
+ "@protobase/layout": "0.67.12",
48
+ "@protobase/query": "0.67.12",
49
+ "@protobase/schema": "0.67.12",
50
+ "@protobase/workflows": "0.67.12",
51
51
  "@react-email/render": "^2.1.0",
52
52
  "@react-pdf/primitives": "^4.4.0",
53
53
  "@react-pdf/renderer": "^4.9.0",