@protobase/cli 0.71.0 → 0.71.2

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.
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.71.0";
2
+ var version = "0.71.2";
3
3
  //#endregion
4
4
  export { version };
@@ -4,7 +4,8 @@ import { AdminAuth } from "@protobase/server";
4
4
  * The sign-in of the project in `projectDir`, built from its config's `auth` over DATABASE_URL, with only the plugins
5
5
  * it turns on. With a device or history store of the app's own (notifications/store.ts, notifications/history-store.ts),
6
6
  * deleting a user removes their devices or history from it too; the built-in tables go with the user by their foreign
7
- * keys.
7
+ * keys. With workflows, deleting a user revokes their connected accounts and parks the triggers on them, as the app
8
+ * does, reporting a token it left valid on stderr.
8
9
  */
9
10
  export declare const loadProjectAuth: (projectDir: string) => Promise<AdminAuth>;
10
11
  //#endregion
@@ -1,23 +1,30 @@
1
1
  import { loadNearestEnvFile } from "../dev/env.js";
2
- import { projectNotifications } from "../config/project-parts.js";
2
+ import { projectNotifications, projectWorkflows } from "../config/project-parts.js";
3
3
  import { projectAuth } from "../config/build-project.js";
4
4
  import { loadSourceConfig } from "../config/source-config.js";
5
5
  import { openAppDatabase } from "../project/pg-db.js";
6
6
  import pg from "pg";
7
- import { removeDevicesWithUsers, removeHistoryWithUsers } from "@protobase/server";
7
+ import { releaseConnectionsWithUsers, removeDevicesWithUsers, removeHistoryWithUsers } from "@protobase/server";
8
8
  //#region src/users/load-auth.ts
9
9
  /**
10
10
  * The sign-in of the project in `projectDir`, built from its config's `auth` over DATABASE_URL, with only the plugins
11
11
  * it turns on. With a device or history store of the app's own (notifications/store.ts, notifications/history-store.ts),
12
12
  * deleting a user removes their devices or history from it too; the built-in tables go with the user by their foreign
13
- * keys.
13
+ * keys. With workflows, deleting a user revokes their connected accounts and parks the triggers on them, as the app
14
+ * does, reporting a token it left valid on stderr.
14
15
  */
