@protobase/server 0.67.11 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/batch/run-batch.d.ts +1 -0
  2. package/dist/batch/run-batch.js +6 -4
  3. package/dist/create-admin.js +7 -4
  4. package/dist/idempotency.d.ts +1 -0
  5. package/dist/idempotency.js +12 -0
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.js +3 -2
  8. package/dist/mcp/mcp-tools.d.ts +4 -2
  9. package/dist/mcp/mcp-tools.js +9 -3
  10. package/dist/package.js +1 -1
  11. package/dist/webhooks/attempt.js +2 -2
  12. package/dist/webhooks/table/address-check.js +1 -1
  13. package/dist/webhooks/table/webhook-table.js +1 -1
  14. package/dist/webhooks/webhooks.js +2 -1
  15. package/dist/workflows/effects-store.js +18 -0
  16. package/dist/workflows/input-limits.js +4 -2
  17. package/dist/workflows/mcp/workflow-instructions.js +1 -1
  18. package/dist/workflows/mcp/workflow-read-tools.d.ts +11 -1
  19. package/dist/workflows/mcp/workflow-read-tools.js +16 -19
  20. package/dist/workflows/mcp/workflow-write-tools.js +12 -3
  21. package/dist/workflows/options.d.ts +6 -0
  22. package/dist/workflows/plugin-fetch.js +40 -0
  23. package/dist/workflows/plugin-records.d.ts +10 -0
  24. package/dist/workflows/plugin-records.js +120 -0
  25. package/dist/workflows/plugin-services.d.ts +13 -0
  26. package/dist/workflows/plugin-services.js +32 -0
  27. package/dist/workflows/ready/ready-check.js +62 -0
  28. package/dist/workflows/ready/ready-start.js +104 -0
  29. package/dist/workflows/ready/ready-when.js +101 -0
  30. package/dist/workflows/rerun.js +36 -0
  31. package/dist/workflows/run-files.js +3 -7
  32. package/dist/workflows/save-hook.js +15 -13
  33. package/dist/workflows/schema-sql.js +72 -4
  34. package/dist/workflows/schema.d.ts +2 -2
  35. package/dist/workflows/schema.js +6 -2
  36. package/dist/workflows/start-hook.js +9 -3
  37. package/dist/workflows/start-workflow.d.ts +3 -1
  38. package/dist/workflows/start-workflow.js +3 -3
  39. package/dist/workflows/triggers/trigger-jobs.js +114 -0
  40. package/dist/workflows/triggers/trigger-row.js +36 -0
  41. package/dist/workflows/triggers/trigger-sync.js +48 -0
  42. package/dist/workflows/workflow-engine.js +17 -5
  43. package/dist/workflows/workflow-types-route.js +22 -0
  44. package/dist/workflows/workflow-types.d.ts +22 -0
  45. package/dist/workflows/workflow-types.js +63 -0
  46. package/dist/workflows/workflows-module.d.ts +27 -1
  47. package/package.json +6 -5
  48. package/dist/workflows/http-step.js +0 -103
  49. package/dist/workflows/mail-step.js +0 -58
  50. package/dist/workflows/mcp/type-catalog.js +0 -144
  51. package/dist/workflows/piece-step.js +0 -11
  52. package/dist/workflows/records-step.js +0 -114
  53. package/dist/workflows/step-types.js +0 -27
@@ -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>;
@@ -54,6 +56,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
54
56
  readonly cause: import("@protobase/schema").Field<string | null>;
55
57
  readonly resume_at: import("@protobase/schema").Field<string | null>;
56
58
  readonly started_by: import("@protobase/schema").Field<string | null>;
59
+ readonly rerun_of: import("@protobase/schema").Field<string | number | null>;
57
60
  readonly tenant_id: import("@protobase/schema").Field<string | null>;
58
61
  readonly created_at: import("@protobase/schema").Field<string>;
59
62
  readonly finished_at: import("@protobase/schema").Field<string | null>;
