@protobase/cli 0.67.12 → 0.68.1
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/build/config-bundle.js +1 -1
- package/dist/cli/package.js +1 -1
- package/dist/config/package-entry.js +49 -0
- package/dist/config/project-files.js +6 -3
- package/dist/config/project-parts.js +18 -3
- package/dist/host-modules.json +1 -0
- package/dist/serve/host-module-ids.js +1 -0
- package/dist/serve/host-modules.js +2 -0
- package/dist/serve/load-bundle.js +1 -0
- package/package.json +8 -7
- package/skills/protobase/SKILL.md +1 -0
- package/skills/protobase/references/features/actions.md +1 -0
- package/skills/protobase/references/features/navigation.md +1 -0
- package/skills/protobase/references/features/record-pages.md +1 -0
- package/skills/protobase/references/features/user-tables.md +1 -0
- package/skills/protobase/references/features/workflows.md +8 -2
- package/skills/protobase/references/reference/config/jobs-and-events.md +13 -2
- package/skills/protobase/references/reference/config.md +2 -0
- package/skills/protobase/references/reference/languages.md +2 -0
- package/skills/protobase/references/reference/layouts.md +12 -2
- package/skills/protobase/references/reference/mcp.md +5 -4
- package/skills/protobase/references/reference/sidebar.md +4 -0
- package/skills/protobase/references/reference/ui-config.md +2 -1
- package/skills/protobase/references/reference/user-tables.md +20 -4
- package/skills/protobase/references/reference/versioning.md +1 -1
- package/skills/protobase/references/reference/workflow-plugins.md +119 -0
- package/skills/protobase/references/reference/workflows.md +42 -22
|
@@ -88,7 +88,7 @@ const buildConfigBundle = async ({ projectDir, outFile, bun, extensions = [], co
|
|
|
88
88
|
collectComponents(components),
|
|
89
89
|
extensionImports(projectDir, extensions),
|
|
90
90
|
servedImports(native),
|
|
91
|
-
entry(file, bundleEntryCode(projectDir, extensions, config.files?.images ? [config.files.images] : []))
|
|
91
|
+
entry(file, bundleEntryCode(projectDir, extensions, [...config.files?.images ? [config.files.images] : [], ...config.workflows?.plugins ?? []]))
|
|
92
92
|
];
|
|
93
93
|
await viteBundle({
|
|
94
94
|
root: projectDir,
|
package/dist/cli/package.js
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { findPackageDirSync, packageName } from "../build/packages/package-dir.js";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
//#region src/config/package-entry.ts
|
|
5
|
+
const conditions = [
|
|
6
|
+
"node",
|
|
7
|
+
"import",
|
|
8
|
+
"default"
|
|
9
|
+
];
|
|
10
|
+
const targetOf = (target) => {
|
|
11
|
+
if (target === void 0 || target === null) return void 0;
|
|
12
|
+
if (typeof target === "string") return target;
|
|
13
|
+
if (Array.isArray(target)) return target.map(targetOf).find((found) => found !== void 0);
|
|
14
|
+
const condition = Object.keys(target).find((key) => conditions.includes(key));
|
|
15
|
+
return condition === void 0 ? void 0 : targetOf(target[condition]);
|
|
16
|
+
};
|
|
17
|
+
const exportOf = (exported, subpath) => {
|
|
18
|
+
if (typeof exported === "string" || Array.isArray(exported) || exported === null) return subpath === "." ? targetOf(exported) : void 0;
|
|
19
|
+
if (!Object.keys(exported).every((key) => key.startsWith("."))) return subpath === "." ? targetOf(exported) : void 0;
|
|
20
|
+
return targetOf(exported[subpath]);
|
|
21
|
+
};
|
|
22
|
+
const mainOf = (dir, main = "index.js") => {
|
|
23
|
+
const found = [
|
|
24
|
+
main,
|
|
25
|
+
`${main}.js`,
|
|
26
|
+
path.join(main, "index.js")
|
|
27
|
+
].map((file) => path.join(dir, file)).find((file) => existsSync(file));
|
|
28
|
+
if (!found) throw new Error(`The package in ${dir} has no ${main}`);
|
|
29
|
+
return found;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The file an `import` of `id`, such as `sharp` or `@acme/plugin/steps`, loads from `fromDir`, as Node's ES module
|
|
33
|
+
* resolution finds it: through the package's `exports` with the `import` conditions, otherwise its `main` or
|
|
34
|
+
* `index.js`.
|
|
35
|
+
*/
|
|
36
|
+
const packageEntry = (id, fromDir) => {
|
|
37
|
+
const name = packageName(id);
|
|
38
|
+
if (!name) throw new Error(`${id} is no package name`);
|
|
39
|
+
const dir = findPackageDirSync(name, fromDir);
|
|
40
|
+
if (!dir) throw new Error(`Cannot find the package ${name} from ${fromDir}; install it`);
|
|
41
|
+
const manifest = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
42
|
+
const subpath = `.${id.slice(name.length)}`;
|
|
43
|
+
if (manifest.exports === void 0) return subpath === "." ? mainOf(dir, manifest.main) : path.join(dir, subpath);
|
|
44
|
+
const target = exportOf(manifest.exports, subpath);
|
|
45
|
+
if (!target) throw new Error(`The package ${name} exports no ${subpath} to import`);
|
|
46
|
+
return path.join(dir, target);
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { packageEntry };
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { conventionConfig } from "../project/convention.js";
|
|
2
2
|
import { uiDirs } from "../project/convention-files.js";
|
|
3
3
|
import { functionFiles } from "../project/functions-folder.js";
|
|
4
|
-
import {
|
|
4
|
+
import { packageEntry } from "./package-entry.js";
|
|
5
5
|
import { existsSync, readdirSync } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
//#region src/config/project-files.ts
|
|
9
|
+
const folderFiles = (projectDir, folder) => existsSync(path.join(projectDir, folder)) ? readdirSync(path.join(projectDir, folder)).filter((name) => name.endsWith(".ts") && !/\.(test|d)\.ts$/.test(name)).map((name) => `${folder}/${name}`).sort() : [];
|
|
9
10
|
/** The files the conventions name besides resources and functions, which a bundle carries for its serve runtime. */
|
|
10
11
|
const conventionModules = (projectDir) => {
|
|
11
|
-
const inFolder = (folder) =>
|
|
12
|
+
const inFolder = (folder) => folderFiles(projectDir, folder);
|
|
12
13
|
const files = [
|
|
13
14
|
"auth/hooks.ts",
|
|
14
15
|
"auth/authenticate.ts",
|
|
@@ -26,6 +27,7 @@ const conventionModules = (projectDir) => {
|
|
|
26
27
|
...inFolder("jobs"),
|
|
27
28
|
...inFolder("hooks"),
|
|
28
29
|
...inFolder("files"),
|
|
30
|
+
...inFolder("workflows/plugins"),
|
|
29
31
|
...files.filter((file) => existsSync(path.join(projectDir, file)))
|
|
30
32
|
].sort();
|
|
31
33
|
};
|
|
@@ -42,9 +44,10 @@ const sourceFiles = (projectDir, importModules) => {
|
|
|
42
44
|
} },
|
|
43
45
|
functions: Object.fromEntries(functionFiles(projectDir).map(({ name, file }) => [name, one(file)])),
|
|
44
46
|
module: (file) => existsSync(path.join(projectDir, file)) ? one(path.join(projectDir, file)) : void 0,
|
|
47
|
+
folder: (folder) => folderFiles(projectDir, folder),
|
|
45
48
|
package: (name) => import(
|
|
46
49
|
/* @vite-ignore */
|
|
47
|
-
pathToFileURL(
|
|
50
|
+
pathToFileURL(packageEntry(name, projectDir)).href
|
|
48
51
|
)
|
|
49
52
|
};
|
|
50
53
|
};
|
|
@@ -192,13 +192,28 @@ const projectWebhooks = async (config) => {
|
|
|
192
192
|
...ownerRoles && { ownerRoles }
|
|
193
193
|
};
|
|
194
194
|
};
|
|
195
|
-
|
|
195
|
+
const workflowPlugins = async (config, packages) => {
|
|
196
|
+
const project = config.sources.at(-1).files;
|
|
197
|
+
const fromPackages = await Promise.all(packages.map(async (name) => ({
|
|
198
|
+
plugin: (await project.package(name)).default,
|
|
199
|
+
source: name
|
|
200
|
+
})));
|
|
201
|
+
const fromFolders = await Promise.all(config.sources.flatMap(({ files, label }) => files.folder("workflows/plugins").map(async (file) => ({
|
|
202
|
+
plugin: (await files.module(file)()).default,
|
|
203
|
+
source: `${file} of ${label}`
|
|
204
|
+
}))));
|
|
205
|
+
return [...fromPackages, ...fromFolders];
|
|
206
|
+
};
|
|
207
|
+
/** The workflows options, with the roles of workflows/saved-by-roles.ts when a config's folder has it, and the app's plugins. */
|
|
196
208
|
const projectWorkflows = async (config) => {
|
|
197
209
|
if (!config.workflows) return void 0;
|
|
210
|
+
const { plugins: packages = [], ...options } = config.workflows;
|
|
198
211
|
const savedByRoles = await lastDefault(config, "workflows/saved-by-roles.ts");
|
|
212
|
+
const plugins = await workflowPlugins(config, packages);
|
|
199
213
|
return {
|
|
200
|
-
...
|
|
201
|
-
...savedByRoles && { savedByRoles }
|
|
214
|
+
...options,
|
|
215
|
+
...savedByRoles && { savedByRoles },
|
|
216
|
+
...plugins.length > 0 && { plugins }
|
|
202
217
|
};
|
|
203
218
|
};
|
|
204
219
|
/**
|
package/dist/host-modules.json
CHANGED
|
@@ -21,6 +21,7 @@ import * as reactJsxRuntime from "react/jsx-runtime";
|
|
|
21
21
|
import * as zod from "zod";
|
|
22
22
|
import * as protobaseConfig from "@protobase/config";
|
|
23
23
|
import * as pdf from "@protobase/server/pdf";
|
|
24
|
+
import * as workflowPlugin from "@protobase/workflows/plugin";
|
|
24
25
|
//#region src/serve/host-modules.ts
|
|
25
26
|
const hostModules = {
|
|
26
27
|
"@protobase/config": protobaseConfig,
|
|
@@ -30,6 +31,7 @@ const hostModules = {
|
|
|
30
31
|
"@protobase/query": query,
|
|
31
32
|
"@protobase/server": server,
|
|
32
33
|
"@protobase/server/pdf": pdf,
|
|
34
|
+
"@protobase/workflows/plugin": workflowPlugin,
|
|
33
35
|
kysely,
|
|
34
36
|
"kysely/helpers/postgres": kyselyPostgres,
|
|
35
37
|
pg: pg$1,
|
|
@@ -15,6 +15,7 @@ const projectFiles = ({ resources, functions, modules }, packages) => ({
|
|
|
15
15
|
...resources && { resources },
|
|
16
16
|
functions,
|
|
17
17
|
module: (file) => modules[file],
|
|
18
|
+
folder: (folder) => Object.keys(modules).filter((file) => file.startsWith(`${folder}/`) && !file.slice(folder.length + 1).includes("/")).sort(),
|
|
18
19
|
package: async (name) => {
|
|
19
20
|
const load = packages[name];
|
|
20
21
|
if (!load) throw new Error(`The bundle carries no ${name}; build it again`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.1",
|
|
4
4
|
"description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,12 +43,13 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@better-auth/core": "^1.7.12",
|
|
45
45
|
"@hono/node-server": "^2.1.3",
|
|
46
|
-
"@protobase/config": "0.
|
|
47
|
-
"@protobase/layout": "0.
|
|
48
|
-
"@protobase/query": "0.
|
|
49
|
-
"@protobase/schema": "0.
|
|
50
|
-
"@protobase/server": "0.
|
|
51
|
-
"@protobase/ui": "0.
|
|
46
|
+
"@protobase/config": "0.68.1",
|
|
47
|
+
"@protobase/layout": "0.68.1",
|
|
48
|
+
"@protobase/query": "0.68.1",
|
|
49
|
+
"@protobase/schema": "0.68.1",
|
|
50
|
+
"@protobase/server": "0.68.1",
|
|
51
|
+
"@protobase/ui": "0.68.1",
|
|
52
|
+
"@protobase/workflows": "0.68.1",
|
|
52
53
|
"@react-email/render": "^2.1.0",
|
|
53
54
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
54
55
|
"@sentry/vite-plugin": "^11.10.0",
|
|
@@ -89,6 +89,7 @@ Reference:
|
|
|
89
89
|
- When letting people without an account create records → read [Public forms](references/reference/forms.md)
|
|
90
90
|
- When running background jobs → read [Jobs](references/reference/jobs.md)
|
|
91
91
|
- When writing or running workflows, their steps and their runs → read [Workflows](references/reference/workflows.md)
|
|
92
|
+
- When adding trigger or step types to workflows → read [Workflow plugins](references/reference/workflow-plugins.md)
|
|
92
93
|
- When sending email → read [Email](references/reference/email.md)
|
|
93
94
|
- When making a PDF file, such as an invoice, a quote or a report → read [PDF documents](references/reference/pdf.md)
|
|
94
95
|
- When registering users' devices for push notifications → read [Notifications](references/reference/notifications.md)
|
|
@@ -9,3 +9,4 @@ Buttons that change records, ask for input or run your own code, per role.
|
|
|
9
9
|
- [Conditional and bulk actions](../reference/ui-config.md#where-an-action-applies): Actions show, or show disabled with a reason, only where a condition holds, and run on several selected rows at once.
|
|
10
10
|
- [Views per role](../reference/ui-config.md#actions-and-views-per-role): Each role can get its own view of a resource, with other columns, filters, layout and actions.
|
|
11
11
|
- [Named actions](../reference/layouts.md#named-actions): An action is declared once in a view and used as a button on lists, record pages and composed pages.
|
|
12
|
+
- [Links to a resource the record names](../reference/layouts.md#links-to-a-resource-the-record-names): A link action goes to the list or a record of the resource a field of the record names, and shows once the app serves it.
|
|
@@ -8,6 +8,7 @@ The sidebar, the user menu, the bottom bar on phones and the global search.
|
|
|
8
8
|
- [Sidebar icons](../reference/sidebar.md#sidebar-icons): Each resource shows the icon its view names in the sidebar, bottom bar and profile menu.
|
|
9
9
|
- [Recent records](../reference/sidebar.md#recent-records) (beta): A sidebar entry lists a few records under it, with status dots or as titles alone, and marked per reader, such as unread ones in bold with a count.
|
|
10
10
|
- [Create link](../reference/sidebar.md#create-link): A sidebar entry links to its create form, such as "New message" under Direct messages, for whoever may create.
|
|
11
|
+
- [Entries for creators](../reference/sidebar.md#entries-for-creators): A sidebar entry shows only to those who may create its records, such as the tables list to those who may add tables.
|
|
11
12
|
- [The current organization](../reference/sidebar.md#the-current-organization): A sidebar entry opens its list on the organization you work in, which you can clear to see every organization you are a member of.
|
|
12
13
|
- [User menu](../reference/user-menu.md): Account pages, help links, security settings, setting a status, the theme switch and Sign out.
|
|
13
14
|
- [Bottom bar](../reference/bottom-bar.md): On phones, tabs along the bottom for each role's main screens.
|
|
@@ -18,6 +18,7 @@ The page of one record, with its sections, related records, galleries, charts an
|
|
|
18
18
|
- [Groups and tabs](../reference/ui-config.md#groups): Parts of a record page sit under headings that open on click, or in tabs, and the open ones are kept in the address.
|
|
19
19
|
- [Key facts](../reference/ui-config.md#key-facts): A section shows its values side by side with icons.
|
|
20
20
|
- [Confirming a change](../reference/ui-config.md#confirming-a-change): A field that is easy to set by mistake asks a question before a person changes it in a form or a list cell, and Cancel keeps its value.
|
|
21
|
+
- [Fields only once a record exists](../reference/ui-config.md#names-and-columns): A view leaves a field off the create form, such as a choice among records added after it, and the record page shows it as usual.
|
|
21
22
|
- [Help while editing](../reference/ui-config.md#help-while-editing): Help lines meant for whoever changes a record show only on its form.
|
|
22
23
|
- [Conditional sections](../reference/ui-config.md#form-and-read-view): Sections and fields show only while editing, only while reading, or only when a condition on the record holds.
|
|
23
24
|
- [Galleries](../reference/ui-config.md#galleries): A record's photos show as tiles that open a full-screen lightbox, with keyboard and swipe navigation.
|
|
@@ -6,5 +6,6 @@ Tables an app's users add from the admin UI, by hand or from a file, and how a d
|
|
|
6
6
|
|
|
7
7
|
- [Tables defined in the database](../reference/user-tables.md#tables-defined-in-the-database) (beta): An app can keep tables its users add in its own database, beside the resources in its code. Each one is served like any other resource, at its own address with its list, record page, search and API. Every server process serves a new table within a second of it being added.
|
|
8
8
|
- [Adding tables and fields](../reference/user-tables.md#adding-tables-and-fields) (beta): Someone with the right capability creates a table with its fields, or adds a field to it, through the app's API. It is created in the database at once, with an index for every field but JSON so its list can be filtered by it, and sorted by it unless it is long text, and only admins can see it until access is widened.
|
|
9
|
+
- [Adding a table in the admin app](../reference/user-tables.md#in-the-admin-app) (beta): People who may add tables find Tables in the admin app, make a table with its fields and add fields later, on a computer or a phone. The new table appears in the sidebar at once.
|
|
9
10
|
- [Taking a table over as code](../reference/cli.md#eject) (beta): `protobase eject` turns a table someone added in the admin app into ordinary config and a migration in the project, so a developer can customise it like any other resource. Its records stay where they are.
|
|
10
11
|
- [Tables per organization](../reference/user-tables.md#organizations) (beta): In an app with organizations, a table someone adds belongs to their organization. Only its owners and admins, and superusers working in it, see the table and its records.
|
|
@@ -7,14 +7,20 @@ Graphs of steps the server runs along their edges, written in a language a model
|
|
|
7
7
|
- [Workflows](../reference/workflows.md#turning-them-on) (beta): Workflows kept in the app's database, each changed definition checked and kept as a version, run on the app's own jobs.
|
|
8
8
|
- [Workflow language](../reference/workflows.md#the-document) (beta): The States Language with JSONata expressions, every value read by name, so a model writes and edits it as a small, local change.
|
|
9
9
|
- [Checked on save](../reference/workflows.md#reading-values-by-name) (beta): A definition that does not fit the language, has an edge to no step, reads a value nothing gives or leaves out an argument its step type needs is refused, naming the step and the field.
|
|
10
|
-
- [Workflow editor](../reference/workflows.md#the-editor) (beta): The admin app draws a workflow as a graph that lays itself out, to add, connect and remove steps and
|
|
10
|
+
- [Workflow editor](../reference/workflows.md#the-editor) (beta): The admin app draws a workflow as a graph that lays itself out, to add, connect and remove steps of every trigger and step type of the app, its plugins' included, each with a form for its input, with every problem the save would refuse shown on its step as it is edited.
|
|
11
11
|
- [Branches, steps per item and retries](../reference/workflows.md#states) (beta): Choices, a step per item with their results merged, waits, and per-step retries, timeouts and error paths.
|
|
12
12
|
- [HTTP request step](../reference/workflows.md#apphttp) (beta): Calls a URL built at run time through the outgoing-address guard, only at public addresses unless the config allows private ones, reading at most 10 MiB of the answer as JSON, text or a file.
|
|
13
13
|
- [Records steps](../reference/workflows.md#apprecords) (beta): Create, update, get and list records as the workflow's last editor, with the roles they hold now.
|
|
14
14
|
- [Mail step](../reference/workflows.md#appmailsend) (beta): Sends mail, with files the run downloaded attached, from the app's own sender.
|
|
15
|
-
- [Runs and step records](../reference/workflows.md#runs-and-their-steps) (beta): Each run started by hand through the API
|
|
15
|
+
- [Runs and step records](../reference/workflows.md#runs-and-their-steps) (beta): Each run started by hand through the API, from the app's code or by a trigger, with every step's input, output, logs, timing, tries and error recorded, secrets blanked out.
|
|
16
16
|
- [Run debugger](../reference/workflows.md#following-and-debugging-a-run) (beta): Each workflow lists its runs with their status, who started them, when and for how long; a run's page marks every step's status on the workflow's graph, live while it runs, with each step's input, output, logs, timing, tries and error, secrets blanked out.
|
|
17
17
|
- [Re-run from the failed step](../reference/workflows.md#re-running-a-failed-run) (beta): Once the workflow is fixed, a failed run runs again from the step that failed, keeping what the steps before it gave; a test run starts the workflow with a sample input from its page.
|
|
18
18
|
- [Runs that survive restarts](../reference/workflows.md#how-a-run-is-run) (beta): A run takes up where it stood after a restart or a crashed worker, never running a saved step again, and every limit fails closed.
|
|
19
19
|
- [Who may run workflows](../reference/workflows.md#who-may-do-what) (beta): Making, changing and running workflows and reading their runs are each granted by name, within the tenant.
|
|
20
20
|
- [Workflows over MCP](../reference/mcp.md#workflows) (beta): AI clients find, write, validate, change by a small edit and test-run workflows as the signed-in person, and read each step of a run to fix the one that failed and re-run it from there.
|
|
21
|
+
- [Record triggers](../reference/workflows.md#apprecord) (beta): A workflow starts once for each record that becomes ready, at once or when a time in it comes, read as the user who saved the enabled version.
|
|
22
|
+
- [Triggers turned on while the app runs](../reference/workflows.md#turning-a-trigger-on) (beta): Turning a workflow on or changing it registers its trigger on every server process, and turning it off stops it, with no restart.
|
|
23
|
+
- [Workflow plugins](../reference/workflow-plugins.md) (beta): A file of the app or a package adds trigger and step types through the same public interface the built-in types use.
|
|
24
|
+
- [Guarded requests for plugins](../reference/workflow-plugins.md#sending-requests) (beta): A plugin's requests reach only public addresses, follow no redirect and read at most 10 MiB.
|
|
25
|
+
- [Webhook trigger](../reference/workflow-plugins.md#polling-and-webhook-coming-soon) (coming soon): A workflow starts from a signed call to a URL of its own, limited in size and rate.
|
|
26
|
+
- [Polling triggers](../reference/workflow-plugins.md#polling-and-webhook-coming-soon) (coming soon): A workflow starts once for each new item a plugin finds on a schedule.
|
|
@@ -211,7 +211,7 @@ export default defineConfig(({ command }) => ({ webhooks: { allowPrivateAddresse
|
|
|
211
211
|
|
|
212
212
|
## workflows
|
|
213
213
|
|
|
214
|
-
- **Type:** `{ schema?: string; secrets?: string[]; allowPrivateAddresses?: boolean }`
|
|
214
|
+
- **Type:** `{ schema?: string; secrets?: string[]; allowPrivateAddresses?: boolean; plugins?: string[] }`
|
|
215
215
|
- **Default:** off; `{}` turns them on
|
|
216
216
|
|
|
217
217
|
Workflows that people and models make in the app, kept with their versions, runs and steps in a schema of the app's database that `protobase migration` writes, and run by the `workflows.segment` job. Records steps act as each workflow's last editor, with the roles they hold now, read from the app's sign-in; a project that signs people in otherwise exports its own, `async ({ id, tenant }) => [...]`, as the default of `workflows/saved-by-roles.ts`. See [Workflows](../workflows.md).
|
|
@@ -247,12 +247,23 @@ export default defineConfig({ workflows: { secrets: ['TICKETS_TOKEN'] } })
|
|
|
247
247
|
- **Type:** `boolean`
|
|
248
248
|
- **Default:** `false`
|
|
249
249
|
|
|
250
|
-
Lets the `app:http` step reach private, loopback and link-local addresses, for development and tests. Off, a URL whose host resolves to one fails the
|
|
250
|
+
Lets the `app:http` step, and every plugin's `fetch`, reach private, loopback and link-local addresses, for development and tests. Off, a URL whose host resolves to one fails the request, which connects only to the address it checked, so a workflow cannot reach the app's own network.
|
|
251
251
|
|
|
252
252
|
```ts
|
|
253
253
|
export default defineConfig(({ command }) => ({ workflows: { allowPrivateAddresses: command === 'dev' } }))
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
+
## workflows.plugins
|
|
257
|
+
|
|
258
|
+
- **Type:** `string[]`
|
|
259
|
+
- **Default:** none
|
|
260
|
+
|
|
261
|
+
Packages of [workflow plugins](../workflow-plugins.md) the app runs, by package name, beside the built-in types and the app's own plugins in `workflows/plugins/<name>.ts`. Two plugins of one name stop startup, naming both.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
export default defineConfig({ workflows: { plugins: ['@acme/workflow-plugin'] } })
|
|
265
|
+
```
|
|
266
|
+
|
|
256
267
|
## notifications
|
|
257
268
|
|
|
258
269
|
- **Type:** `{ ui?: boolean; schema?: string; devices?: { table?: string; columns?: Record<string, string> }; history?: { table?: string; columns?: Record<string, string> } }`
|
|
@@ -141,6 +141,7 @@ The other files belong to an option, and are described with it:
|
|
|
141
141
|
| `jobs/<name>.ts` | [`jobs`](config/jobs-and-events.md#jobs) |
|
|
142
142
|
| `webhooks/owner-roles.ts` | [`webhooks`](config/jobs-and-events.md#webhooks) |
|
|
143
143
|
| `workflows/saved-by-roles.ts` | [`workflows`](config/jobs-and-events.md#workflows) |
|
|
144
|
+
| `workflows/plugins/<name>.ts` | [`workflows.plugins`](config/jobs-and-events.md#workflowsplugins) |
|
|
144
145
|
| `notifications/store.ts`, `notifications/history-store.ts`, `notifications/senders.ts` | [`notifications`](config/jobs-and-events.md#notifications) |
|
|
145
146
|
| `files/<name>.ts` | [`files.providers`](config/files.md#filesproviders) |
|
|
146
147
|
|
|
@@ -163,6 +164,7 @@ export default defineUi({ extends: [tasksUi] })
|
|
|
163
164
|
| assistant tools of `assistant/tools.ts` | the extensions' first, in order, then the app's | an error naming the tool and both folders |
|
|
164
165
|
| MCP tools of `mcp/tools.ts` | the extensions' first, in order, then the app's | an error naming the tool and both folders |
|
|
165
166
|
| `jobs`, functions, file providers (`files.providers`) | all kept, by name | an error naming the name and both folders |
|
|
167
|
+
| workflow plugins of `workflows/plugins/` | all kept | an error naming the plugin and both sources |
|
|
166
168
|
| `files.trustedHosts` | all kept | |
|
|
167
169
|
| the other keys of `files` | each the app's own; otherwise the last config that sets it | |
|
|
168
170
|
| `writeHooks` | all run, in the same order | |
|
|
@@ -26,6 +26,8 @@ Numbers, amounts and dates use the conventions of the language: English as writt
|
|
|
26
26
|
|
|
27
27
|
Every built-in screen is translated: the shell with its sidebar, top bar, search, notification bell, profile menu and bottom bar; the list page with all its displays (table, cards, board, calendar, timeline and map), filters, search, sort, imports and exports; the record page with its actions, related lists, history, comments, gallery, saving, conflicts and deleting; the create page and composed pages; saved searches and favorites; the sign-in, sign-up and two-factor screens, the account and sign-in policy pages, and the organization, people and invitation pages; public forms; the assistant's dock and a record's [lines](ui-config.md#record-lines); the notice naming a plugin that is off and why; and the messages, toasts and confirmations around them. The parts `@protobase/ui` gives your own components, such as the [form parts](custom-components.md#form-parts), the [chat parts](custom-components.md#chat-parts) and the markdown editor, come in the app's language too. So are counts, relative times ("5 minutes ago"), yes, no and the other values the app writes itself, and the notices shown while the app loads.
|
|
28
28
|
|
|
29
|
+
The screens where people add [tables of their own](user-tables.md#in-the-admin-app) are translated too: the tables list, a table's page with its fields, and the forms that make a table and a field, with the names of the field types. <gap>The other lists Protobase adds, such as failed jobs, activity and comments, cannot show in Dutch because their texts are not in the locale files yet.</gap>
|
|
30
|
+
|
|
29
31
|
The approvals an AI client asks for over [MCP](mcp.md#writes) are translated too, with their buttons and the refusal for a client that cannot ask: in the app's `locale`, or else in the language the client's request accepts.
|
|
30
32
|
|
|
31
33
|
The other texts a plugin or the server writes itself, such as an error message the API sends back, show as they are written.
|
|
@@ -81,12 +81,12 @@ Record pages take the same conditions in the `when` of `l.section`, `l.related`
|
|
|
81
81
|
| --- | --- |
|
|
82
82
|
| `a.update(name, { label, set, confirm?, icon?, bulk?, inputs? })` | Updates the record with `set`, with its ETag; not offered on a record that holds those values already, unless it asks for `inputs`, whose answers it writes where `set` says `'{name}'`. |
|
|
83
83
|
| `a.remove(name, { label, confirm?, icon? })` | Deletes the record (soft delete when the resource has one). |
|
|
84
|
-
| `a.link(name, { label, to, icon? })` | Goes `to` an [address](#addresses) in the app, with `{field}` in its key, filter, order or create values replaced by the record's value. |
|
|
84
|
+
| `a.link(name, { label, to, icon? })` | Goes `to` an [address](#addresses) in the app, with `{field}` in its key, filter, order or create values replaced by the record's value, and its resource too when that is only `{field}`: see [links to a resource the record names](#links-to-a-resource-the-record-names). |
|
|
85
85
|
| `a.link(name, { label, href, icon? })` | Opens an `href` that leaves the app (`https:`, `mailto:`), with `{field}` replaced by the record's value (percent-encoded); an `https:` or `http:` link opens in a new tab without the referrer. An `href` that is only a field, `'{website}'`, opens the URL the record holds, as it is: see [links to a record's URL](#links-to-a-records-url). A path in the app is refused when the config loads: give it as `to`. |
|
|
86
86
|
| `a.ai(name, { label, prompt, writes, confirm?, icon? })` | Has the model write the field `writes` of the record from `prompt`, `{field}` replaced by the record's values, as the user, on the server; see [AI fields and actions](ai.md#ai-actions). |
|
|
87
87
|
| `a.action(name, { label, confirm?, icon?, bulk?, inputs? })` | Runs the handler the app registers under `name` in [`protobase.ui.tsx`](custom-components.md#action-handlers), after asking for its [`inputs`](ui-config.md#actions-that-ask-for-input) in a dialog when it has some. |
|
|
88
88
|
|
|
89
|
-
`icon` is one of `archive`, `arrow-right-left`, `ban`, `bath`, `bed`, `bell`, `bookmark`, `calendar`, `camera`, `copy`, `cpu`, `door-open`, `download`, `external-link`, `file-text`, `hard-drive`, `heart`, `house`, `key`, `land-plot`, `leaf`, `memory`, `network`, `play`, `plug`, `power`, `restart`, `ruler`, `send`, `server`, `stop`, `tag`, `terminal`, `upload`, `wallet` or `wrench`; another name shows a lightning bolt. With `confirm` the button asks first. Every builder also takes `when`, `otherwise` and `reason`, which limit the action to the records it [applies to](ui-config.md#where-an-action-applies), and `variant: 'danger'`, which draws it in red. The button is disabled, with the reason as its tooltip, when the user may not update or delete the record (or, for an AI action, the field it writes), when an `a.action` has no handler, or when an update, delete or AI action has no record. After an action, everything shown of that resource reloads. The record page's action rail runs the same actions the same way; there an `a.link` is a real link that looks like the rail's buttons, which the browser can preview, copy or open in a new tab, while a disabled one is a disabled button with its reason. <gap>An `<Action>` block for an `a.link` cannot be opened in a new tab or copied as a link because drawing link actions as links is not implemented for the block.</gap>
|
|
89
|
+
`icon` is one of `archive`, `arrow-right-left`, `ban`, `bath`, `bed`, `bell`, `bookmark`, `calendar`, `camera`, `copy`, `cpu`, `door-open`, `download`, `external-link`, `file-text`, `hard-drive`, `heart`, `house`, `key`, `land-plot`, `leaf`, `memory`, `network`, `play`, `plug`, `plus`, `power`, `restart`, `ruler`, `send`, `server`, `stop`, `table`, `tag`, `terminal`, `upload`, `wallet` or `wrench`; another name shows a lightning bolt. With `confirm` the button asks first. Every builder also takes `when`, `otherwise` and `reason`, which limit the action to the records it [applies to](ui-config.md#where-an-action-applies), and `variant: 'danger'`, which draws it in red. The button is disabled, with the reason as its tooltip, when the user may not update or delete the record (or, for an AI action, the field it writes), when an `a.action` has no handler, or when an update, delete or AI action has no record. After an action, everything shown of that resource reloads. The record page's action rail runs the same actions the same way; there an `a.link` is a real link that looks like the rail's buttons, which the browser can preview, copy or open in a new tab, while a disabled one is a disabled button with its reason. <gap>An `<Action>` block for an `a.link` cannot be opened in a new tab or copied as a link because drawing link actions as links is not implemented for the block.</gap>
|
|
90
90
|
|
|
91
91
|
### Links to a record's URL
|
|
92
92
|
|
|
@@ -98,6 +98,16 @@ a.link('source', { label: 'View on {host}', icon: 'external-link', href: '{sourc
|
|
|
98
98
|
|
|
99
99
|
The value is used as it is, not percent-encoded, and must be an absolute `https:` or `http:` URL: on a record whose field is empty or holds anything else the button does not show. It opens in a new tab that gets neither the app's window nor its address. `{host}` in the label becomes the URL's host without `www.`, so the button above reads "View on example.com". The field must be one of the resource's, and the button shows to whoever the view grants the field, a visitor who is not signed in included; for anyone it hides the field from, the action is left out.
|
|
100
100
|
|
|
101
|
+
### Links to a resource the record names
|
|
102
|
+
|
|
103
|
+
A link whose address has only a `{field}` as its `resource` goes to the resource that field names, such as a record that describes a table and links to that table's list:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
a.link('open_table', { label: 'Open table', to: { kind: 'list', resource: '{name}' } })
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The button shows only while the app gives the user a resource of that name: on a record whose field is empty, or names a resource the user cannot see or the app does not serve yet, it is left out, and it shows once `/meta` names it. As the resource is known only when the link is followed, such an address takes no filter, order or create values, which the server refuses when it starts; a record's `key` may still be a `{field}`.
|
|
110
|
+
|
|
101
111
|
## Addresses
|
|
102
112
|
|
|
103
113
|
Every place in the app has an address, and the app writes and reads them in one way, so no link or component spells out a path:
|
|
@@ -92,10 +92,10 @@ In an app with [workflows](workflows.md), the endpoint also offers tools to find
|
|
|
92
92
|
|
|
93
93
|
| Tool | What it does |
|
|
94
94
|
| --- | --- |
|
|
95
|
-
| `list_workflow_types` | The step types a Task names as its `Resource
|
|
95
|
+
| `list_workflow_types` | The step types a Task names as its `Resource` and the triggers a definition holds, built-in and [plugin](workflow-plugins.md) ones alike, each by id with its label, description, input as JSON Schema and, when the type declares one, its output, the errors a step fails with, and whether the app can run it. |
|
|
96
96
|
| `list_workflows` | A page of the workflows the person may read, newest change first, without their definitions: `filter` on `name`, `page_size` (20 by default, at most 100) and `page_token`. |
|
|
97
97
|
| `get_workflow` | One workflow with its whole definition and its ETag. |
|
|
98
|
-
| `validate_workflow` | Checks a `definition`, or a workflow's `id` with an `edit` of its saved definition, as a save checks it, and saves nothing: `{ valid: true }`, or `{ valid: false, problems }`. |
|
|
98
|
+
| `validate_workflow` | Checks a `definition`, or a workflow's `id` with an `edit` of its saved definition, as a save checks it, and saves nothing: `{ valid: true }`, or `{ valid: false, problems }`. A record trigger is checked against the resources and fields the person may read, so a resource they cannot read is not a resource of the app. |
|
|
99
99
|
| `get_workflow_run` | A run's status, input, output, error and cause, with its steps in the order they ran, each with its state, scope, try, status, input, output, assigned variables, logs, error and cause; `state` reads one state's steps, a page of 20 by default. |
|
|
100
100
|
| `create_workflow` | Creates a workflow from its `name`, `description` and `definition`, and returns its `id`, `version` and ETag. |
|
|
101
101
|
| `update_workflow` | Changes a workflow's definition by an `edit` or whole, as `definition`, and its `name` or `description`, and returns its `id`, new `version` and ETag. With the `etag` that `get_workflow` gave, it fails when someone changed the workflow since; an edit without one is saved against the version it was applied to. |
|
|
@@ -280,8 +280,9 @@ The endpoint serves each request on its own, without an MCP session: a `GET` or
|
|
|
280
280
|
|
|
281
281
|
- `mcpHandler({ authenticate, resource, scope, server })`: the endpoint as a fetch handler. It checks the host, admits a valid token (`mcpGate`) and serves the request with the server that `server(session, request)` builds for its caller; `request` is the HTTP request, such as for the language the person reads.
|
|
282
282
|
- `mcpServer({ info, instructions, tools, context, report, rolesWithoutApproval, approvals })`: an MCP server named by `info`, its `name`, `version` and `icons`, such as `mcpIcons(origin, icons)`, the app's icons on `origin`, and offering [assistant tools](assistant.md#the-apps-own-tools), each with its JSON Schema, its `annotations`, MCP's tool hints, and its `title` and `_meta`, and each refusing as [the app's own tools](#the-apps-own-tools) do. `context` is the tools' context without `approve`, with the `locale` its approvals are asked in (English when absent): each call gets its own, which asks the client's user, or approves at once when the session's user holds one of `rolesWithoutApproval`. It is a `ToolContext`, with the app's `records`, `resources()` and `show`, unless every tool is declared with a smaller one, such as `defineTool<BaseToolContext>(...)`, whose context is only `session`, `locale` and `approve`: a host without app records then passes `{ session }`. `report` gets what a tool throws other than a refusal. `approvals` is `mcpApprovals()`, built once and passed to every server the endpoint builds: it keeps the approvals asked for until they are answered or expire. `instructions` is what it tells clients when they connect, by default `mcpInstructions`, the advice on reading records above.
|
|
283
|
-
- `mcpTools({ db, sql, writes, commented, workflows, own })`: the endpoint's tools for a session; `writes` is `true`, `false` or `{ resources, rolesWithoutApproval }`, `commented` names the resources with comments, `workflows
|
|
284
|
-
- `
|
|
283
|
+
- `mcpTools({ db, sql, writes, commented, workflows, own })`: the endpoint's tools for a session; `writes` is `true`, `false` or `{ resources, rolesWithoutApproval }`, `commented` names the resources with comments, `workflows` offers the [workflow tools](#workflows) with the app's types, and `own` are [the app's own tools](#the-apps-own-tools), offered after the rest.
|
|
284
|
+
- `workflowTypes(plugins, models)`: the app's workflow types, a `WorkflowCatalog`, which `workflows` and `workflowReadTools` take. `plugins` are the app's own [workflow plugins](workflow-plugins.md), each `{ plugin, source }`, beside the built-in ones, and `models(name)` gives the model of the app's resource of that name, which a record trigger is checked against. An app with `workflows` needs none of this: its endpoint offers the tools with the app's types; these are for an MCP server of your own.
|
|
285
|
+
- `workflowReadTools(catalog)` and `workflowWriteTools({ resources })`: the workflow tools alone, and `workflowInstructions`, what the endpoint tells clients about the language, to append to `mcpInstructions`.
|
|
285
286
|
- `readRecordTools()`, or `listResourcesTool()`, `describeResourceTool()`, `listRecordsTool()` and `getRecordTool()`: the read tools alone; `readOnlyQueryTool(db)` is `run_read_only_query`, and `readWriteQueryTool(db)` proposes one write statement that the user approves before it runs.
|
|
286
287
|
- `commentTools({ commented }, writes)`, or `listCommentsTool({ commented })` and `addCommentTool({ commented, resources })`: the comment tools on the resources named in `commented`, which call the context's `comments`, and `add_comment` only when `writes` is not `false`.
|
|
287
288
|
- `writeRecordTools({ resources })`, or `createRecordTool(...)`, `updateRecordTool(...)` and `deleteRecordTool(...)`: the single write tools alone, on the listed resources or, without `resources`, on any; `batchWriteTool({ resources })` is `batch_write` alone.
|
|
@@ -31,6 +31,10 @@ view<typeof apps>('apps')
|
|
|
31
31
|
|
|
32
32
|
`/meta` writes the session's organization into the list's opening filter, here `isNull(parent_id) AND organization_id = "<id>"`, and into the filter of its [recent records](#recent-records). Like any opening filter it applies when the address names no filter: the facet shows the organization chosen, and the person picks others or clears it, which lists every organization they are a member of. A link with its own filter replaces it, and switching organization opens the app again on the new one. A [named list](ui-config.md#more-than-one-list) takes `tenant` in its own `nav`. `createAdmin` refuses to start when the view has no `.list(...)`, or the resource has no `.tenant()` on a `.filterable()` field.
|
|
33
33
|
|
|
34
|
+
## Entries for creators
|
|
35
|
+
|
|
36
|
+
`nav({ creatorsOnly: true })` shows the entry only to whoever its list offers **New**, as for the [create link](#create-link), for a list that is there to make records, such as the [tables users add](user-tables.md#in-the-admin-app). Anyone else who may read the resource still opens it at its address.
|
|
37
|
+
|
|
34
38
|
## Create link
|
|
35
39
|
|
|
36
40
|
`nav({ create: true })` puts a link to the resource's create form under its entry, reading "New <singular>" after the view's `names`; `create: { label }` gives it its own text. A chat's direct messages, with the conversations under the entry and a link to start one:
|
|
@@ -101,6 +101,7 @@ Field hints:
|
|
|
101
101
|
| --- | --- |
|
|
102
102
|
| `.label(text)` | Instead of the name in words (`customer_id` would be "Customer" anyway). |
|
|
103
103
|
| `.help(text, { when? })` | A line under the field on the record page and in forms. `when: 'editing'` shows it only while the record is edited; see [Help while editing](#help-while-editing). |
|
|
104
|
+
| `.notOnCreate()` | Leaves the field off the create form, for one that makes sense only once the record exists, such as a choice among records added after it: the create leaves it empty or to its default, and the record page shows it as usual. The server stops at startup on a field a create needs, one that is not optional and has no default, a boolean too. |
|
|
104
105
|
| `.confirm(question)` | Asks `question` before a person changes the value in a form or a list cell, and Cancel keeps it. See [Confirming a change](#confirming-a-change). |
|
|
105
106
|
| `.format(format)` | `badge` shows statuses as coloured badges, `code` sets identifiers in monospace, as the list's identifier column too, `percent` adds a % sign, and `signed` shows a whole number in a list with its sign, green above zero and red below, such as a stock change of `+12` or `−3`; it needs an integer field. On a text field, `email`, `phone` and `url` show the value as a link that writes to it (`mailto:`), calls it (`tel:`, its digits and a leading +) or opens it in a new tab (only an `http` or `https` address; anything else stays text), on the record page, in its sidebar and in the list's cells but the title's, which links to the record; forms edit it in an email, phone or web address input, which brings up the keyboard for it on a mobile screen and offers the browser's autofill. Another field type with one of them stops the server at startup. `relative` on a timestamp shows it as "5 minutes ago" or "in 2 hours" in lists and on the record page, with the exact time on hover; `absolute` and `compact` are accepted and carried in `/meta` for apps that render their own lists. <gap>The admin does not use the `absolute` and `compact` formats yet.</gap> |
|
|
106
107
|
| `.multiline()` | A text of several lines, such as a description: forms edit it in a box that grows with the text, so the page scrolls rather than the box, and the record page's sections, a page's field blocks and a card's subtitle keep its line breaks (a card shows the first three lines); the record's sidebar shows it on one line. On the record page and the create form it takes the whole width of its section. Only a text field takes it; the list's table shows the text on one line. |
|
|
@@ -793,7 +794,7 @@ view<typeof listings>('listings')
|
|
|
793
794
|
])
|
|
794
795
|
```
|
|
795
796
|
|
|
796
|
-
The read view shows the gallery; the form shows the cover photo's field and the table of photos to manage instead. The create form still offers every field the user may set: one only in items or fields meant for reading lands in its last section. A gallery's photo details leave out what the photo's own view shows only while editing. Lists, cards and filters show a field either way.
|
|
797
|
+
The read view shows the gallery; the form shows the cover photo's field and the table of photos to manage instead. The create form still offers every field the user may set but those [`.notOnCreate()`](#names-and-columns) leaves off: one only in items or fields meant for reading lands in its last section. A gallery's photo details leave out what the photo's own view shows only while editing. Lists, cards and filters show a field either way.
|
|
797
798
|
|
|
798
799
|
### Editing a record
|
|
799
800
|
|
|
@@ -9,13 +9,11 @@ An app's resources usually come from its code: a resource in `config/` over a ta
|
|
|
9
9
|
## Tables defined in the database
|
|
10
10
|
|
|
11
11
|
:::caution[Beta]
|
|
12
|
-
Tables are added through the API
|
|
12
|
+
Tables are added through the API or the admin app.
|
|
13
13
|
:::
|
|
14
14
|
|
|
15
15
|
An app can keep tables its users add in its own database, beside the resources in its code. Each one is served like any other resource, at its own address with its list, record page, search and API. Every server process serves a new table within a second of it being added.
|
|
16
16
|
|
|
17
|
-
<gap>Users cannot add a table from the admin app yet because its screens for tables and fields are not implemented.</gap>
|
|
18
|
-
|
|
19
17
|
### The switch
|
|
20
18
|
|
|
21
19
|
User tables are off by default. Turn them on in `protobase.config.ts`:
|
|
@@ -83,7 +81,7 @@ Measured with six fields per table on one machine running Postgres and the proce
|
|
|
83
81
|
## Adding tables and fields
|
|
84
82
|
|
|
85
83
|
:::caution[Beta]
|
|
86
|
-
Tables and fields are added through the API
|
|
84
|
+
Tables and fields are added through the API or the admin app, and are never renamed, retyped or removed.
|
|
87
85
|
:::
|
|
88
86
|
|
|
89
87
|
Someone with the right capability creates a table with its fields, or adds a field to it, through the app's API. It is created in the database at once, with an index for every field but JSON so its list can be filtered by it, and sorted by it unless it is long text, and only admins can see it until access is widened.
|
|
@@ -119,6 +117,24 @@ The answer has the table's record, so a client links to the new table at its `ta
|
|
|
119
117
|
|
|
120
118
|
Building an index holds the table's write lock, and every statement runs within the request's statement timeout (1 s by default). <gap>Adding a field to a table with very many records cannot finish when building its index takes longer than the statement timeout, because building it outside the request is not implemented; the field is then not added.</gap>
|
|
121
119
|
|
|
120
|
+
## In the admin app
|
|
121
|
+
|
|
122
|
+
:::caution[Beta]
|
|
123
|
+
A table is made first and given its fields one by one, and a file cannot be imported into a new table yet.
|
|
124
|
+
:::
|
|
125
|
+
|
|
126
|
+
People who may add tables find **Tables** in the admin app, make a table with its fields and add fields later, on a computer or a phone. The new table appears in the sidebar at once.
|
|
127
|
+
|
|
128
|
+
The screens are the admin app's standard list, record page and create forms over the [two resources](#adding-tables-and-fields), so they work the same in a browser, the installed web app and the native app, and come in [English and Dutch](languages.md).
|
|
129
|
+
|
|
130
|
+
- **Tables.** The sidebar's **Tables** group leads with a **Tables** entry, shown only to those who may create tables (`user_tables.create`, or admins without roles), and holds every table users added. The list shows each table's name and address.
|
|
131
|
+
- **A new table.** **New table** asks for the plural label, the singular label and the name; the title field is set later, as no field exists yet. Saving opens the table's page.
|
|
132
|
+
- **A table's page.** It shows the table's labels and name, and its fields in their order. **Add field** opens the form for a new field of the table, and **Open table** the table's own list, at its address. On a phone both sit in the top bar's **Record actions** menu.
|
|
133
|
+
- **A new field.** The form asks for the label, the name, the type, chosen by its name in words, such as **Long text** or **Yes or no**, and the position, and for a decimal number its digits and decimals. Every field users add is optional, so the form does not ask.
|
|
134
|
+
- **What changes later.** On a table's page, its labels and its title field, the name of one of its text fields; on a field's page, its label and position. The name, the type and a decimal's digits and decimals stay as made.
|
|
135
|
+
|
|
136
|
+
<gap>A table cannot be made together with its fields in one form because a create form that adds related records is not implemented; each field is added from the table's page.</gap>
|
|
137
|
+
|
|
122
138
|
## Taking a table over as code
|
|
123
139
|
|
|
124
140
|
:::caution[Beta]
|
|
@@ -53,7 +53,7 @@ Every commit message follows [Conventional Commits](https://www.conventionalcomm
|
|
|
53
53
|
|
|
54
54
|
When `main` moved on while the checks ran, the run of the newer push releases both. `bun run release --dry-run` prints the next version and its notes without tagging anything.
|
|
55
55
|
|
|
56
|
-
A push made with GitHub Actions' own token, such as a pull request the AI review merged, [starts no workflow](https://docs.github.com/en/actions/concepts/security/github_token), but a run by hand always starts one. Running the workflow by hand from `main` without a tag (`gh workflow run release.yml`) releases `main`'s head as a push does, with the same checks, which then run in full, commitlint over the commits since the last `v<version>` tag, and the same release. A release started while another runs waits for it.
|
|
56
|
+
A push made with GitHub Actions' own token, such as a pull request the AI review merged, [starts no workflow](https://docs.github.com/en/actions/concepts/security/github_token), but a run by hand always starts one. Running the workflow by hand from `main` without a tag (`gh workflow run release.yml`) releases `main`'s head as a push does, with the same checks, which then run in full, commitlint over the commits since the head the previous release run of `main` linted, as a push lints those since the previous push (the commits since the last `v<version>` tag when no recent run linted), and the same release. A malformed message that reached `main` so fails one release, not every later one. A release started while another runs waits for it.
|
|
57
57
|
|
|
58
58
|
Running the workflow by hand with a tag (`gh workflow run release.yml -f tag=v0.1.0`) only packs and publishes: the packages at that tag and its version, skipping every version npm already has, and the images, which repeats a missed or half-done publish. A publish that npm fails because it could not read the identity token, or because Sigstore's transparency log already holds its provenance (a 409 after npm's own retry), is tried again up to 3 times, and the 409 counts as published when npm has the version; any other failure fails the run. `node scripts/publish.mjs --dry-run` packs the packages and shows what `npm publish` would send, and `node scripts/publish.mjs --pack`, which CI runs, packs every package without asking npm or publishing. Each package on npm trusts `release.yml` of `PrototeamHQ/protobase` as its publisher; a new package has to be published once by hand before that can be set up. The workflow pushes the images with its own token; a new image on GitHub's registry starts out private and is made public once in its package settings.
|
|
59
59
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<!-- Generated by scripts/protobase-skill.ts from docs/website/src/content/docs/reference/workflow-plugins.md: run `bun run docs:skill`, do not edit. -->
|
|
2
|
+
|
|
3
|
+
# Workflow plugins
|
|
4
|
+
|
|
5
|
+
Add trigger and step types to workflows from a file of the app or a package, through the same public interface the built-in types use.
|
|
6
|
+
|
|
7
|
+
:::caution[Beta]
|
|
8
|
+
Plugins add step types and record triggers. Polling and webhook triggers, and connections that sign in to other services, are declared by the interface but not run yet.
|
|
9
|
+
:::
|
|
10
|
+
|
|
11
|
+
A **workflow plugin** adds trigger and step types to an app's [workflows](workflows.md). Its types are named `<plugin>:<key>`, such as `acme:create_invoice`: a Task names a step type as its `Resource`, and a definition's `trigger` holds a trigger type with its input. The built-in types, `app:http`, `app:records.*`, `app:mail.send` and `app:record`, are a plugin like any other, written on this interface alone, so a plugin's types run, are checked and are listed exactly as they are.
|
|
12
|
+
|
|
13
|
+
## Writing a plugin
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// workflows/plugins/acme.ts
|
|
17
|
+
import { definePlugin, StepError } from '@protobase/workflows/plugin'
|
|
18
|
+
|
|
19
|
+
export default definePlugin({
|
|
20
|
+
name: 'acme',
|
|
21
|
+
label: 'Acme',
|
|
22
|
+
steps: {
|
|
23
|
+
create_invoice: {
|
|
24
|
+
label: 'Create an invoice',
|
|
25
|
+
description: 'Creates an invoice at Acme for a customer.',
|
|
26
|
+
errorPrefix: 'Acme',
|
|
27
|
+
errors: { Refused: 'Acme refused the invoice; its answer is in the cause' },
|
|
28
|
+
input: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
required: ['customer', 'amount'],
|
|
31
|
+
properties: { customer: { type: 'string' }, amount: { type: 'number', minimum: 0 } },
|
|
32
|
+
},
|
|
33
|
+
output: { type: 'object', properties: { id: { type: 'string' } } },
|
|
34
|
+
run: async ({ input, fetch, signal, redact, idempotencyKey }) => {
|
|
35
|
+
const answer = await fetch('https://api.acme.example/invoices', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json', 'idempotency-key': idempotencyKey },
|
|
38
|
+
body: JSON.stringify(input),
|
|
39
|
+
signal,
|
|
40
|
+
})
|
|
41
|
+
if (!answer.ok) throw new StepError('Acme.Refused', `${answer.status}: ${redact(await answer.text()).slice(0, 200)}`)
|
|
42
|
+
return { id: ((await answer.json()) as { id: string }).id }
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`definePlugin` checks the plugin's names: its `name` is lowercase letters, digits and `_`, each type's key is lowercase words joined by `_` or `.`, and a type that names a connection must find it among the plugin's `connections`.
|
|
50
|
+
|
|
51
|
+
### Installing it
|
|
52
|
+
|
|
53
|
+
- **The app's own:** the default export of `workflows/plugins/<name>.ts`, found by its path as jobs are. An [extension](config.md#extensions) adds its own from its folder.
|
|
54
|
+
- **From a package:** listed by name in [`workflows.plugins`](config/jobs-and-events.md#workflowsplugins), its default export the plugin. The package is a dependency of the app.
|
|
55
|
+
|
|
56
|
+
Two plugins of one name stop startup, naming both. A plugin is server code that runs with the app's rights, like the app's own code: load only plugins you trust. The interface's version is `pluginApi`, `1`; releases add optional keys to it and never break it.
|
|
57
|
+
|
|
58
|
+
## Step types
|
|
59
|
+
|
|
60
|
+
| Key | |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `label`, `description` | What the type list shows, to a person and to a model. |
|
|
63
|
+
| `input` | The JSON Schema of the Task's `Arguments`, checked before `run`: arguments that do not fit fail the step as `<errorPrefix>.InvalidArguments`, naming the field. |
|
|
64
|
+
| `output` | The JSON Schema of what `run` returns, for the type list. |
|
|
65
|
+
| `errorPrefix`, `errors` | The prefix of its error names, `Step` by default, and the errors it fails with by name, each with what it means. |
|
|
66
|
+
| `connection` | The connection it signs in with, by the name the plugin declares it under. |
|
|
67
|
+
| `run(context)` | Returns the step's result. It throws a `StepError` to fail with an error name of its own; any other error is `States.TaskFailed`. |
|
|
68
|
+
|
|
69
|
+
What `run` gets, and nothing more:
|
|
70
|
+
|
|
71
|
+
| | |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `input` | The Task's evaluated `Arguments`, checked. |
|
|
74
|
+
| `fetch(url, init)` | The only way a plugin should send a request: see [Sending requests](#sending-requests). |
|
|
75
|
+
| `records` | `create`, `createMany`, `update`, `get` and `list` of the app's records, through the API as the user who saved the version the run runs, with the roles they hold now: their access rules, tenant, validation and write hooks apply. A refusal fails the step with `Records.Refused`. |
|
|
76
|
+
| `files` | `keep(bytes, name, type)` stores a file of the run and returns its file value; `read(value, field)` reads one the run kept. No other file of the app is reachable. |
|
|
77
|
+
| `mail` | `send(message)` from the app's own sender; `undefined` when the app sends no mail. |
|
|
78
|
+
| `connection` | `accessToken()` of the connection the step declares, or `undefined`. |
|
|
79
|
+
| `runId`, `idempotencyKey` | The run, and a key that every retry of this visit of the step shares, while a later visit of the same state, such as in a loop, gets a new one: send it where the receiver deduplicates. |
|
|
80
|
+
| `signal` | Aborted at the step's timeout, or when the worker stops; pass it on to `fetch`. |
|
|
81
|
+
| `log(message)`, `redact(text)` | A line in the step's logs, and the text with the app's secrets blanked out, both as the runner records them. |
|
|
82
|
+
|
|
83
|
+
### Sending requests
|
|
84
|
+
|
|
85
|
+
`fetch` resolves the URL's host once, refuses it unless every address is public, and connects to the very address it checked, so a URL built from a run's data cannot reach the app's own network or a cloud metadata address. It follows no redirect: a 3xx comes back as it is, and a plugin that follows one calls `fetch` again with the new URL, whose host is checked in turn. It reads at most `maxBytes` of the answer's body as it comes, 10 MiB by default and at most, and never decodes a `Content-Encoding`, so a large or endless answer fails with `ResponseTooLargeError` instead of filling memory; a refused address fails with `AddressRefusedError`. [`workflows.allowPrivateAddresses`](config/jobs-and-events.md#workflowsallowprivateaddresses) lets it reach private addresses, for development and tests.
|
|
86
|
+
|
|
87
|
+
A plugin sends through `context.fetch`, never through the global `fetch`, Node's `http`, `https`, `net` or `tls`, or an HTTP client of its own: those are not guarded, so a workflow's data could make them reach the app's own network. The built-in plugins are held to this by the repository's boundary check, which fails on any of them.
|
|
88
|
+
|
|
89
|
+
## Trigger types
|
|
90
|
+
|
|
91
|
+
A trigger type has a `label`, a `description`, an `input` schema that a save checks the trigger's input against, an optional `check(input)` for what a schema cannot say, and a `kind`.
|
|
92
|
+
|
|
93
|
+
### Ready
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
triggers: {
|
|
97
|
+
overdue: {
|
|
98
|
+
kind: 'ready',
|
|
99
|
+
label: 'Overdue invoice',
|
|
100
|
+
description: 'Starts a run for each invoice three days past its due date.',
|
|
101
|
+
input: { type: 'object', properties: {} },
|
|
102
|
+
ready: () => ({ resource: 'invoices', when: "status = 'sent' AND due_at <= now() - 3d", key: ['due_at'] }),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`ready(input)` returns the resource, the readiness filter `when` and the `key` fields, and the engine runs it as it runs [`app:record`](workflows.md#apprecord): one run per record that becomes ready, at the time its time terms say, read as the user who saved the enabled version.
|
|
108
|
+
|
|
109
|
+
### Polling and webhook (coming soon)
|
|
110
|
+
|
|
111
|
+
The interface declares `kind: 'polling'`, with `every`, `limit`, `start` and `poll`, and `kind: 'webhook'`, with `receive` and the optional `subscribe`, `unsubscribe` and `renew`. <gap>A plugin's polling or webhook trigger cannot start runs because the polling and webhook triggers are not implemented; the type list marks them unavailable.</gap>
|
|
112
|
+
|
|
113
|
+
## Connections (coming soon)
|
|
114
|
+
|
|
115
|
+
`connections` declares the sign-ins a plugin's types use: one of the app's sign-in providers by name (`signIn`), or an OAuth provider of its own (`oauth`), with the `scopes` it asks for. A step or trigger names one as its `connection`, and gets only that one, through the workflow's own binding: the context takes no connection id, so a plugin cannot reach another workflow's sign-in. <gap>A type that declares a connection cannot run because connections are not implemented; the type list marks it unavailable, and a save that uses it is refused.</gap>
|
|
116
|
+
|
|
117
|
+
## The type list
|
|
118
|
+
|
|
119
|
+
[`list_workflow_types`](mcp.md#workflows) over MCP lists every step and trigger type the app has, built-in and plugin ones alike, as their plugins declare them: id, label, description, input and output schemas, errors and connection, with `available: false` and a reason for a type the app cannot run. A save, and `validate_workflow`, check a definition against the same list.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Workflows written as state machines in the States Language with JSONata, checked on every save, run step by step on the app's jobs, with every step recorded.
|
|
6
6
|
|
|
7
7
|
:::caution[Beta]
|
|
8
|
-
Workflows run when they are started through the API, over MCP, from the app's code or by a test run in the admin app.
|
|
8
|
+
Workflows run when they are started through the API, over MCP, from the app's code or by a test run in the admin app, and from record triggers. Webhook and polling triggers and sign-in connections to other services are not built yet.
|
|
9
9
|
:::
|
|
10
10
|
|
|
11
11
|
A **workflow** is a graph of steps that the server runs one after another along its edges: fetch something, decide, run a step per item, merge the results, write records, send mail. It is written in the **States Language**, the JSON language of state machines, with **JSONata** as its expression language, in a profile of it that reads every value by name. A language model writes and changes it as easily as a person, and the same document is what the server checks and runs. Each run records every step it takes, with what the step read, gave and logged.
|
|
@@ -18,7 +18,7 @@ import { defineConfig } from '@protobase/config'
|
|
|
18
18
|
export default defineConfig({ workflows: { secrets: ['TICKETS_TOKEN'] } })
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
It adds
|
|
21
|
+
It adds five resources, over a `workflows` schema that [`protobase migration`](cli.md#migration) writes (`NNN_workflows.sql`), and the `workflows.segment` [job](jobs.md) that runs them:
|
|
22
22
|
|
|
23
23
|
| Resource | |
|
|
24
24
|
| --- | --- |
|
|
@@ -26,12 +26,14 @@ It adds four resources, over a `workflows` schema that [`protobase migration`](c
|
|
|
26
26
|
| `workflow_versions` | Every version of every definition as it was saved, with who saved it and when. Read-only. |
|
|
27
27
|
| `workflow_runs` | A run, started by creating it with `workflow_id` and an `input`, or with `rerun_of` to [re-run a failed run](#re-running-a-failed-run); it runs the version its workflow had then. Its status, output, error and timing are written by the runner. |
|
|
28
28
|
| `workflow_steps` | Every step of every run. Read-only. |
|
|
29
|
+
| `workflow_triggers` | The trigger of each enabled workflow that has one besides `manual`: its type, status and last error. Read-only. |
|
|
29
30
|
|
|
30
31
|
| `workflows` | |
|
|
31
32
|
| --- | --- |
|
|
32
33
|
| `schema` | The schema of its tables, for a database that has a `workflows` schema already. Default `workflows`. |
|
|
33
34
|
| `secrets` | The environment variables workflows may read, by name. Startup stops when one is unset or shorter than 8 characters. |
|
|
34
|
-
| `allowPrivateAddresses` | Lets the `app:http` step reach private, loopback and link-local addresses, for development and tests. Default false. |
|
|
35
|
+
| `allowPrivateAddresses` | Lets the `app:http` step, and every plugin's `fetch`, reach private, loopback and link-local addresses, for development and tests. Default false. |
|
|
36
|
+
| `plugins` | Packages of [workflow plugins](workflow-plugins.md) to run, by name, beside the app's own in `workflows/plugins/<name>.ts`. |
|
|
35
37
|
|
|
36
38
|
Records steps act as each workflow's last editor, with the roles the project's sign-in gives them now. A project that signs people in otherwise exports its own, `async ({ id, tenant }) => [...]`, as the default of `workflows/saved-by-roles.ts` (`createAdmin` takes it as `workflows.savedByRoles`); without either, startup stops.
|
|
37
39
|
|
|
@@ -41,7 +43,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
|
|
|
41
43
|
|
|
42
44
|
```json
|
|
43
45
|
{
|
|
44
|
-
"trigger": { "record": { "
|
|
46
|
+
"trigger": { "app:record": { "resource": "orders", "when": "status = 'placed'" } },
|
|
45
47
|
"stateMachine": {
|
|
46
48
|
"QueryLanguage": "JSONata",
|
|
47
49
|
"StartAt": "Charge",
|
|
@@ -52,7 +54,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
|
|
|
52
54
|
"Arguments": {
|
|
53
55
|
"method": "POST",
|
|
54
56
|
"url": "https://payments.example.com/charges",
|
|
55
|
-
"body": { "order_id": "{% $states.context.Execution.Input.
|
|
57
|
+
"body": { "order_id": "{% $states.context.Execution.Input.record.id %}", "amount": "{% $states.context.Execution.Input.record.total %}" }
|
|
56
58
|
},
|
|
57
59
|
"Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 10, "MaxAttempts": 3, "BackoffRate": 2 }],
|
|
58
60
|
"Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "MarkFailed", "Assign": { "chargeError": "{% $states.errorOutput %}" } }],
|
|
@@ -61,7 +63,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
|
|
|
61
63
|
"MarkFailed": {
|
|
62
64
|
"Type": "Task",
|
|
63
65
|
"Resource": "app:records.update",
|
|
64
|
-
"Arguments": { "resource": "orders", "key": "{% $states.context.Execution.Input.
|
|
66
|
+
"Arguments": { "resource": "orders", "key": "{% $states.context.Execution.Input.record.id %}", "data": { "status": "payment_failed" } },
|
|
65
67
|
"Next": "TellOps"
|
|
66
68
|
},
|
|
67
69
|
"TellOps": {
|
|
@@ -69,7 +71,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
|
|
|
69
71
|
"Resource": "app:mail.send",
|
|
70
72
|
"Arguments": {
|
|
71
73
|
"to": "ops@example.com",
|
|
72
|
-
"subject": "{% 'Payment failed for order ' & $states.context.Execution.Input.
|
|
74
|
+
"subject": "{% 'Payment failed for order ' & $states.context.Execution.Input.record.id %}",
|
|
73
75
|
"text": "{% $chargeError.Cause %}"
|
|
74
76
|
},
|
|
75
77
|
"End": true
|
|
@@ -83,17 +85,33 @@ The document holds no layout: the [editor](#the-editor) lays the graph out from
|
|
|
83
85
|
|
|
84
86
|
### Triggers
|
|
85
87
|
|
|
86
|
-
`trigger` holds
|
|
88
|
+
`trigger` holds one key: `manual`, or a trigger type `<plugin>:<key>` with its input. The built-in ones are the `app` plugin's; a [workflow plugin](workflow-plugins.md) adds its own the same way, and `list_workflow_types` over MCP lists them all with their inputs.
|
|
87
89
|
|
|
88
90
|
| Trigger | Starts a run | The input holds |
|
|
89
91
|
| --- | --- | --- |
|
|
90
|
-
| `"webhook": {}` | on each POST to the workflow's webhook address | `{ body, headers, query }` |
|
|
91
|
-
| `"schedule": { "cron", "timezone" }` | on a 5-field cron, in an IANA time zone (default `UTC`) | `{ scheduled_at }`, ISO 8601 with the zone's offset |
|
|
92
|
-
| `"mail": { "mailbox" }` | on each mail arriving in that mailbox | `{ from, to, subject, text, html, received_at, attachments }` |
|
|
93
|
-
| `"record": { "events": ["<resource>.<event>"] }` | on each of those record events, such as `deals.created` | `{ event, resource, key, after, before, changed }` |
|
|
94
92
|
| `"manual": {}` | when someone runs the workflow by hand | the input they give |
|
|
93
|
+
| `"app:record": { "resource", "when", "key" }` | once per record that becomes ready | `{ resource, key, record }` |
|
|
95
94
|
|
|
96
|
-
A save checks a trigger's
|
|
95
|
+
A save checks a trigger's input against its type, and refuses a type the app does not have; besides triggers, runs start through the API, over MCP, from the app's code or by a [test run](#test-runs). <gap>A workflow cannot start from a webhook or a schedule because the webhook and polling triggers are not implemented.</gap>
|
|
96
|
+
|
|
97
|
+
### Turning a trigger on
|
|
98
|
+
|
|
99
|
+
A trigger starts runs only while its workflow is enabled: set `enabled_version` to the workflow's current `version` through the API, in the admin app or with `update_workflow`, and clear it to turn the workflow off. The trigger is registered at once on every server process, with no restart, and its status, `active` or `failing` with its last error, is the workflow's `workflow_triggers` record. A save of an enabled workflow keeps it enabled on its new version; a new version whose trigger has the same type and input keeps the running trigger as it is, and any other change stops it and starts the new one. A trigger's runs run the enabled version and act as the user who saved it, as any run acts as its version's saver.
|
|
100
|
+
|
|
101
|
+
### app:record
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{ "app:record": { "resource": "invoices", "when": "status = 'sent' AND due_at <= now() - 3d", "key": ["due_at"] } }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Starts one run for each record of `resource` that becomes ready, with `{ resource, key, record }` as the run's input, so the record's fields are read as `$states.context.Execution.Input.record.<field>`. Without `when`, every record is ready once it exists, so each new record starts one run.
|
|
108
|
+
|
|
109
|
+
- **`when`** is a [filter](api.md#filters) on the resource's filterable fields. `now()` may stand only in AND terms of the form `<timestamp or date field> <= now() - <duration>` (or `<`): such a term says when a record becomes ready, and the run starts at that time with no write, such as three days after an invoice's `due_at`. Any other use of `now()`, such as inside an OR, `search()` and `similar()` are refused at save, naming the term.
|
|
110
|
+
- **One run per ready key:** the record's key, plus the values of the `key` fields. A record that stays ready over later writes starts no second run; one whose `due_at` changes is ready again under a new key.
|
|
111
|
+
- **Who reads it:** the record is read through the API as the user who saved the enabled version, in the workflow's tenant, and `when` is checked on what they may read, so a record they cannot read, or a field they cannot see, never reaches a run. A saver who holds no role any more, or whom the API refuses, marks the trigger `failing`; the next write it reads a record on makes it active again, and it then passes over the records it missed. A key field the saver cannot read marks it `failing` too.
|
|
112
|
+
- **When it looks:** a write to the resource, through the API or the database, reaches the trigger through [events](events.md), so the resource must declare some; turning a workflow on also starts runs for the records ready already, 100 per job. No periodic sweep runs.
|
|
113
|
+
|
|
114
|
+
<gap>A workflow cannot start on every write of a record because an every-change trigger is not implemented; a record trigger starts once per ready key.</gap> <gap>An owner is not told when a trigger starts failing because trigger alerts are not implemented.</gap>
|
|
97
115
|
|
|
98
116
|
### Reading values by name
|
|
99
117
|
|
|
@@ -113,7 +131,7 @@ A state's `Assign` is evaluated with the variables as they were when the state w
|
|
|
113
131
|
|
|
114
132
|
The save is refused, naming each state and field, when the document does not fit the profile:
|
|
115
133
|
|
|
116
|
-
- a field does not belong to its state or has the wrong type, or the trigger is not exactly one of
|
|
134
|
+
- a field does not belong to its state or has the wrong type, or the trigger is not exactly one of `manual` or an available `<plugin>:<key>` type with input that fits that type's schema;
|
|
117
135
|
- a `Next`, `Default` or catcher names no state, a state is never reached, or a state name is used twice, Maps included;
|
|
118
136
|
- a Task names a step type the app does not have, or the state type is not one of Task, Choice, Pass, Map, Wait, Succeed and Fail;
|
|
119
137
|
- an expression does not parse, or reads anything but the names above: the state's input as JSONata's context (a bare field name, `$`, `$$`), `$states.input` outside an item's first state, `$states.context` but for the three reads above, a variable no state that can run before it assigns, or `$eval`;
|
|
@@ -121,7 +139,7 @@ The save is refused, naming each state and field, when the document does not fit
|
|
|
121
139
|
|
|
122
140
|
The problem's `problems` lists them as `{ state, field, message }`, where a state inside a Map is named by its path, such as `Each/Total`. The document's JSON Schema is `definitionSchema` of `@protobase/workflows`, and `checkDefinition` runs the whole check.
|
|
123
141
|
|
|
124
|
-
<gap>A save cannot refuse a Task's
|
|
142
|
+
A save refuses a Task that leaves out an argument its step type needs, as [the editor](#the-editor) describes. <gap>A save cannot refuse a Task's unknown argument because checking `Arguments` against the step type's whole schema at save is not implemented.</gap> The step fails its run instead, with an error such as `Http.InvalidArguments` whose cause names the argument.
|
|
125
143
|
|
|
126
144
|
### States
|
|
127
145
|
|
|
@@ -146,16 +164,18 @@ A failing Task or Map fails with an error name: a step type's own, such as `Http
|
|
|
146
164
|
A workflow's record page in the admin app, `/workflows/<id>`, and its create form draw the definition as a graph to edit, and save the same document the API saves:
|
|
147
165
|
|
|
148
166
|
- **The graph lays itself out.** The trigger is on top, each state is a box, a Map is a box around the states it runs for each item, and each edge is drawn from the field that names it: a state's `Next`, a Choice's rules (labelled with their condition) and its `Default` ("Otherwise"), and a Task's or Map's catchers ("On error", dashed). The graph is laid out by ELK's layered algorithm, from top to bottom, each time a state or edge is added, removed or renamed; the document keeps no positions, so the graph a person sees is the one a model or the API wrote, and the one they edit is what a model reads back. The page scrolls under the wheel; the canvas zooms with its buttons and by pinching.
|
|
149
|
-
- **Adding a step.** *Add a step* adds a Task of
|
|
167
|
+
- **Adding a step.** *Add a step* adds a Task of any step type the app can run, its plugins' included, or a Choice, Map, Wait, Pass, Succeed or Fail, named `Step 1`, `Step 2` and so on. It goes into the Map chosen, or the Map the chosen step is in, else into the workflow; it ends the run, or its item, until it is connected, and in a workflow or Map without steps it is the first.
|
|
150
168
|
- **Connecting steps.** Drag from a step's handle to another step, or tap one handle and then the other: the bottom handle sets `Next`, a Choice's *When* handle adds a rule and its *Otherwise* handle sets `Default`, a Task's or Map's *On error* handle adds a catcher of `States.ALL`, and the trigger's handle sets `StartAt`. A step leads only to steps of its own machine, so an edge into or out of a Map is refused. The chosen step's form sets the same edges with menus: *Goes on to*, each rule's and catcher's *Then goes to*, and *Otherwise goes to*.
|
|
151
169
|
- **Removing.** Delete or Backspace removes the chosen step or edge, as does the form's *Remove step*. Removing a step removes the edges to it: a `Next` to it becomes `End`, and a rule, `Default` or catcher naming it goes. Removing the first step of a workflow or Map makes the first step left in it the first.
|
|
152
|
-
- **A step's form.** Choosing a step opens its form under the graph: its name, which renames every edge to it, then what its type takes. A Task's form has a field for each argument of its step type, the ones it needs marked, as listed under [Step types](#step-types); a Choice's its rules and `Default`; a Map's `Items` and `MaxConcurrency`; a Wait's `Seconds`; a Pass's and a Succeed's `Output`; a Fail's `Error` and `Cause`. A field takes text, an expression (`{% ... %}`) or JSON; an emptied field is left out. *The step as JSON* edits every field of the step, such as `Retry`, `Assign` and `TimeoutSeconds`. The trigger's form picks the
|
|
170
|
+
- **A step's form.** Choosing a step opens its form under the graph: its name, which renames every edge to it, then what its type takes. A Task's form has a field for each argument of its step type's input schema, the ones it needs marked, as listed under [Step types](#step-types) or by its plugin: one line of text for a string, one line for a number or `true` and `false`, a menu for an `enum`, names separated by commas for a list of strings, and JSON for anything else; a Choice's its rules and `Default`; a Map's `Items` and `MaxConcurrency`; a Wait's `Seconds`; a Pass's and a Succeed's `Output`; a Fail's `Error` and `Cause`. A field takes text, an expression (`{% ... %}`) or JSON; an emptied field is left out. *The step as JSON* edits every field of the step, such as `Retry`, `Assign` and `TimeoutSeconds`. The trigger's form picks among the app's triggers, its plugins' included, and draws a field for each input of its schema the same way, such as a record trigger's resource, `when` and `key`.
|
|
153
171
|
- **Problems on their step.** The editor runs the same check as a save on every change, and shows each problem on the step it names, as a count on its box and a list and a marked field in its form; a problem with the trigger or the first step shows on the trigger, and one about the whole document above the graph. Saving writes the definition through the API, where the save hook checks it again: a definition with a problem is refused with the 400 above, whose `errors` hold each problem for the `definition` field, with its state and field in `params`.
|
|
154
172
|
|
|
155
|
-
A save also refuses a Task that leaves out an argument its step type needs, such as `app:http` without `url`, unless its `Arguments` is one expression, whose value is known only when it runs. A user who may read a workflow but not change it sees the same graph and forms, locked. <gap>The editor cannot undo a single change because an undo history is not implemented.</gap>
|
|
173
|
+
A save also refuses a Task that leaves out an argument its step type needs, such as `app:http` without `url`, unless its `Arguments` is one expression, whose value is known only when it runs. A user who may read a workflow but not change it sees the same graph and forms, locked. <gap>The editor cannot undo a single change because an undo history is not implemented.</gap> The editor reads the app's types from `GET /api/workflows/types`, `client.workflowTypes()` of `@protobase/client`: the same list `list_workflow_types` gives over MCP, `{ step_types, triggers }`, which only a user who may read workflows gets. A type the app cannot run, such as a polling or webhook trigger in this version, is not offered, and the check refuses it with the reason; a stored trigger of such a type, or of a type the app does not have, shows its type and input, read-only. <gap>A box on the graph cannot name a plugin's trigger or step type by its label because handing the type list to the canvas is not implemented; it shows the type's id, such as `acme:shout`.</gap>
|
|
156
174
|
|
|
157
175
|
## Step types
|
|
158
176
|
|
|
177
|
+
The built-in step types are the `app` plugin's, written on the same [plugin interface](workflow-plugins.md) as any other; a plugin's step is a Task with `"Resource": "<plugin>:<key>"`. A step's `Arguments` are checked against its type's input schema before it runs: one that does not fit fails as `<Prefix>.InvalidArguments`, such as `Http.InvalidArguments`.
|
|
178
|
+
|
|
159
179
|
### app:http
|
|
160
180
|
|
|
161
181
|
One request to a URL built at run time:
|
|
@@ -248,9 +268,9 @@ The workflow resources take only [named grants](access.md#resources-that-need-na
|
|
|
248
268
|
|
|
249
269
|
| Grant | |
|
|
250
270
|
| --- | --- |
|
|
251
|
-
| `workflows.create`, `workflows.update`, `workflows.delete` | Make, change and delete workflows. Each save that changes the definition makes the saver the workflow's last editor, whom its records steps act as. |
|
|
271
|
+
| `workflows.create`, `workflows.update`, `workflows.delete` | Make, change and delete workflows, and turn them on and off. Each save that changes the definition makes the saver the workflow's last editor, whom its records steps and its trigger act as. |
|
|
252
272
|
| `workflow_runs.create` | Run a workflow, with any input. The API writes only the fields a caller reads, so it goes with `workflow_runs.read`, and the caller must read the workflow too. |
|
|
253
|
-
| `workflows.read`, `workflow_versions.read`, `workflow_runs.read`, `workflow_steps.read` | Read workflows, their versions, runs and
|
|
273
|
+
| `workflows.read`, `workflow_versions.read`, `workflow_runs.read`, `workflow_steps.read`, `workflow_triggers.read` | Read workflows, their versions, runs, steps and triggers, the steps' inputs and outputs included. |
|
|
254
274
|
|
|
255
275
|
```ts
|
|
256
276
|
defineRoles({
|
|
@@ -259,7 +279,7 @@ defineRoles({
|
|
|
259
279
|
})
|
|
260
280
|
```
|
|
261
281
|
|
|
262
|
-
All
|
|
282
|
+
All five are scoped to the tenant like any resource with `.tenant()`. A run takes its workflow's tenant; a start of another tenant's workflow is refused as one of a workflow that is not there.
|
|
263
283
|
|
|
264
284
|
## How a run is run
|
|
265
285
|
|
|
@@ -267,7 +287,7 @@ The runner is `@protobase/workflows`, a package with no other Protobase dependen
|
|
|
267
287
|
|
|
268
288
|
- A run is run in **segments**, each a `workflows.segment` job whose payload is the run's id alone: one runs the run from where it stands to its next wait, then queues the next. A Wait whose time is still ahead, or a retry's delay, saves its step with the time the run goes on, ends the segment and queues the next one for that time, so a waiting run holds no worker. A segment hands the rest of the run on after a minute of steps.
|
|
269
289
|
- Where a run stands is read from its saved steps. A Task's step, and one with a time the run goes on at, is saved before the next state runs; the steps of other states are kept and saved with it, or as the segment ends. Each save is **one statement**, unique by each step's scope and number, so a second segment of the same run that finds one taken stops.
|
|
270
|
-
- A worker that stops or dies mid-step leaves that step unsaved: its job is tried again and takes the run up from its last saved step, so a saved step never runs again, and the step that was in progress runs **at least once**, as do the steps kept since the last save, which give the same records again. A step whose effect happened before its result was saved, such as a request that was sent, happens again. Every try of a step gets the same idempotency key, for a step type whose receiver dedupes by it, and `app:mail.send` makes it the mail's Message-ID. A job whose worker died is tried again once its timeout, 10 minutes, has passed.
|
|
290
|
+
- A worker that stops or dies mid-step leaves that step unsaved: its job is tried again and takes the run up from its last saved step, so a saved step never runs again, and the step that was in progress runs **at least once**, as do the steps kept since the last save, which give the same records again. A step whose effect happened before its result was saved, such as a request that was sent, happens again. Every try of a step gets the same idempotency key, for a step type whose receiver dedupes by it, and `app:mail.send` makes it the mail's Message-ID. `app:records.create` keeps it with the records it creates, in their transaction, so a try after they committed creates none again and returns them as the first try created them. A job whose worker died is tried again once its timeout, 10 minutes, has passed.
|
|
271
291
|
- A Map's items run in the segment that reaches them, each item's steps saved under its own scope; an item that waits ends the segment once the others running settle, and the next segment takes each item up where it stands. A Map's `Items` is evaluated again by a segment that takes up an unfinished Map.
|
|
272
292
|
- JSONata runs in the server's process under a time and a depth limit per expression; it reaches no file, network or code of the app.
|
|
273
293
|
|