15
16
  const loadProjectAuth = async (projectDir) => {
16
17
  loadNearestEnvFile(projectDir);
17
18
  const config = await loadSourceConfig(projectDir, { command: "tool" });
18
19
  if (!config.auth) throw new Error("Sign-in is off (`auth: false` in the config), so the app has no users to manage");
19
20
  if (!process.env.DATABASE_URL) throw new Error("No database: set DATABASE_URL");
20
- const [auth, notifications] = await Promise.all([projectAuth(config, openAppDatabase(pg, process.env.DATABASE_URL)), projectNotifications(config)]);
21
+ const database = openAppDatabase(pg, process.env.DATABASE_URL);
22
+ const [workflows, notifications] = await Promise.all([projectWorkflows(config), projectNotifications(config)]);
23
+ const auth = await projectAuth(config, database, workflows);
24
+ if (workflows) releaseConnectionsWithUsers(auth, workflows, {
25
+ db: database.db,
26
+ report: (error) => void process.stderr.write(`${error.message}\n`)
27
+ });
21
28
  if (notifications?.store) removeDevicesWithUsers(auth, notifications.store);
22
29
  if (notifications?.historyStore) removeHistoryWithUsers(auth, notifications.historyStore);
23
30
  return auth;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.71.0",
3
+ "version": "0.71.2",
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,13 +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.71.0",
47
- "@protobase/layout": "0.71.0",
48
- "@protobase/query": "0.71.0",
49
- "@protobase/schema": "0.71.0",
50
- "@protobase/server": "0.71.0",
51
- "@protobase/ui": "0.71.0",
52
- "@protobase/workflows": "0.71.0",
46
+ "@protobase/config": "0.71.2",
47
+ "@protobase/layout": "0.71.2",
48
+ "@protobase/query": "0.71.2",
49
+ "@protobase/schema": "0.71.2",
50
+ "@protobase/server": "0.71.2",
51
+ "@protobase/ui": "0.71.2",
52
+ "@protobase/workflows": "0.71.2",
53
53
  "@react-email/render": "^2.1.0",
54
54
  "@scalar/hono-api-reference": "^0.12.10",
55
55
  "@sentry/vite-plugin": "^11.10.0",
@@ -21,6 +21,7 @@ Graphs of steps the server runs along their edges, written in a language a model
21
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
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
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
+ - [Step cache](../reference/workflow-plugins.md#caching-results) (beta): A plugin's step reuses the result of an identical earlier call from any run or workflow, as its plugin decides, while secrets always come in with their current value; a workflow can force it on, and the graph marks a step forced on against its plugin's advice.
24
25
  - [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
26
  - [Webhook trigger](../reference/workflows.md#appwebhook) (beta): A workflow starts from a signed call to a URL of its own, limited in size and rate.
26
27
  - [Polling triggers](../reference/workflow-plugins.md#polling-and-webhook) (beta): A workflow starts once for each new item a plugin finds on a schedule, or at each time of a cron with `app:schedule`.
@@ -64,6 +64,7 @@ Two plugins of one name stop startup, naming both. A plugin is server code that
64
64
  | `output` | The JSON Schema of what `run` returns, for the type list. |
65
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
66
  | `connection` | The connection it signs in with, by the name the plugin declares it under. |
67
+ | `cache` | Reuses the result of an identical earlier call: see [Caching results](#caching-results). Without it the engine looks up and keeps nothing for the type, unless a workflow forces it on. |
67
68
  | `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
 
69
70
  What `run` gets, and nothing more:
@@ -80,6 +81,7 @@ What `run` gets, and nothing more:
80
81
  | `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. |
81
82
  | `signal` | Aborted at the step's timeout, or when the worker stops; pass it on to `fetch`. |
82
83
  | `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. |
84
+ | `earlier`, `reuse(output)` | For a type whose cache offers hits: the earlier call's result, as [Caching results](#caching-results) says. |
83
85
 
84
86
  ### Sending requests
85
87
 
@@ -87,6 +89,35 @@ What `run` gets, and nothing more:
87
89
 
88
90
  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.
89
91
 
92
+ ### Caching results
93
+
94
+ A step type can reuse the result of an identical earlier call, from any run of any workflow, so a slow or costly lookup with a small result runs once. Caching is for such steps: a quick read, such as a local file, runs faster than a lookup in the cache, and only fills the app's database. A type without `cache` costs nothing: the engine looks up, keeps and tracks nothing for it.
95
+
96
+ ```ts
97
+ lookup: {
98
+ label: 'Company lookup',
99
+ description: 'Finds a company by its registration number.',
100
+ input: { type: 'object', required: ['number'], properties: { number: { type: 'string' } } },
101
+ cache: { choice: 'force', ttl: '1 day', when: (input) => input.number !== '' },
102
+ run: async ({ input, fetch }) => (await fetch(`https://registry.example/companies/${input.number}`)).json(),
103
+ }
104
+ ```
105
+
106
+ | `cache` | |
107
+ | --- | --- |
108
+ | `choice` | How much the workflow's author decides: `hidden`, nothing; `switch`, a *Cache results* switch, off unless `default` is `true`; `force`, the type decides and the author may force it on; `discouraged`, the same, with a `disclaimer` the editor shows before forcing it on, which the plugin must give. |
109
+ | `ttl` | How long a result serves: seconds, or text such as `10 minutes`, `1 hour` or `2 days`, at most 30 days; or a function of the input. A missing, zero, negative or unreadable one keeps nothing, and the step's logs say so. |
110
+ | `when(input)` | Whether this call may use the cache at all; `false` looks up and keeps nothing. A workflow that forces the cache on overrides it, except for a `switch`, whose `Cache: true` only turns it on. |
111
+ | `use` | `return` (default): a hit is the step's result, and `run` is not called. `offer`: `run` gets the earlier call as `earlier`, `{ output, cached_at, expires_at, secrets_changed }`, and decides: returning `reuse(output)`, with the earlier output as it is or cleaned, keeps the entry as it was; returning anything else is a fresh result that replaces it. `run` gets `earlier` only on a hit. |
112
+ | `key` | What a call's key covers. `reads` (default): only the input values `run` read, everything it serialised, listed or handed to `fetch` counting as read. `input`: the whole input. A function of the input: its value, as JSON; one that throws or gives nothing leaves the call uncached. Under `reads`, a value read only after `run` returned is not in the key, so a type that reads its input in a callback later uses `input`. |
113
+ | `actsAs` | `records`, as the person who saved the version, and `mailbox`, as the person who turned it on: the services a cached call may act through, each adding that person to the key. |
114
+
115
+ Two calls share a result only when their keys match and they act as the same people: the run's tenant always; the saver and the enabler where `actsAs` lists them; and the account the version binds to the type's connection, with the person who turned it on. The app reads these from the run, never from the step. A call that used `records` or `mailbox` without listing them, or touched the run's files, which are the run's alone, runs as always and is not kept. Only a call that succeeded is kept, at most 256 KiB of it as the run records it, so secrets are blanked out. Each type keeps at most 1,000 results per tenant, and the ones that expire soonest go first. A cache that cannot be read within two seconds or written leaves the step to run without it.
116
+
117
+ A workflow forces the cache on with `Cache: true` on its Task, even for a type without `cache` or with a hidden one: such a type keys on its whole input and on every person a step can act as, and keeps a result for an hour. A Task with `Cache: false` never uses it.
118
+
119
+ Secrets are never kept. A step always gets a secret's current value, and a key holds the secret's name where the input held its value, so changing a secret and running again calls the step with the new value whenever the old result no longer matches. A hit whose input named a secret that has changed since says so in the step's logs, and in `secrets_changed`, by a fingerprint of the value under the app's secret, `PROTOBASE_SECRET`. Without one, the app cannot tell. A cached step that decides on access, such as whether someone may do something, keeps its answer until its result expires. The results are kept in the workflows schema's `step_cache`. <gap>A run cannot be replayed with its outside writes not sent and its reads served from the cache because replaying a run is not implemented.</gap>
120
+
90
121
  ## Trigger types
91
122
 
92
123
  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`.
@@ -202,4 +233,4 @@ An account connected through a provider of a connection's own never signs anyone
202
233
 
203
234
  ## The type list
204
235
 
205
- [`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.
236
+ [`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, connection and a step type's cache advice (`choice`, `default` and `disclaimer`), 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.
@@ -190,7 +190,7 @@ A save refuses a Task that leaves out an argument its step type needs, as [the e
190
190
 
191
191
  | Type | |
192
192
  | --- | --- |
193
- | `Task` | Runs a step type, its `Resource`, with its `Arguments`; `TimeoutSeconds`, `Retry` and `Catch`. |
193
+ | `Task` | Runs a step type, its `Resource`, with its `Arguments`; `TimeoutSeconds`, `Retry` and `Catch`. `Cache` reuses the result of an identical earlier call: `true` forces it on, `false` turns it off, and without it the step type decides, as [Caching results](workflow-plugins.md#caching-results) says. |
194
194
  | `Pass` | Shapes values with `Output` and `Assign`, and goes on. |
195
195
  | `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`. |
196
196
  | `Wait` | Waits `Seconds`, or until a `Timestamp` (ISO 8601), as a number or an expression. |
@@ -215,7 +215,9 @@ A workflow's record page in the admin app, `/workflows/<id>`, and its create for
215
215
  - **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`.
216
216
  - **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`.
217
217
 
218
- 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 one that signs in through a connection whose OAuth client the app does not set, 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>
218
+ 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 one that signs in through a connection whose OAuth client the app does not set, 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. The boxes, their accessible names and a run's step headings name each type by the label the list gives it, such as *Shout* for `acme:shout`.
219
+
220
+ A Task's form has a *Cache results* switch, unless its step type hides it. For a type whose plugin offers an on and off switch, it turns caching on or off. For any other type, the type decides when it reuses a result, and the switch forces it on for every call. A type whose plugin advises against forcing it on shows its plugin's warning first. Once forced on, its box carries an exclamation mark, also in its accessible name, so a screenshot shows at once that the warning was set aside.
219
221
 
220
222
  ## Connecting an account
221
223
 
@@ -223,7 +225,7 @@ A save also refuses a Task that leaves out an argument its step type needs, such
223
225
  Each connected account belongs to the person who connected it, and only that person can turn on a workflow that uses it.
224
226
  :::
225
227
 
226
- A trigger or step type that signs in to another service, such as a mailbox, uses an account that someone connected. Each person connects their own accounts on the **Connections** section of the account page's Profile tab. Connecting takes them to the provider and back. Under each account, the section links each workflow that uses it: those the person turned on, whose enabled version binds the account, while they may still change them. The app keeps the account's tokens on the server, encrypted, and refreshes them when they expire. **Disconnect** first makes the triggers on the account wait for a new sign-in, then asks the provider to revoke the token when the connection has a revocation endpoint, and then deletes the account, so no step or trigger can use the token again, even one that was fetching it at that moment. The answer's `revocation` says what became of the token: `revoked` when the provider took it, `queued` when it did not answer or refused, and `not_revoked` when the connection has no revocation endpoint or the token could not be revoked or queued, as when it no longer decrypts with the app's secret; the section shows it. A queued token is tried again later, waiting from a minute up to an hour between tries, ten tries in all. Until then it keeps the token encrypted with the app's secret, in the workflows schema's `connection_revocations`, and drops it once the provider takes it, after the last try, or once the app's secret no longer decrypts it. A provider that never takes it keeps the token valid on its side. When stopping the triggers or deleting the account fails, disconnecting answers that error and keeps the account, and disconnecting again finishes the job. It answers `503` (`CONNECTIONS_STARTING`) and keeps the account while the workflows have not started. Its refusals come in the language the request asks for. <gap>Connection refusals from the auth endpoints cannot follow the app's `locale` setting because passing it to the auth instance, which is made before the app's options, is not implemented.</gap> The auth endpoint `/unlink-account` refuses an account that Connections lists (`403`, `CONNECTION_UNLINK_REFUSED`): disconnect it there. Connecting again makes a new account with a new id: bind it in the workflow's `connections` and turn the workflow on again. Revoking at Google ends everything the account allowed the app, the sign-in and a linked mailbox included, and the dialog says so.
228
+ A trigger or step type that signs in to another service, such as a mailbox, uses an account that someone connected. Each person connects their own accounts on the **Connections** section of the account page's Profile tab. Connecting takes them to the provider and back. Under each account, the section links each workflow that uses it: those the person turned on, whose enabled version binds the account, while they may still change them. The app keeps the account's tokens on the server, encrypted, and refreshes them when they expire. **Disconnect** first makes the triggers on the account wait for a new sign-in, then asks the provider to revoke the token when the connection has a revocation endpoint, and then deletes the account, so no step or trigger can use the token again, even one that was fetching it at that moment. The answer's `revocation` says what became of the token: `revoked` when the provider took it, `queued` when it did not answer or refused, and `not_revoked` when the connection has no revocation endpoint or the token could not be revoked or queued, as when it no longer decrypts with the app's secret; the section shows it. A queued token is tried again later, waiting from a minute up to an hour between tries, ten tries in all. Until then it keeps the token encrypted with the app's secret, in the workflows schema's `connection_revocations`, and drops it once the provider takes it, after the last try, or once the app's secret no longer decrypts it. A provider that never takes it keeps the token valid on its side. When stopping the triggers or deleting the account fails, disconnecting answers that error and keeps the account, and disconnecting again finishes the job. It answers `503` (`CONNECTIONS_STARTING`) and keeps the account while the workflows have not started. Deleting a user, by an admin, by themselves or with `protobase users delete`, does the same for each of their connected accounts before the user goes; when it cannot, as while the workflows have not started, the user and their accounts stay, and deleting them again finishes the job. <gap>A user deletion refused while the workflows start cannot answer in the language the request asks for because passing the request to the user-deletion listeners is not implemented.</gap> Its refusals come in the language the request asks for. <gap>Connection refusals from the auth endpoints cannot follow the app's `locale` setting because passing it to the auth instance, which is made before the app's options, is not implemented.</gap> The auth endpoint `/unlink-account` refuses an account that Connections lists (`403`, `CONNECTION_UNLINK_REFUSED`): disconnect it there. Connecting again makes a new account with a new id: bind it in the workflow's `connections` and turn the workflow on again. Revoking at Google ends everything the account allowed the app, the sign-in and a linked mailbox included, and the dialog says so.
227
229
 
228
230
  A workflow names the account each of its connections uses in its definition's `connections`, by `<plugin>:<connection>` and the account's id, which the Connections section shows, as does `list_connections` [over MCP](mcp.md#workflows):
229
231