@@ -71,6 +74,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
71
74
  readonly cause: import("@protobase/schema").Field<string | null>;
72
75
  readonly resume_at: import("@protobase/schema").Field<string | null>;
73
76
  readonly started_by: import("@protobase/schema").Field<string | null>;
77
+ readonly rerun_of: import("@protobase/schema").Field<string | number | null>;
74
78
  readonly tenant_id: import("@protobase/schema").Field<string | null>;
75
79
  readonly created_at: import("@protobase/schema").Field<string>;
76
80
  readonly finished_at: import("@protobase/schema").Field<string | null>;
@@ -120,5 +124,27 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
120
124
  readonly finished_at: import("@protobase/schema").Field<string>;
121
125
  readonly duration_ms: import("@protobase/schema").Field<string | null>;
122
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>>>;
123
149
  };
124
150
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.67.11",
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.67.11",
48
- "@protobase/query": "0.67.11",
49
- "@protobase/schema": "0.67.11",
50
- "@protobase/workflows": "0.67.11",
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 };
@@ -1,144 +0,0 @@
1
- //#region src/workflows/mcp/type-catalog.ts
2
- const needs = (takes) => ({
3
- required: true,
4
- takes
5
- });
6
- const may = (takes) => ({
7
- required: false,
8
- takes
9
- });
10
- const resource = needs("the resource's name, as list_resources gives it");
11
- const key = needs("the record's key");
12
- const refused = ["Records.Refused: the API refused it, its status and message in the cause", "Records.InvalidArguments: an argument has the wrong type, named in the cause"];
13
- /** The step types a Task names as its `Resource`, as the workflows reference describes them. */
14
- const stepTypeCatalog = [
15
- {
16
- resource: "app:http",
17
- does: "Sends one HTTP request to a public address and reads the answer.",
18
- arguments: {
19
- url: needs("an http or https URL"),
20
- method: may("GET (default), POST, PUT, PATCH or DELETE"),
21
- headers: may("an object of text values"),
22
- query: may("an object of text values, added to the URL's query"),
23
- body: may("text sent as it is, a file value sent as its bytes, any other value sent as JSON; a GET sends none"),
24
- response: may("how the answer's body is read: json (default; empty is null), text, or file (kept as a file value of the run)")
25
- },
26
- result: "{ status, headers, body }",
27
- errors: [
28
- "Http.Status: a status outside 2xx; the cause names it with the start of the body",
29
- "Http.Network: no answer",
30
- "Http.InvalidArguments: an argument has the wrong type, named in the cause",
31
- "Http.InvalidBody: response json, and the body is not JSON",
32
- "Http.AddressRefused: the host is not a public address",
33
- "Http.ResponseTooLarge: a body over 10 MiB"
34
- ]
35
- },
36
- {
37
- resource: "app:records.create",
38
- does: "Creates records of the app's resources, as the workflow's last editor.",
39
- arguments: {
40
- resource,
41
- data: needs("an object of field values, or a list of them, created in one batch: all or none")
42
- },
43
- result: "the created record, or the list of them",
44
- errors: refused
45
- },
46
- {
47
- resource: "app:records.update",
48
- does: "Updates one record, as the workflow's last editor.",
49
- arguments: {
50
- resource,
51
- key,
52
- data: needs("an object of the field values to change")
53
- },
54
- result: "the updated record",
55
- errors: refused
56
- },
57
- {
58
- resource: "app:records.get",
59
- does: "Reads one record, as the workflow's last editor.",
60
- arguments: {
61
- resource,
62
- key
63
- },
64
- result: "the record",
65
- errors: refused
66
- },
67
- {
68
- resource: "app:records.list",
69
- does: "Lists records, as the workflow's last editor.",
70
- arguments: {
71
- resource,
72
- filter: may("a filter in the API's syntax, such as status = \"open\""),
73
- order_by: may("such as \"created_at desc\""),
74
- limit: may("how many at most")
75
- },
76
- result: "{ records }",
77
- errors: refused
78
- },
79
- {
80
- resource: "app:mail.send",
81
- does: "Sends a mail from the app's own sender.",
82
- arguments: {
83
- to: needs("one address, or a list of at most 50"),
84
- subject: needs("one line of text"),
85
- text: needs("the body as text"),
86
- html: may("the body as HTML too"),
87
- attachments: may("a list of file values the run keeps, at most 10 MiB together")
88
- },
89
- result: "{ message_id }",
90
- errors: ["Mail.InvalidArguments: an argument has the wrong type, named in the cause", "States.TaskFailed: the app sends no mail, or the mailer refused it"]
91
- },
92
- {
93
- resource: "app:piece",
94
- does: "Runs an action of a connector. Not implemented yet: a run fails it.",
95
- arguments: {
96
- piece: needs("the connector's name"),
97
- action: needs("one of its actions"),
98
- connection: needs("the connection holding its sign-in"),
99
- props: may("the action's arguments, by name")
100
- },
101
- result: "what the action returns",
102
- errors: ["Piece.Error: always, until connectors are implemented"]
103
- }
104
- ];
105
- /** The triggers a definition's `trigger` holds exactly one of. */
106
- const triggerCatalog = [
107
- {
108
- trigger: "manual",
109
- example: { manual: {} },
110
- starts: "when someone runs the workflow, such as with run_workflow",
111
- input: "the input they give"
112
- },
113
- {
114
- trigger: "webhook",
115
- example: { webhook: {} },
116
- starts: "on each POST to the workflow's webhook address",
117
- input: "{ body, headers, query }"
118
- },
119
- {
120
- trigger: "schedule",
121
- example: { schedule: {
122
- cron: "0 6 * * 1-5",
123
- timezone: "Europe/Amsterdam"
124
- } },
125
- starts: "on a 5-field cron, in an IANA time zone (default UTC)",
126
- input: "{ scheduled_at }, ISO 8601 with the zone's offset"
127
- },
128
- {
129
- trigger: "mail",
130
- example: { mail: { mailbox: "support" } },
131
- starts: "on each mail arriving in that mailbox",
132
- input: "{ from, to, subject, text, html, received_at, attachments }"
133
- },
134
- {
135
- trigger: "record",
136
- example: { record: { events: ["orders.created"] } },
137
- starts: "on each of those record events, <resource>.<event>",
138
- input: "{ event, resource, key, after, before, changed }"
139
- }
140
- ];
141
- /** Only manual runs start for now: a save takes the other triggers, which start nothing yet. */
142
- const triggerNote = "Runs start only by hand (run_workflow) for now: a workflow saves with any trigger, and the other triggers start no runs yet.";
143
- //#endregion
144
- export { stepTypeCatalog, triggerCatalog, triggerNote };
@@ -1,11 +0,0 @@
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 };
@@ -1,114 +0,0 @@
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 };
@@ -1,27 +0,0 @@
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
- import { workflowStepTypes } from "@protobase/schema/internal";
6
- //#region src/workflows/step-types.ts
7
- /** The step types a Task names as its `Resource`, as the editor offers them. */
8
- const stepTypeNames = Object.keys(workflowStepTypes);
9
- /** The step types of one segment, which sends mail with the mailer of the job that runs it. */
10
- const stepTypes = ({ deps, schema, roles, allowPrivateAddresses }, mail) => {
11
- const files = () => deps().files;
12
- return {
13
- "app:http": httpStep({
14
- allowPrivateAddresses,
15
- files
16
- }),
17
- ...recordsSteps({
18
- deps,
19
- schema,
20
- roles
21
- }),
22
- "app:mail.send": mailStep(() => mail, files),
23
- "app:piece": pieceStep
24
- };
25
- };
26
- //#endregion
27
- export { stepTypeNames, stepTypes };