@protobase/cli 0.67.7 → 0.67.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/package.js +1 -1
- package/dist/config/build-project.js +4 -2
- package/dist/config/project-parts.js +21 -4
- package/dist/dev/entry.js +2 -1
- package/dist/doctor/built-in-models.js +13 -7
- package/dist/migration/load-project.js +1 -0
- package/dist/migration/parts.js +7 -2
- package/dist/project/project-admin.js +1 -0
- package/dist/serve/lifecycle.js +2 -1
- package/package.json +7 -7
- package/skills/protobase/SKILL.md +1 -0
- package/skills/protobase/references/features/workflows.md +16 -0
- package/skills/protobase/references/features.md +1 -0
- package/skills/protobase/references/reference/cli.md +1 -1
- package/skills/protobase/references/reference/config/jobs-and-events.md +45 -1
- package/skills/protobase/references/reference/config.md +3 -2
- package/skills/protobase/references/reference/data-config.md +1 -1
- package/skills/protobase/references/reference/jobs.md +1 -1
- package/skills/protobase/references/reference/versioning.md +1 -1
- package/skills/protobase/references/reference/workflows.md +243 -0
package/dist/cli/package.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { projectFiles, projectFunctions, projectJobs, projectNotifications, projectOptions, projectResources, projectWebhooks } from "./project-parts.js";
|
|
1
|
+
import { projectFiles, projectFunctions, projectJobs, projectNotifications, projectOptions, projectResources, projectWebhooks, projectWorkflows } from "./project-parts.js";
|
|
2
2
|
import { userTablesOn } from "./user-tables-config.js";
|
|
3
3
|
import { buildAppAuth, buildAuthenticator, checkCallsOptions, checkStatusOptions } from "@protobase/server/internal";
|
|
4
4
|
//#region src/config/build-project.ts
|
|
@@ -44,13 +44,14 @@ const projectAuthenticate = async (config, auth) => {
|
|
|
44
44
|
};
|
|
45
45
|
/** The app, built from its merged config over its database: every reference loaded, auth and the authenticator made. */
|
|
46
46
|
const buildProject = async (config, database) => {
|
|
47
|
-
const [resources, functions, files, options, auth, webhooks, notifications, mailer] = await Promise.all([
|
|
47
|
+
const [resources, functions, files, options, auth, webhooks, workflows, notifications, mailer] = await Promise.all([
|
|
48
48
|
projectResources(config),
|
|
49
49
|
projectFunctions(config),
|
|
50
50
|
projectFiles(config),
|
|
51
51
|
projectOptions(config),
|
|
52
52
|
projectAuth(config, database),
|
|
53
53
|
projectWebhooks(config),
|
|
54
|
+
projectWorkflows(config),
|
|
54
55
|
projectNotifications(config),
|
|
55
56
|
projectMailer(config)
|
|
56
57
|
]);
|
|
@@ -69,6 +70,7 @@ const buildProject = async (config, database) => {
|
|
|
69
70
|
...config.events && { events: config.events },
|
|
70
71
|
...config.history && { history: config.history },
|
|
71
72
|
...webhooks && { webhooks },
|
|
73
|
+
...workflows && { workflows },
|
|
72
74
|
...config.realtime && { realtime: config.realtime },
|
|
73
75
|
...notifications && { notifications },
|
|
74
76
|
...config.status && { status: checkStatusOptions(config.status) },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkUnique } from "./merge-config.js";
|
|
2
|
-
import { checkNotificationsOptions, declaredJob, fileProviderOf, isSubscription, webhooksModule } from "@protobase/server/internal";
|
|
2
|
+
import { checkNotificationsOptions, declaredJob, fileProviderOf, isSubscription, webhooksModule, workflowsModule } from "@protobase/server/internal";
|
|
3
3
|
import { isPageDefinition } from "@protobase/layout";
|
|
4
4
|
//#region src/config/project-parts.ts
|
|
5
5
|
/** The config modules' exports as one module: every value once, under a key no other config uses. */
|
|
@@ -38,7 +38,7 @@ const mergedModule = (modules) => {
|
|
|
38
38
|
}
|
|
39
39
|
return merged;
|
|
40
40
|
};
|
|
41
|
-
/** The resources, views, pages and subscriptions of every config's config/ folder, after the
|
|
41
|
+
/** The resources, views, pages and subscriptions of every config's config/ folder, after the pages of `webhooks` and `workflows`. */
|
|
42
42
|
const projectResources = async (config) => {
|
|
43
43
|
const modules = await Promise.all(config.sources.flatMap(({ files, label }) => files.resources ? [files.resources().then((module) => ({
|
|
44
44
|
module,
|
|
@@ -48,7 +48,15 @@ const projectResources = async (config) => {
|
|
|
48
48
|
module: webhooksModule(config.webhooks),
|
|
49
49
|
label: "protobase-webhooks"
|
|
50
50
|
}] : [];
|
|
51
|
-
|
|
51
|
+
const workflows = config.workflows ? [{
|
|
52
|
+
module: workflowsModule(config.workflows),
|
|
53
|
+
label: "protobase-workflows"
|
|
54
|
+
}] : [];
|
|
55
|
+
return mergedModule([
|
|
56
|
+
...webhooks,
|
|
57
|
+
...workflows,
|
|
58
|
+
...modules
|
|
59
|
+
]);
|
|
52
60
|
};
|
|
53
61
|
/** The declared jobs, each run by the default export of its jobs/<name>.ts, loaded on its first run; schedules in the app's `timezone` unless a job names its own. */
|
|
54
62
|
const projectJobs = (config) => Object.entries(config.jobs).map(([name, { declaration, files, label }]) => {
|
|
@@ -184,6 +192,15 @@ const projectWebhooks = async (config) => {
|
|
|
184
192
|
...ownerRoles && { ownerRoles }
|
|
185
193
|
};
|
|
186
194
|
};
|
|
195
|
+
/** The workflows options, with the roles of workflows/saved-by-roles.ts when a config's folder has it. */
|
|
196
|
+
const projectWorkflows = async (config) => {
|
|
197
|
+
if (!config.workflows) return void 0;
|
|
198
|
+
const savedByRoles = await lastDefault(config, "workflows/saved-by-roles.ts");
|
|
199
|
+
return {
|
|
200
|
+
...config.workflows,
|
|
201
|
+
...savedByRoles && { savedByRoles }
|
|
202
|
+
};
|
|
203
|
+
};
|
|
187
204
|
/**
|
|
188
205
|
* The notifications options, checked, with what the conventions find in notifications/: the device store of store.ts,
|
|
189
206
|
* the history store of history-store.ts and the push senders of senders.ts, each its default export.
|
|
@@ -203,4 +220,4 @@ const projectNotifications = async (config) => {
|
|
|
203
220
|
});
|
|
204
221
|
};
|
|
205
222
|
//#endregion
|
|
206
|
-
export { lastModule, mergedModule, projectAssistantTools, projectFiles, projectFunctions, projectJobs, projectMcpTools, projectNotifications, projectOptions, projectResources, projectRoles, projectWebhooks, projectWriteHooks };
|
|
223
|
+
export { lastModule, mergedModule, projectAssistantTools, projectFiles, projectFunctions, projectJobs, projectMcpTools, projectNotifications, projectOptions, projectResources, projectRoles, projectWebhooks, projectWorkflows, projectWriteHooks };
|
package/dist/dev/entry.js
CHANGED
|
@@ -7,7 +7,7 @@ import { devRealtimePrune } from "./dev-prune.js";
|
|
|
7
7
|
import { devDatabase, loadDevConfig } from "./project.js";
|
|
8
8
|
import { withDevServiceWorker } from "./service-worker-route.js";
|
|
9
9
|
import { setMissing, supabaseEnv } from "./supabase-env.js";
|
|
10
|
-
import { apiKeySettings, assistantSettings, authSchemaDrift, authStoreTables, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkUserTablesSchema, checkWebhooksSchema, configExports, hasUsers, relayed, sqlSends } from "@protobase/server";
|
|
10
|
+
import { apiKeySettings, assistantSettings, authSchemaDrift, authStoreTables, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkUserTablesSchema, checkWebhooksSchema, checkWorkflowsSchema, configExports, hasUsers, relayed, sqlSends } from "@protobase/server";
|
|
11
11
|
import { upgradeWebSocket } from "@hono/node-server";
|
|
12
12
|
import { Hono } from "hono";
|
|
13
13
|
//#region src/dev/entry.ts
|
|
@@ -38,6 +38,7 @@ checkAi({
|
|
|
38
38
|
env: process.env
|
|
39
39
|
});
|
|
40
40
|
if (project.webhooks) await checkWebhooksSchema(db, project.webhooks);
|
|
41
|
+
if (project.workflows) await checkWorkflowsSchema(db, project.workflows);
|
|
41
42
|
if (relayed(project.realtime)) await checkRealtimeRelay(db);
|
|
42
43
|
if (sqlSends(project.realtime)) await checkChannelSends(db);
|
|
43
44
|
if (project.notifications) await checkNotificationsSchema(db, project.notifications, project.auth);
|
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
import { builtInResources, configExports, webhooksModule } from "@protobase/server/config-exports";
|
|
1
|
+
import { builtInResources, configExports, webhooksModule, workflowsModule } from "@protobase/server/config-exports";
|
|
2
2
|
//#region src/doctor/built-in-models.ts
|
|
3
3
|
/**
|
|
4
4
|
* The resources and views Protobase adds to an app by what its config enables, as the API serves them: the webhook ones
|
|
5
|
-
* of `webhooks`, then Failed jobs, Activity and Comments by what the app's resources use. Doctor checks them as it checks
|
|
5
|
+
* of `webhooks` and the workflow ones of `workflows`, then Failed jobs, Activity and Comments by what the app's resources use. Doctor checks them as it checks
|
|
6
6
|
* the app's own.
|
|
7
7
|
*/
|
|
8
8
|
const builtInModels = (config, models) => {
|
|
9
|
-
const
|
|
9
|
+
const none = {
|
|
10
10
|
resources: [],
|
|
11
11
|
views: []
|
|
12
12
|
};
|
|
13
|
-
const
|
|
13
|
+
const webhooks = config.webhooks ? configExports(webhooksModule(config.webhooks)) : none;
|
|
14
|
+
const workflows = config.workflows ? configExports(workflowsModule(config.workflows)) : none;
|
|
15
|
+
const moduleModels = [...webhooks.resources, ...workflows.resources].map((resource) => resource.toModel());
|
|
14
16
|
const added = builtInResources({
|
|
15
|
-
models: [...
|
|
17
|
+
models: [...moduleModels, ...models],
|
|
16
18
|
jobs: Object.keys(config.jobs).length > 0,
|
|
17
19
|
notifications: config.notifications !== void 0,
|
|
18
20
|
...config.events && { events: config.events }
|
|
19
21
|
});
|
|
20
22
|
return {
|
|
21
|
-
models: [...
|
|
22
|
-
views: [
|
|
23
|
+
models: [...moduleModels, ...added.resources.map((resource) => resource.toModel())],
|
|
24
|
+
views: [
|
|
25
|
+
...webhooks.views,
|
|
26
|
+
...workflows.views,
|
|
27
|
+
...added.views
|
|
28
|
+
].map((view) => view.toModel())
|
|
23
29
|
};
|
|
24
30
|
};
|
|
25
31
|
/**
|
|
@@ -40,6 +40,7 @@ const migrationProject = async (config, url) => {
|
|
|
40
40
|
...auth && { auth },
|
|
41
41
|
...config.events && { events: config.events },
|
|
42
42
|
...webhooks && { webhooks },
|
|
43
|
+
...config.workflows && { workflows: config.workflows },
|
|
43
44
|
...config.realtime && { realtime: config.realtime },
|
|
44
45
|
...config.calls && { calls: config.calls },
|
|
45
46
|
...notifications && { notifications },
|
package/dist/migration/parts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { authMigrationTitle } from "../auth/migration-file.js";
|
|
2
|
-
import { authSchemaMigration, callsMigration, changesMigration, channelSendsMigration, commentsMigration, computedMigration, isCaptured, jobsSchemaMigration, notificationsMigration, ownedDefinitionsMigration, queryRunnerMigration, realtimeRelayMigration, relayed, sqlSends, statusMigration, userTablesMigration, webhooksMigration } from "@protobase/server";
|
|
2
|
+
import { authSchemaMigration, callsMigration, changesMigration, channelSendsMigration, commentsMigration, computedMigration, isCaptured, jobsSchemaMigration, notificationsMigration, ownedDefinitionsMigration, queryRunnerMigration, realtimeRelayMigration, relayed, sqlSends, statusMigration, userTablesMigration, webhooksMigration, workflowsMigration } from "@protobase/server";
|
|
3
3
|
//#region src/migration/parts.ts
|
|
4
4
|
/** Every feature the project's config enables, in the order their files are written. */
|
|
5
5
|
const migrationParts = (project) => {
|
|
@@ -11,7 +11,7 @@ const migrationParts = (project) => {
|
|
|
11
11
|
title: authMigrationTitle,
|
|
12
12
|
sql: () => authSchemaMigration(project.auth)
|
|
13
13
|
}] : [],
|
|
14
|
-
...project.jobs.length > 0 || project.models.some(isCaptured) || comments || aiFields || project.webhooks || project.notifications ? [{
|
|
14
|
+
...project.jobs.length > 0 || project.models.some(isCaptured) || comments || aiFields || project.webhooks || project.workflows || project.notifications ? [{
|
|
15
15
|
name: "jobs",
|
|
16
16
|
title: "The jobs schema this version of Protobase needs",
|
|
17
17
|
sql: () => jobsSchemaMigration(project.db)
|
|
@@ -41,6 +41,11 @@ const migrationParts = (project) => {
|
|
|
41
41
|
title: "The webhook subscriptions of webhooks",
|
|
42
42
|
sql: () => webhooksMigration(project.db, project.webhooks, project.events)
|
|
43
43
|
}] : [],
|
|
44
|
+
...project.workflows ? [{
|
|
45
|
+
name: "workflows",
|
|
46
|
+
title: "The workflows, their versions, runs and steps",
|
|
47
|
+
sql: () => workflowsMigration(project.db, project.workflows)
|
|
48
|
+
}] : [],
|
|
44
49
|
...project.notifications && !(project.notifications.store && project.notifications.historyStore) ? [{
|
|
45
50
|
name: "notifications",
|
|
46
51
|
title: "The tables of notifications",
|
|
@@ -32,6 +32,7 @@ const projectAdmin = (project, db, ui, reporter, upgradeWebSocket, userTables) =
|
|
|
32
32
|
...project.events && { events: project.events },
|
|
33
33
|
...project.history && { history: project.history },
|
|
34
34
|
...project.webhooks && { webhooks: project.webhooks },
|
|
35
|
+
...project.workflows && { workflows: project.workflows },
|
|
35
36
|
...project.realtime && { realtime: project.realtime },
|
|
36
37
|
...project.notifications && { notifications: project.notifications },
|
|
37
38
|
...project.status && { status: project.status },
|
package/dist/serve/lifecycle.js
CHANGED
|
@@ -9,7 +9,7 @@ import { startCleanupTimer } from "./cleanup-timer.js";
|
|
|
9
9
|
import { closeSockets } from "./close-sockets.js";
|
|
10
10
|
import { servedRelease } from "./mobile-release.js";
|
|
11
11
|
import pg from "pg";
|
|
12
|
-
import { assistantSettings, authStoreTables, backgroundSettled, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkUserTablesSchema, checkWebhooksSchema, configExports, createFileCleanup, relayed, sqlSends, startJobs } from "@protobase/server";
|
|
12
|
+
import { assistantSettings, authStoreTables, backgroundSettled, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkUserTablesSchema, checkWebhooksSchema, checkWorkflowsSchema, configExports, createFileCleanup, relayed, sqlSends, startJobs } from "@protobase/server";
|
|
13
13
|
import { createAdaptorServer, upgradeWebSocket } from "@hono/node-server";
|
|
14
14
|
//#region src/serve/lifecycle.ts
|
|
15
15
|
const listen = (server, port) => new Promise((resolve, reject) => {
|
|
@@ -44,6 +44,7 @@ const startServe = async ({ config, env, openDb = (url) => openAppDatabase(pg, u
|
|
|
44
44
|
env: process.env
|
|
45
45
|
});
|
|
46
46
|
if (env.startupChecks && project.webhooks) await checkWebhooksSchema(db, project.webhooks);
|
|
47
|
+
if (env.startupChecks && project.workflows) await checkWorkflowsSchema(db, project.workflows);
|
|
47
48
|
if (env.startupChecks && relayed(project.realtime)) await checkRealtimeRelay(db);
|
|
48
49
|
if (env.startupChecks && sqlSends(project.realtime)) await checkChannelSends(db);
|
|
49
50
|
if (env.startupChecks && project.notifications) await checkNotificationsSchema(db, project.notifications, project.auth);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.9",
|
|
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,12 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@better-auth/core": "^1.7.12",
|
|
45
45
|
"@hono/node-server": "^2.1.3",
|
|
46
|
-
"@protobase/config": "0.67.
|
|
47
|
-
"@protobase/layout": "0.67.
|
|
48
|
-
"@protobase/query": "0.67.
|
|
49
|
-
"@protobase/schema": "0.67.
|
|
50
|
-
"@protobase/server": "0.67.
|
|
51
|
-
"@protobase/ui": "0.67.
|
|
46
|
+
"@protobase/config": "0.67.9",
|
|
47
|
+
"@protobase/layout": "0.67.9",
|
|
48
|
+
"@protobase/query": "0.67.9",
|
|
49
|
+
"@protobase/schema": "0.67.9",
|
|
50
|
+
"@protobase/server": "0.67.9",
|
|
51
|
+
"@protobase/ui": "0.67.9",
|
|
52
52
|
"@react-email/render": "^2.1.0",
|
|
53
53
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
54
54
|
"@sentry/vite-plugin": "^11.10.0",
|
|
@@ -88,6 +88,7 @@ Reference:
|
|
|
88
88
|
- When filling fields with a model → read [AI fields and actions](references/reference/ai.md)
|
|
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
|
+
- When writing or running workflows, their steps and their runs → read [Workflows](references/reference/workflows.md)
|
|
91
92
|
- When sending email → read [Email](references/reference/email.md)
|
|
92
93
|
- When making a PDF file, such as an invoice, a quote or a report → read [PDF documents](references/reference/pdf.md)
|
|
93
94
|
- When registering users' devices for push notifications → read [Notifications](references/reference/notifications.md)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!-- Generated by scripts/protobase-skill.ts from docs/website/src/content/docs/features/workflows.md: run `bun run docs:skill`, do not edit. -->
|
|
2
|
+
|
|
3
|
+
# Workflows
|
|
4
|
+
|
|
5
|
+
Graphs of steps the server runs along their edges, written in a language a model writes well, with every step recorded.
|
|
6
|
+
|
|
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
|
+
- [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
|
+
- [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 or reads a value nothing gives is refused, naming the step and the field.
|
|
10
|
+
- [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.
|
|
11
|
+
- [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.
|
|
12
|
+
- [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.
|
|
13
|
+
- [Mail step](../reference/workflows.md#appmailsend) (beta): Sends mail, with files the run downloaded attached, from the app's own sender.
|
|
14
|
+
- [Runs and step records](../reference/workflows.md#runs-and-their-steps) (beta): Each run started by hand through the API or from the app's code, with every step's input, output, logs, timing, tries and error recorded, secrets blanked out.
|
|
15
|
+
- [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.
|
|
16
|
+
- [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.
|
|
@@ -32,6 +32,7 @@ A feature without a mark is stable. The others say so after their name:
|
|
|
32
32
|
- [Files and images](features/files-and-images.md): Uploaded files and images, where they are kept and how they are served.
|
|
33
33
|
- [History, comments and live updates](features/history-comments-and-live-updates.md): The changes of each record, comments on it, and pages that refresh as records change.
|
|
34
34
|
- [Events, webhooks and jobs](features/events-webhooks-and-jobs.md): React to changes, deliver them to other systems, and run work in the background.
|
|
35
|
+
- [Workflows](features/workflows.md): Graphs of steps the server runs along their edges, written in a language a model writes well, with every step recorded.
|
|
35
36
|
- [Notifications](features/notifications.md): A bell in the top bar and push to browsers and phones, for what your app tells its users.
|
|
36
37
|
- [Calls](features/calls.md): Audio and video calls between an app's users, with Protobase deciding who is in a call and who hears whom.
|
|
37
38
|
- [Email and public forms](features/email-and-public-forms.md): Mail the app sends, the PDF documents it makes, and forms for people without an account.
|
|
@@ -595,7 +595,7 @@ Applies the project's `db/migrations/*.sql` that the database has not applied ye
|
|
|
595
595
|
protobase migration [--dir db/migrations] [--extend <dir>]
|
|
596
596
|
```
|
|
597
597
|
|
|
598
|
-
Writes what the database still needs for every feature the config enables, as the project's next migrations in `--dir`, one file per feature: the auth schema when sign-in is on (`NNN_auth.sql`, as [`auth migration`](#auth-migration) writes it), the [jobs schema](jobs.md#the-jobs-schema) when the config has jobs, `webhooks` or `notifications`, or a resource has events, history, comments or an [AI field](ai.md) (`NNN_jobs.sql`), the [capture of events](events.md#migrations-and-startup) and the [history table](history.md#the-table) (`NNN_changes.sql`), the columns of [rollup and formula fields](data-config.md#rollups-and-formulas) and the triggers that keep the rollups (`NNN_computed.sql`), the role the [assistant's read-only queries](assistant.md#the-built-in-backend) run as, when it finds the built-in assistant's key (`NNN_query.sql`), the [comments table](comments.md#the-table) (`NNN_comments.sql`), the [webhook subscriptions](webhooks.md#subscriptions-in-the-admin-app) of `webhooks` (`NNN_webhooks.sql`), the [devices and history tables](notifications.md#table-names) of `notifications` (`NNN_notifications.sql`), the `realtime_relay` schema of [`realtime: { processes: 'many' }`](supabase-realtime.md#several-processes) (`NNN_realtime_relay.sql`), the `channels` schema of [`realtime: { sqlSends: true }`](channels.md#from-sql) (`NNN_channel_sends.sql`), and, with [`userTables`](user-tables.md#the-switch), the schemas of user tables (`NNN_user_tables.sql`) and the delete of the definitions of tables that resources in code [own now](user-tables.md#taking-a-table-over-as-code) (`NNN_owned_definitions.sql`). It compares the database, with the project's migrations applied, with the config (for the auth schema, it reads the store's record of the migrations it applied), merged with those of `--extend` as `dev` merges them, and writes nothing when the database is current. Apply the files with the project's other migrations. It reads the `.env` nearest the current directory and needs `DATABASE_URL`.
|
|
598
|
+
Writes what the database still needs for every feature the config enables, as the project's next migrations in `--dir`, one file per feature: the auth schema when sign-in is on (`NNN_auth.sql`, as [`auth migration`](#auth-migration) writes it), the [jobs schema](jobs.md#the-jobs-schema) when the config has jobs, `webhooks`, `workflows` or `notifications`, or a resource has events, history, comments or an [AI field](ai.md) (`NNN_jobs.sql`), the [capture of events](events.md#migrations-and-startup) and the [history table](history.md#the-table) (`NNN_changes.sql`), the columns of [rollup and formula fields](data-config.md#rollups-and-formulas) and the triggers that keep the rollups (`NNN_computed.sql`), the role the [assistant's read-only queries](assistant.md#the-built-in-backend) run as, when it finds the built-in assistant's key (`NNN_query.sql`), the [comments table](comments.md#the-table) (`NNN_comments.sql`), the [webhook subscriptions](webhooks.md#subscriptions-in-the-admin-app) of `webhooks` (`NNN_webhooks.sql`), the [workflows, versions, runs and steps](workflows.md#turning-them-on) of `workflows` (`NNN_workflows.sql`), the [devices and history tables](notifications.md#table-names) of `notifications` (`NNN_notifications.sql`), the `realtime_relay` schema of [`realtime: { processes: 'many' }`](supabase-realtime.md#several-processes) (`NNN_realtime_relay.sql`), the `channels` schema of [`realtime: { sqlSends: true }`](channels.md#from-sql) (`NNN_channel_sends.sql`), and, with [`userTables`](user-tables.md#the-switch), the schemas of user tables (`NNN_user_tables.sql`) and the delete of the definitions of tables that resources in code [own now](user-tables.md#taking-a-table-over-as-code) (`NNN_owned_definitions.sql`). It compares the database, with the project's migrations applied, with the config (for the auth schema, it reads the store's record of the migrations it applied), merged with those of `--extend` as `dev` merges them, and writes nothing when the database is current. Apply the files with the project's other migrations. It reads the `.env` nearest the current directory and needs `DATABASE_URL`.
|
|
599
599
|
|
|
600
600
|
```sh
|
|
601
601
|
bun run db:migrate # the database at the project's current schema
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Jobs and events options
|
|
4
4
|
|
|
5
|
-
The config keys of what the app does in the background and records as it changes, jobs, events, history, webhooks, notifications and status, with their types and defaults, and the files where a job's run and the stores of its own go.
|
|
5
|
+
The config keys of what the app does in the background and records as it changes, jobs, events, history, webhooks, workflows, notifications and status, with their types and defaults, and the files where a job's run and the stores of its own go.
|
|
6
6
|
|
|
7
7
|
The keys of the app's background work and of what it keeps as records change, at the top level of [`protobase.config.ts`](../config.md).
|
|
8
8
|
|
|
@@ -209,6 +209,50 @@ Lets subscriptions send to private, loopback and link-local addresses, for devel
|
|
|
209
209
|
export default defineConfig(({ command }) => ({ webhooks: { allowPrivateAddresses: command === 'dev' } }))
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
+
## workflows
|
|
213
|
+
|
|
214
|
+
- **Type:** `{ schema?: string; secrets?: string[]; allowPrivateAddresses?: boolean }`
|
|
215
|
+
- **Default:** off; `{}` turns them on
|
|
216
|
+
|
|
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).
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
export default defineConfig({ workflows: {} })
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## workflows.schema
|
|
224
|
+
|
|
225
|
+
- **Type:** `string`
|
|
226
|
+
- **Default:** `workflows`
|
|
227
|
+
|
|
228
|
+
The schema of the workflows, their versions, runs and steps.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
export default defineConfig({ workflows: { schema: 'flows' } })
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## workflows.secrets
|
|
235
|
+
|
|
236
|
+
- **Type:** `string[]`
|
|
237
|
+
- **Default:** none
|
|
238
|
+
|
|
239
|
+
The environment variables workflows may read, each as `$states.context.Secrets.<NAME>`; no other variable is reachable from a workflow. Their values are blanked out of everything a run records but its input, which is kept as its caller gave it. Startup stops when one is unset or shorter than 8 characters.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
export default defineConfig({ workflows: { secrets: ['TICKETS_TOKEN'] } })
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## workflows.allowPrivateAddresses
|
|
246
|
+
|
|
247
|
+
- **Type:** `boolean`
|
|
248
|
+
- **Default:** `false`
|
|
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 step, which connects only to the address it checked, so a workflow cannot reach the app's own network.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
export default defineConfig(({ command }) => ({ workflows: { allowPrivateAddresses: command === 'dev' } }))
|
|
254
|
+
```
|
|
255
|
+
|
|
212
256
|
## notifications
|
|
213
257
|
|
|
214
258
|
- **Type:** `{ ui?: boolean; schema?: string; devices?: { table?: string; columns?: Record<string, string> }; history?: { table?: string; columns?: Record<string, string> } }`
|
|
@@ -30,7 +30,7 @@ Each key is described with its type and default in the option pages:
|
|
|
30
30
|
| [Sign-in options](config/sign-in.md) | `auth`, `authenticate`, `signingKeyDays`, `signingKeyGraceDays` |
|
|
31
31
|
| [API options](config/api.md) | `apiPath`, `apiKeys`, `cors`, `defaultAccess`, `rowPermissions`, `statementTimeoutMs`, `scanGuard`, `errorReporting`, `writeHooks`, `mcpEndpoint`, `remoteResources`, `runtimeUpdates`, `realtime`, `calls`, `userTables` |
|
|
32
32
|
| [Admin app options](config/admin-app.md) | `assistant`, `maps`, `plugins` |
|
|
33
|
-
| [Jobs and events options](config/jobs-and-events.md) | `jobs`, `events`, `history`, `webhooks`, `notifications`, `status` |
|
|
33
|
+
| [Jobs and events options](config/jobs-and-events.md) | `jobs`, `events`, `history`, `webhooks`, `workflows`, `notifications`, `status` |
|
|
34
34
|
| [Files options](config/files.md) | `files` |
|
|
35
35
|
|
|
36
36
|
## Config types
|
|
@@ -140,6 +140,7 @@ The other files belong to an option, and are described with it:
|
|
|
140
140
|
| `mcp/tools.ts` | [`mcpEndpoint`](config/api.md#mcpendpoint) |
|
|
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
|
+
| `workflows/saved-by-roles.ts` | [`workflows`](config/jobs-and-events.md#workflows) |
|
|
143
144
|
| `notifications/store.ts`, `notifications/history-store.ts`, `notifications/senders.ts` | [`notifications`](config/jobs-and-events.md#notifications) |
|
|
144
145
|
| `files/<name>.ts` | [`files.providers`](config/files.md#filesproviders) |
|
|
145
146
|
|
|
@@ -165,7 +166,7 @@ export default defineUi({ extends: [tasksUi] })
|
|
|
165
166
|
| `files.trustedHosts` | all kept | |
|
|
166
167
|
| the other keys of `files` | each the app's own; otherwise the last config that sets it | |
|
|
167
168
|
| `writeHooks` | all run, in the same order | |
|
|
168
|
-
| `config/roles.ts`, `auth/authenticate.ts`, `audit/queue.ts`, `hooks/error.ts`, `webhooks/owner-roles.ts`, `notifications/*.ts` | the app's own; otherwise the last extension's | |
|
|
169
|
+
| `config/roles.ts`, `auth/authenticate.ts`, `audit/queue.ts`, `hooks/error.ts`, `webhooks/owner-roles.ts`, `workflows/saved-by-roles.ts`, `notifications/*.ts` | the app's own; otherwise the last extension's | |
|
|
169
170
|
| `auth/hooks.ts`, `auth/mailer.ts` | those of the last folder whose config sets `auth`; otherwise the app's | |
|
|
170
171
|
| every other key (`auth`, `authenticate`, `assistant`, ...) | the app's own; otherwise the last config that sets it | |
|
|
171
172
|
| UI components (exported by name or in `components`), `actions` | all kept | an error naming the name and both configs |
|
|
@@ -88,7 +88,7 @@ Field and resource names are snake_case by convention, like the columns: they ar
|
|
|
88
88
|
| `f.timestamp()` | `timestamp`, `timestamptz` | ISO 8601 with offset | an instant, shown on the viewer's clock, in their browser's time zone, which hovering over it names |
|
|
89
89
|
| `f.uuid()` | `uuid` | string | |
|
|
90
90
|
| `f.enum(['a', 'b'])` | Postgres enum, or text with a `CHECK (... IN ...)` | one of the values | |
|
|
91
|
-
| `f.json()` | `json`, `jsonb` | any JSON | |
|
|
91
|
+
| `f.json()` | `json`, `jsonb` | any JSON | nested at most 256 levels deep (each array or object is a level); a deeper value is refused |
|
|
92
92
|
| `f.relation('resource')` | the key column(s) of another resource | its key | `.columns([...])` |
|
|
93
93
|
| `f.currency()` | `text` | ISO 4217 code (`EUR`) | |
|
|
94
94
|
| `f.country()` | `text` | ISO 3166-1 alpha-2 code (`NL`) | |
|
|
@@ -183,4 +183,4 @@ bun run db:migrate
|
|
|
183
183
|
|
|
184
184
|
[`protobase migration`](cli.md#migration) writes what the database lacks, taken from pg-boss's own construction and migration plans for the pinned version, without their `BEGIN` and `COMMIT` and with any `CONCURRENTLY` index build made a plain one, so the file runs in the project's migration transaction. The server never creates or migrates it: `protobase dev` and `protobase serve` read its version at startup and refuse to start while it is missing or behind, naming the command. A database whose jobs schema is newer than the running Protobase is refused too.
|
|
185
185
|
|
|
186
|
-
pg-boss runs here without what needs a session of its own: no `LISTEN`/`NOTIFY` (a job enqueued in the same process wakes its worker directly, the rest is polled), only transaction-scoped advisory locks, unnamed prepared statements, no index rebuilds and no instance registry. It works through PgBouncer in transaction mode
|
|
186
|
+
pg-boss runs here without what needs a session of its own: no `LISTEN`/`NOTIFY` (a job enqueued in the same process wakes its worker directly, the rest is polled), only transaction-scoped advisory locks, unnamed prepared statements, no index rebuilds and no instance registry. It works through PgBouncer in transaction mode, over the app's own connection pool.
|
|
@@ -44,7 +44,7 @@ Every commit message follows [Conventional Commits](https://www.conventionalcomm
|
|
|
44
44
|
|
|
45
45
|
`.github/workflows/release.yml` runs on every push to `main`:
|
|
46
46
|
|
|
47
|
-
1. The checks of `ci.yml` (`bun run check`, the smoke test on Node, the docs build, the Storybook play tests and the docs check), of the tests and steps the changes since the last `v<version>` tag affect (see What a push checks in `
|
|
47
|
+
1. The checks of `ci.yml` (`bun run check`, the smoke test on Node, the docs build, the Storybook play tests and the docs check), of the tests and steps the changes since the last `v<version>` tag affect (see What a push checks in `docs/ci.md`), and commitlint over the pushed commits. The Postgres suite (`bun run test:postgres`, on Postgres and PgBouncer) runs in full as well, whatever changed, beside the other checks. They start at once, and the release waits for them all.
|
|
48
48
|
2. `bun run release` (`scripts/release.mjs`, with [conventional-changelog](https://github.com/conventional-changelog/conventional-changelog)) computes the next version and its notes from the commits since the last `v<version>` tag, as above, and tags the pushed commit `v<version>`. A squash-merged pull request counts with each commit its body lists as a `* ` item, so one titled `chore` that carries a fix or feature is released; an item an older commit since the tag lists already counts once. It commits nothing and changes no file. Without a fix, feature or breaking change it tags nothing.
|
|
49
49
|
3. The tag is pushed, and the GitHub release of the tag gets the notes.
|
|
50
50
|
4. Beside the checks, a pack job takes the tag `bun run release --dry-run` predicts, and `scripts/stamp-version.mjs` writes its version into every `package.json` and `bun.lock` in the runner, never committed. `scripts/publish.mjs --pack --out` then builds every package to `dist` and packs it with Bun (the private `@protobase/presets` is not published). Once the tag is pushed, npm, under Node, publishes those tarballs with [trusted publishing](https://docs.npmjs.com/trusted-publishers) and provenance (`scripts/publish.mjs --from`); there is no npm token, and only the publish job holds the identity token it needs. A tag other than the version the tarballs were stamped with fails the publish before anything is uploaded, and a run that cut no release packs and publishes nothing.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<!-- Generated by scripts/protobase-skill.ts from docs/website/src/content/docs/reference/workflows.md: run `bun run docs:skill`, do not edit. -->
|
|
2
|
+
|
|
3
|
+
# Workflows
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
:::caution[Beta]
|
|
8
|
+
Workflows run when they are started through the API or from the app's code. Triggers, sign-in connections to other services, MCP tools for them and a graph editor are not built yet.
|
|
9
|
+
:::
|
|
10
|
+
|
|
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.
|
|
12
|
+
|
|
13
|
+
## Turning them on
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { defineConfig } from '@protobase/config'
|
|
17
|
+
|
|
18
|
+
export default defineConfig({ workflows: { secrets: ['TICKETS_TOKEN'] } })
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
It adds four 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
|
+
|
|
23
|
+
| Resource | |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `workflows` | A name, a description and the definition. Each save that changes the definition is checked, and kept as the workflow's next version, saved by the caller. |
|
|
26
|
+
| `workflow_versions` | Every version of every definition as it was saved, with who saved it and when. Read-only. |
|
|
27
|
+
| `workflow_runs` | A run, started by creating it with `workflow_id` and an `input`; it runs the version its workflow had then. Its status, output, error and timing are written by the runner. |
|
|
28
|
+
| `workflow_steps` | Every step of every run. Read-only. |
|
|
29
|
+
|
|
30
|
+
| `workflows` | |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `schema` | The schema of its tables, for a database that has a `workflows` schema already. Default `workflows`. |
|
|
33
|
+
| `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
|
+
|
|
36
|
+
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
|
+
|
|
38
|
+
## The document
|
|
39
|
+
|
|
40
|
+
A definition is one JSON document: the workflow's trigger, and the state machine a run runs from `StartAt`. Every state names where the run goes next, and every value in it is JSON; a string written as `{% ... %}` is a JSONata expression, evaluated when the state runs, and keeps its value's type.
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"trigger": { "record": { "events": ["orders.created"] } },
|
|
45
|
+
"stateMachine": {
|
|
46
|
+
"QueryLanguage": "JSONata",
|
|
47
|
+
"StartAt": "Charge",
|
|
48
|
+
"States": {
|
|
49
|
+
"Charge": {
|
|
50
|
+
"Type": "Task",
|
|
51
|
+
"Resource": "app:http",
|
|
52
|
+
"Arguments": {
|
|
53
|
+
"method": "POST",
|
|
54
|
+
"url": "https://payments.example.com/charges",
|
|
55
|
+
"body": { "order_id": "{% $states.context.Execution.Input.after.id %}", "amount": "{% $states.context.Execution.Input.after.total %}" }
|
|
56
|
+
},
|
|
57
|
+
"Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 10, "MaxAttempts": 3, "BackoffRate": 2 }],
|
|
58
|
+
"Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "MarkFailed", "Assign": { "chargeError": "{% $states.errorOutput %}" } }],
|
|
59
|
+
"End": true
|
|
60
|
+
},
|
|
61
|
+
"MarkFailed": {
|
|
62
|
+
"Type": "Task",
|
|
63
|
+
"Resource": "app:records.update",
|
|
64
|
+
"Arguments": { "resource": "orders", "key": "{% $states.context.Execution.Input.after.id %}", "data": { "status": "payment_failed" } },
|
|
65
|
+
"Next": "TellOps"
|
|
66
|
+
},
|
|
67
|
+
"TellOps": {
|
|
68
|
+
"Type": "Task",
|
|
69
|
+
"Resource": "app:mail.send",
|
|
70
|
+
"Arguments": {
|
|
71
|
+
"to": "ops@example.com",
|
|
72
|
+
"subject": "{% 'Payment failed for order ' & $states.context.Execution.Input.after.id %}",
|
|
73
|
+
"text": "{% $chargeError.Cause %}"
|
|
74
|
+
},
|
|
75
|
+
"End": true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The document holds no layout: an editor lays the graph out from its edges.
|
|
83
|
+
|
|
84
|
+
### Triggers
|
|
85
|
+
|
|
86
|
+
`trigger` holds exactly one of these; its payload is the run's input.
|
|
87
|
+
|
|
88
|
+
| Trigger | Starts a run | The input holds |
|
|
89
|
+
| --- | --- | --- |
|
|
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
|
+
| `"manual": {}` | when someone runs the workflow by hand | the input they give |
|
|
95
|
+
|
|
96
|
+
A save checks a trigger's shape; runs start through the API or the app's code. <gap>A webhook, schedule, mail or record trigger cannot start runs because the triggers are not implemented.</gap>
|
|
97
|
+
|
|
98
|
+
### Reading values by name
|
|
99
|
+
|
|
100
|
+
Every value a state reads has a name, so a step can be moved or added without the data around it shifting:
|
|
101
|
+
|
|
102
|
+
| Name | Holds |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `$states.context.Execution.Input` | the run's input, the same in every state |
|
|
105
|
+
| `$states.context.Execution.StartTime` | when the run started, ISO 8601 |
|
|
106
|
+
| `$states.context.Secrets.<NAME>` | the secret of that name, one `workflows.secrets` lists |
|
|
107
|
+
| `$<variable>` | a value an earlier state assigned with `Assign` |
|
|
108
|
+
| `$states.result` | a Task's or Map's result, in its own `Output` and `Assign` |
|
|
109
|
+
| `$states.errorOutput` | the caught error, `{ Error, Cause }`, in a catcher's `Output` and `Assign` |
|
|
110
|
+
| `$states.input` | the item, in the first state of a Map's item processor only |
|
|
111
|
+
|
|
112
|
+
A state's `Assign` is evaluated with the variables as they were when the state was entered; the new values are seen from the next state on. States inside a Map read the outer variables but may not assign one of the same name.
|
|
113
|
+
|
|
114
|
+
The save is refused, naming each state and field, when the document does not fit the profile:
|
|
115
|
+
|
|
116
|
+
- a field does not belong to its state or has the wrong type, or the trigger is not exactly one of the above, with a 5-field cron and a known time zone;
|
|
117
|
+
- a `Next`, `Default` or catcher names no state, a state is never reached, or a state name is used twice, Maps included;
|
|
118
|
+
- 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
|
+
- 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`;
|
|
120
|
+
- a limit below is passed.
|
|
121
|
+
|
|
122
|
+
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
|
+
|
|
124
|
+
### States
|
|
125
|
+
|
|
126
|
+
| Type | |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| `Task` | Runs a step type, its `Resource`, with its `Arguments`; `TimeoutSeconds`, `Retry` and `Catch`. |
|
|
129
|
+
| `Pass` | Shapes values with `Output` and `Assign`, and goes on. |
|
|
130
|
+
| `Choice` | Goes to the `Next` of the first of its `Choices` whose `Condition` is true, else to its `Default`; with neither it fails with `States.NoChoiceMatched`. A rule may carry its own `Output` and `Assign`. |
|
|
131
|
+
| `Wait` | Waits `Seconds`, or until a `Timestamp` (ISO 8601), as a number or an expression. |
|
|
132
|
+
| `Map` | Runs its `ItemProcessor` once per item of `Items`, at most `MaxConcurrency` at once (0, the default, means the most, 20); its result is the items' outputs in order. |
|
|
133
|
+
| `Succeed` | Ends the run, or the Map item, with its `Output`. |
|
|
134
|
+
| `Fail` | Ends it as failed with its `Error` and `Cause`. |
|
|
135
|
+
|
|
136
|
+
A state's output is its `Output`, else its result (a Task or Map) or the input it was given. Fields that read a state's input implicitly (`InputPath`, `Parameters`, `ResultPath` and the other fields of the JSONPath language), and `Parallel`, are not part of the profile.
|
|
137
|
+
|
|
138
|
+
### Errors and retries
|
|
139
|
+
|
|
140
|
+
A failing Task or Map fails with an error name: a step type's own, such as `Http.Status`, `States.Timeout` when it ran past its timeout, `States.TaskFailed` for any other error it threw, and `States.QueryEvaluationError` for an expression that did not evaluate or ran past its limits. The first retrier whose `ErrorEquals` names it tries the state again after `IntervalSeconds` (default 1), times `BackoffRate` (default 2) for each earlier retry, up to `MaxDelaySeconds`; `MaxAttempts` counts the retries after the first try (default 3, 0 for none). Without `Retry` a failure is not retried. Once no retry is left, the first catcher that names it goes on to its `Next`; without one the run fails. `States.ALL` names every error but `States.DataLimitExceeded` and `States.Runtime`, which end the run even where a retrier or catcher names them.
|
|
141
|
+
|
|
142
|
+
## Step types
|
|
143
|
+
|
|
144
|
+
### app:http
|
|
145
|
+
|
|
146
|
+
One request to a URL built at run time:
|
|
147
|
+
|
|
148
|
+
| Argument | |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `url` | An http or https URL. |
|
|
151
|
+
| `method` | `GET` (default), `POST`, `PUT`, `PATCH` or `DELETE`. |
|
|
152
|
+
| `headers`, `query` | Objects of text values; `query` is added to the URL's query. |
|
|
153
|
+
| `body` | Text is sent as it is, a file value as its bytes with its type, any other value as JSON; a `GET` sends none. |
|
|
154
|
+
| `response` | How the answer's body is read: `json` (default, parsed; empty is `null`), `text`, or `file`, kept as a file of the run. |
|
|
155
|
+
|
|
156
|
+
The result is `{ status, headers, body }`, without the cookies the answer sets. A status outside 2xx fails the step with `Http.Status`, whose cause names the status and holds at most the first 200 characters of the body, secrets blanked out first; a redirect is never followed. `Http.Network` is a request that got no answer. The step resolves the URL's host when it runs and refuses it with `Http.AddressRefused` unless every address is public, then connects to the very address it checked, so a URL built from a webhook's payload cannot reach the app's own network. It reads at most 10 MiB of the answer's body: a larger one fails the step with `Http.ResponseTooLarge`, rather than being cut short.
|
|
157
|
+
|
|
158
|
+
### app:records
|
|
159
|
+
|
|
160
|
+
| Step type | Arguments | Result |
|
|
161
|
+
| --- | --- | --- |
|
|
162
|
+
| `app:records.create` | `resource`, `data`: an object of field values, or a list of them, created in one batch: all or none | the created record, or the list of them |
|
|
163
|
+
| `app:records.update` | `resource`, `key`, `data` | the updated record |
|
|
164
|
+
| `app:records.get` | `resource`, `key` | the record |
|
|
165
|
+
| `app:records.list` | `resource`, optional `filter` (the API's filter syntax), `order_by`, `limit` | `{ records }` |
|
|
166
|
+
|
|
167
|
+
They go through the app's API as the user who saved the run's version, with the roles that user holds now in the run's tenant, so its access rules, tenant scope, validation and write hooks apply as they do to that user's own requests. Whoever may start a run (`workflow_runs.create`) thus has it do what the workflow's last editor may do; a workflow never does more. When that user holds no role any more, or the version was saved by the app's code, the step fails with `Records.Refused`, as it does for a refusal of the API, its status and message in the cause. A number for a decimal field is written as its text; a file field takes a file value the run keeps.
|
|
168
|
+
|
|
169
|
+
### app:mail.send
|
|
170
|
+
|
|
171
|
+
| Argument | |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `to` | One address, or a list of at most 50, each one address. |
|
|
174
|
+
| `subject` | One line of text. |
|
|
175
|
+
| `text`, `html` | The body as text, and optionally as HTML. |
|
|
176
|
+
| `attachments` | A list of file values the run keeps, at most 10 MiB together. |
|
|
177
|
+
|
|
178
|
+
The mail goes from the app's own sender with the app's mailer; a workflow cannot set the sender. The result is `{ message_id }`, the Message-ID the step sets, the same for each try of the step. Without mail the step fails.
|
|
179
|
+
|
|
180
|
+
### app:piece
|
|
181
|
+
|
|
182
|
+
`piece`, `action`, `connection` and `props` name an action of a connector and its arguments. A save takes it, and a run fails it with `Piece.Error`. <gap>A piece step cannot run an action because its handler is not implemented.</gap>
|
|
183
|
+
|
|
184
|
+
### File values
|
|
185
|
+
|
|
186
|
+
A file value is `{ uri, name, type, size }`, the handle to bytes in the app's storage that the API returns for a file field. `app:http` with `response: "file"` keeps the answer as a file of the run, in the `private` file provider under the run's id; steps take only the files of their own run, so a workflow cannot attach, send or store another file of the app's by naming its URI. <gap>A workflow cannot send or attach a file it read from a record because steps reading the app's other files is not implemented.</gap>
|
|
187
|
+
|
|
188
|
+
## Runs and their steps
|
|
189
|
+
|
|
190
|
+
A run starts when it is created, through the API or the admin app, in the same transaction: `POST /api/v1/workflow_runs` with `{ "workflow_id": "...", "input": { ... } }`. The input is at most 1 MiB as JSON and nests at most 64 levels; a larger one is refused with `input-too-large` or `input-too-deep` and no run. From the app's own code, such as a job or a write hook, `startWorkflow(db, { workflow, input, tenant, startedBy })` from `@protobase/server` starts one in `db`'s transaction, so it starts when the caller's writes commit, or not at all. With a `key`, such as `orders:42`, a workflow runs once per key: a start with a key it has a run for already, in this transaction or another, starts nothing and returns that run's id.
|
|
191
|
+
|
|
192
|
+
Each step is saved as it ends, as a `workflow_steps` record: the state, its type, the scope it ran in (empty for the run itself, `Each.3[0]` for item 0 of the Map `Each` that was step 3), its number, its try, its status (`succeeded`, `retrying`, `caught` or `failed`), its input, output and assigned variables, its logs, when it started and ended and how long it took, its error and cause, and where it goes on to. A run's status is `queued`, `running`, `waiting` (with the time it goes on), `succeeded` with its output, or `failed` with its error and cause.
|
|
193
|
+
|
|
194
|
+
### Secrets
|
|
195
|
+
|
|
196
|
+
A workflow reads a secret only by name, `$states.context.Secrets.TICKETS_TOKEN`, and only one the config's `workflows.secrets` lists; the values are never part of the definition. Every value of the listed secrets is blanked out as `[redacted]` wherever it appears in what a run records: a step's input, output, variables, logs, error and cause, and the run's output, error and cause. In a Task's recorded input, every field whose name says it holds a secret, such as `Authorization`, `token` or `apiKey`, is blanked out too. A value an expression computes from a secret, however it changes it, such as `'Basic ' & $base64encode(...)`, is kept whole as `[redacted]`: in a Task's `Arguments` the step type gets the value and the record keeps `[redacted]`; in an `Output`, an `Assign`, a Map's `Items` or a Fail's `Error` and `Cause` the value itself is `[redacted]`, so later states read that. Read a secret in the `Arguments` of the Task that sends it. A secret that comes back changed in a step's result, such as one a server encodes in its answer, is not the value the runner looks for, and is kept as it came. A run's input is kept as its caller gave it, so a secret's value never belongs in it.
|
|
197
|
+
|
|
198
|
+
## Who may do what
|
|
199
|
+
|
|
200
|
+
The workflow resources take only [named grants](access.md#resources-that-need-named-grants): a role's wildcard such as `*.read` reaches none of them, since a run reads and sends data as its definition says.
|
|
201
|
+
|
|
202
|
+
| Grant | |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| `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. |
|
|
205
|
+
| `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. |
|
|
206
|
+
| `workflows.read`, `workflow_versions.read`, `workflow_runs.read`, `workflow_steps.read` | Read workflows, their versions, runs and steps, the steps' inputs and outputs included. |
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
defineRoles({
|
|
210
|
+
admin: ['*'],
|
|
211
|
+
builder: ['workflows.*', 'workflow_versions.read', 'workflow_runs.*', 'workflow_steps.read'],
|
|
212
|
+
})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
All four 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.
|
|
216
|
+
|
|
217
|
+
## How a run is run
|
|
218
|
+
|
|
219
|
+
The runner is `@protobase/workflows`, a package with no other Protobase dependency, over a narrow port: where runs and steps are kept (here the `workflows` schema), the queue that runs them (here the app's [jobs](jobs.md)), and the step types.
|
|
220
|
+
|
|
221
|
+
- 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.
|
|
222
|
+
- Where a run stands is read from its saved steps. Each step is saved with **one statement**, unique by its scope and number, so a second segment of the same run saves nothing and stops.
|
|
223
|
+
- 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**. 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.
|
|
224
|
+
- 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.
|
|
225
|
+
- 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.
|
|
226
|
+
|
|
227
|
+
Every limit fails closed: a run or step that passes one fails, rather than going on with less.
|
|
228
|
+
|
|
229
|
+
| Limit | |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| A definition | 256 KiB as JSON, 100 states, Maps included; an expression 4,096 characters. |
|
|
232
|
+
| An expression's evaluation | 1 second, and 300 levels deep; beyond, `States.QueryEvaluationError`. |
|
|
233
|
+
| A Task's `TimeoutSeconds` | 300 by default, and at most. |
|
|
234
|
+
| A retrier's `MaxAttempts` | 19. |
|
|
235
|
+
| The wait before a retry | 1 day, however `BackoffRate` grows it. |
|
|
236
|
+
| A Map's items | 10,000; beyond, the Map fails with `States.DataLimitExceeded` before any item runs. |
|
|
237
|
+
| A Map's `MaxConcurrency` | 20. |
|
|
238
|
+
| Steps of one run, every try and item counted | 50,000; beyond, the run fails with `States.Runtime`. |
|
|
239
|
+
| The input, output and variables a step saves | 1 MiB each, as JSON; beyond, the step fails with `States.DataLimitExceeded`. |
|
|
240
|
+
| A step's logs | 100 lines of 1,000 characters; more lines are left out with a last line saying how many. |
|
|
241
|
+
| A run's input | 1 MiB as JSON, 64 levels deep. |
|
|
242
|
+
| An answer `app:http` reads, a file a run keeps | 10 MiB. |
|
|
243
|
+
| A Wait | 366 days, by `Seconds` or `Timestamp`. |
|