@protobase/cli 0.72.2 → 0.73.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.
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.72.2";
2
+ var version = "0.73.1";
3
3
  //#endregion
4
4
  export { version };
@@ -1,5 +1,6 @@
1
1
  import { androidSigning } from "./android-signing.js";
2
2
  import { runChild } from "./child-process.js";
3
+ import { buildIosAutomatic, iosApiKey } from "./ios-release.js";
3
4
  import { copyRelease, releaseFile } from "./release-output.js";
4
5
  import { prepareReleaseProject } from "./release-project.js";
5
6
  import { prepare, summary } from "./run.js";
@@ -11,25 +12,24 @@ const gradleArgs = [
11
12
  "--no-configuration-cache",
12
13
  "-Pkotlin.compiler.execution.strategy=in-process"
13
14
  ];
14
- const capBuildIos = ({ team, manual }) => [
15
+ const capBuildManual = (team, { certificate, profile }) => [
15
16
  "build",
16
17
  "ios",
17
18
  "--xcode-team-id",
18
19
  team,
19
- ...manual ? [
20
- "--xcode-signing-style",
21
- "manual",
22
- "--xcode-signing-certificate",
23
- manual.certificate,
24
- "--xcode-provisioning-profile",
25
- manual.profile
26
- ] : []
20
+ "--xcode-signing-style",
21
+ "manual",
22
+ "--xcode-signing-certificate",
23
+ certificate,
24
+ "--xcode-provisioning-profile",
25
+ profile
27
26
  ];
28
27
  /** `protobase mobile build`: generates the native project as a release, builds it signed and copies the binary out. */
29
28
  const mobileBuild = async (input, out, deps = {}) => {
30
29
  const { version, outDir, ios, ...mobile } = input;
31
30
  if (mobile.platform === "ios" && !ios) throw new Error("An iOS release is signed by your Apple Developer team: pass --team");
32
31
  const signing = mobile.platform === "android" ? androidSigning(process.env, mobile.projectDir) : void 0;
32
+ const apiKey = ios && !ios.manual ? iosApiKey(process.env, mobile.projectDir) : void 0;
33
33
  const prepared = await prepare({
34
34
  ...mobile,
35
35
  release: true
@@ -45,7 +45,16 @@ const mobileBuild = async (input, out, deps = {}) => {
45
45
  ...signing.env
46
46
  }
47
47
  });
48
- } else await prepared.cap(capBuildIos(ios));
48
+ } else {
49
+ const { team, manual } = ios;
50
+ if (manual) await prepared.cap(capBuildManual(team, manual));
51
+ else await buildIosAutomatic({
52
+ ws: prepared.ws,
53
+ env: prepared.env,
54
+ team,
55
+ key: apiKey
56
+ }, deps.run ?? runChild);
57
+ }
49
58
  const file = await copyRelease(releaseFile(prepared.ws, mobile.platform), outDir, `${prepared.appId}-${version.version}-${version.build}`);
50
59
  out(`release ${path.relative(mobile.projectDir, file)}\n`);
51
60
  };
@@ -8,7 +8,8 @@ const liveUpdatePlugin = "@capawesome/capacitor-live-update";
8
8
  const readyTimeout = 3e4;
9
9
  /**
10
10
  * The app's `capacitor.config.json`, every key it sets named here. It never sets `server.url`, the hostname or the
11
- * schemes (the API's CORS list names Capacitor's default origins), or WebView debugging (on in debug builds only).
11
+ * schemes (the API's CORS list names Capacitor's default origins). WebView debugging is off in an iOS release by name:
12
+ * Capacitor's prebuilt simulator framework is a Debug build, so its default lists a Release app in Safari's Develop menu.
12
13
  * `plugins` configures ProtobaseAuth, the keyboard plugin and, with the live updater, what its guard needs: the key
13
14
  * releases are signed with, where they come from, the binary's fingerprint, shell version and the order of its own
14
15
  * files, background updates off, a rolled-back bundle blocked and `readyTimeout`, within which the shell must call
@@ -19,7 +20,7 @@ const readyTimeout = 3e4;
19
20
  * guards it. The file ships outside the app's web part, so the fingerprint in the user agent and ProtobaseAuth's
20
21
  * origins are beyond page script and a live update.
21
22
  */
22
- const capacitorConfig = ({ appId, appName, fingerprint, shellVersion, plugins, liveUpdate, allowances, apiOrigin, platform }) => ({
23
+ const capacitorConfig = ({ appId, appName, fingerprint, shellVersion, plugins, liveUpdate, allowances, apiOrigin, platform, release }) => ({
23
24
  appId,
24
25
  appName,
25
26
  webDir: "web",
@@ -50,7 +51,8 @@ const capacitorConfig = ({ appId, appName, fingerprint, shellVersion, plugins, l
50
51
  android: {
51
52
  resolveServiceWorkerRequests: false,
52
53
  ...allowances.mixedContent && { allowMixedContent: true }
53
- }
54
+ },
55
+ ...release && platform === "ios" && { ios: { webContentsDebuggingEnabled: false } }
54
56
  });
55
57
  /** Writes the config into the workspace `dir`, removing the `.ts` and `.js` configs Capacitor would read instead. */
56
58
  const writeCapacitorConfig = async (dir, config) => {
@@ -0,0 +1,99 @@
1
+ import { statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { rm, writeFile } from "node:fs/promises";
4
+ //#region src/mobile/ios-release.ts
5
+ const apiKeyVariables = [
6
+ "PROTOBASE_IOS_API_KEY_PATH",
7
+ "PROTOBASE_IOS_API_KEY_ID",
8
+ "PROTOBASE_IOS_API_KEY_ISSUER"
9
+ ];
10
+ /**
11
+ * The API key in `env`, checked before anything is installed: none of its variables or all of them, the key a file,
12
+ * made absolute against the project folder since xcodebuild runs in the native project. Without it Xcode signs with
13
+ * the account in its settings.
14
+ */
15
+ const iosApiKey = (env, projectDir) => {
16
+ const missing = apiKeyVariables.filter((key) => !env[key]);
17
+ if (missing.length === apiKeyVariables.length) return;
18
+ if (missing.length > 0) throw new Error(`Signing with an App Store Connect API key takes all of ${apiKeyVariables.join(", ")}: set ${missing.join(", ")}`);
19
+ const file = path.resolve(projectDir, env.PROTOBASE_IOS_API_KEY_PATH);
20
+ if (!statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`PROTOBASE_IOS_API_KEY_PATH names ${file}, which is not a file`);
21
+ return {
22
+ path: file,
23
+ id: env.PROTOBASE_IOS_API_KEY_ID,
24
+ issuer: env.PROTOBASE_IOS_API_KEY_ISSUER
25
+ };
26
+ };
27
+ const archive = "App.xcarchive";
28
+ const exportOptions = "archive.plist";
29
+ const provisioningArgs = (key) => ["-allowProvisioningUpdates", ...key ? [
30
+ "-authenticationKeyPath",
31
+ key.path,
32
+ "-authenticationKeyID",
33
+ key.id,
34
+ "-authenticationKeyIssuerID",
35
+ key.issuer
36
+ ] : []];
37
+ /** xcodebuild's arguments to archive the release for `team`. */
38
+ const iosArchiveArgs = (team, key) => [
39
+ "-project",
40
+ "App.xcodeproj",
41
+ "-scheme",
42
+ "App",
43
+ "-configuration",
44
+ "Release",
45
+ "-destination",
46
+ "generic/platform=iOS",
47
+ "-archivePath",
48
+ archive,
49
+ "archive",
50
+ `DEVELOPMENT_TEAM=${team}`,
51
+ ...provisioningArgs(key)
52
+ ];
53
+ /** xcodebuild's arguments to export the archive as an .ipa into `output`, where `releaseFile` finds it. */
54
+ const iosExportArgs = (key) => [
55
+ "-exportArchive",
56
+ "-archivePath",
57
+ archive,
58
+ "-exportOptionsPlist",
59
+ exportOptions,
60
+ "-exportPath",
61
+ "output",
62
+ ...provisioningArgs(key)
63
+ ];
64
+ /** The export options of an App Store build `team` signs automatically; `team` is an id `--team` checked. */
65
+ const iosExportOptions = (team) => `<?xml version="1.0" encoding="UTF-8"?>
66
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
67
+ <plist version="1.0">
68
+ <dict>
69
+ <key>method</key>
70
+ <string>app-store-connect</string>
71
+ <key>signingStyle</key>
72
+ <string>automatic</string>
73
+ <key>teamID</key>
74
+ <string>${team}</string>
75
+ </dict>
76
+ </plist>
77
+ `;
78
+ /**
79
+ * Archives and exports the iOS project in `ws` with automatic signing. xcodebuild runs here rather than through
80
+ * `cap build ios`, which lets Xcode create nothing while archiving and cannot pass an API key to either step.
81
+ */
82
+ const buildIosAutomatic = async ({ ws, env, team, key }, run) => {
83
+ const cwd = path.join(ws, "ios", "App");
84
+ await run("xcodebuild", iosArchiveArgs(team, key), {
85
+ cwd,
86
+ env
87
+ });
88
+ await writeFile(path.join(cwd, exportOptions), iosExportOptions(team));
89
+ await run("xcodebuild", iosExportArgs(key), {
90
+ cwd,
91
+ env
92
+ });
93
+ await rm(path.join(cwd, archive), {
94
+ recursive: true,
95
+ force: true
96
+ });
97
+ };
98
+ //#endregion
99
+ export { buildIosAutomatic, iosApiKey, iosArchiveArgs, iosExportArgs, iosExportOptions };
@@ -71,7 +71,8 @@ const prepare = async ({ projectDir, platform, api, extensions, live, release },
71
71
  plugins,
72
72
  allowances,
73
73
  apiOrigin: url.origin,
74
- platform
74
+ platform,
75
+ ...release && { release }
75
76
  };
76
77
  await writeCapacitorConfig(ws, capacitorConfig({
77
78
  ...config,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.72.2",
3
+ "version": "0.73.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,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.72.2",
47
- "@protobase/layout": "0.72.2",
48
- "@protobase/query": "0.72.2",
49
- "@protobase/schema": "0.72.2",
50
- "@protobase/server": "0.72.2",
51
- "@protobase/ui": "0.72.2",
52
- "@protobase/workflows": "0.72.2",
46
+ "@protobase/config": "0.73.1",
47
+ "@protobase/layout": "0.73.1",
48
+ "@protobase/query": "0.73.1",
49
+ "@protobase/schema": "0.73.1",
50
+ "@protobase/server": "0.73.1",
51
+ "@protobase/ui": "0.73.1",
52
+ "@protobase/workflows": "0.73.1",
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",
@@ -16,7 +16,7 @@ Graphs of steps the server runs along their edges, written in a language a model
16
16
  - [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.
17
17
  - [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 shown on its workflow's page marks every step's status on the graph of the version it ran on, live while it runs, with each step's input, output, logs, timing, tries and error, secrets blanked out.
18
18
  - [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.
19
- - [Replay a run](../reference/workflows.md#replaying-a-run) (beta): A run that ended runs again from the start on its version and input without sending its outside writes, such as mail, record writes and requests other than GET, which give what they gave then, while its reads come from the step cache or run again, so you can work on what formed a message without sending it twice.
19
+ - [Replay a run](../reference/workflows.md#replaying-a-run) (beta): replay a finished run on your workflow as it is now, with the same input. Steps that write are held back and reads come from the step cache or run again, so you can try a change on the same data without sending anything again.
20
20
  - [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.
21
21
  - [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.
22
22
  - [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.
@@ -118,7 +118,9 @@ Use it as the upload key with Play App Signing. Set these variables in your shel
118
118
 
119
119
  Only Gradle gets them, for the length of the build: npm, its install scripts and Capacitor never do. The build needs the Android SDK and JDK 21.
120
120
 
121
- **iOS**, on a Mac with Xcode: `--team` is your Apple Developer team id, shown in your account's membership details. Xcode signs with the certificates and profiles of the account you added in its settings. For manual signing, pass `--certificate <name>` and `--profile <name>` too. <gap>Manual signing cannot use a certificate or profile name that holds `<`, `>` or `&` because writing the escaped export options is not implemented.</gap> If automatic signing finds no profile for a new app, run `protobase mobile open ios`, pick the team in Signing & Capabilities and archive once from Xcode (Product, Archive), which creates the profile; then build again.
121
+ **iOS**, on a Mac with Xcode: `--team` is your Apple Developer team id, shown in your account's membership details. Xcode signs with the certificates and profiles of the account you added in its settings. For manual signing, pass `--certificate <name>` and `--profile <name>` too. <gap>Manual signing cannot use a certificate or profile name that holds `<`, `>` or `&` because writing the escaped export options is not implemented.</gap> Xcode creates the certificates and profiles a new app lacks.
122
+
123
+ To sign without an account in Xcode, as on a build machine, make an App Store Connect API key with the Admin role and set `PROTOBASE_IOS_API_KEY_PATH` to its `.p8` file, absolute or relative to the project folder, `PROTOBASE_IOS_API_KEY_ID` to its key id and `PROTOBASE_IOS_API_KEY_ISSUER` to its issuer id. Set all three or none; manual signing ignores them. The bundle id must exist on the team first.
122
124
 
123
125
  ## 7. Live updates
124
126
 
@@ -143,7 +143,7 @@ The queue is an `AuditQueue`, `{ publish(event): Promise<void> }`, the default e
143
143
 
144
144
  ## Filters
145
145
 
146
- `filter` and `order_by` are AIP-160 and AIP-132 text, checked against the model (filterable and sortable fields only). Functions: `in(field, v, ...)`, `search("text")` and bare words (every word, ignoring case, anywhere inside one of the resource's search fields, so `port` finds "Portfolio"; `%`, `_` and `\` are literal; a [`digitsEnd`](data-config.md#resource) field matches only the end of its digits), `similar(field, "text")`, `regex(field, "pattern")`, `isNull(field)`, `now()` with offsets such as `now() - 7d`. A filter is capped at 10,000 characters in a query string and 200,000 in a `:search` body.
146
+ `filter` and `order_by` are AIP-160 and AIP-132 text, checked against the model (filterable and sortable fields only). Functions: `in(field, v, ...)`, `search("text")` and bare words (every word, ignoring case, anywhere inside one of the resource's search fields, so `port` finds "Portfolio"; `%`, `_` and `\` are literal; a [`digitsEnd`](data-config.md#resource) field matches only the end of its digits), `similar(field, "text")`, `regex(field, "pattern")`, `isNull(field)`, `now()` with offsets such as `now() - 7d`. A filter is capped at 10,000 characters in a query string and 200,000 in a `:search` body. A proxy in front of the API can refuse a long URL sooner with a `431`, counting the headers too: Protobase Cloud refused about 8,000 characters. So send a long filter to `:search`, as `client.list` does when its query string passes 4,000 characters.
147
147
 
148
148
  ## Writes
149
149
 
@@ -650,7 +650,7 @@ Builds the project in the current directory as an iOS or Android app with Capaci
650
650
  - It installs `@protobase/mobile`, of the CLI's version, which pins Capacitor and its plugins, with the section's `plugins` into `.protobase/mobile/` with npm, when that changed, and on every run for a package from a local folder, given as `file:` or a path such as `../my-plugin`, which npm copies. When such a folder's dependencies are not the versions installed, it removes the workspace's `node_modules` and `package-lock.json` so npm resolves them again, and stops when a pin is still not met, as when two packages pin one dependency at different versions. It builds the app's files as [`build`](#build) does and keeps only `public/`, its `index.html` naming the API origin; writes `capacitor.config.json`, compiling in only the section's plugins; and generates the platform's native project again, replacing the last one.
651
651
  - `--api` is the server the app calls, the app's `url` by default: an http or https origin, plain http only to `localhost`, `127.0.0.1` and, on iOS, `[::1]`, or on Android `10.0.2.2`. An Android app calling `localhost` gets the port forwarded.
652
652
  - `run` runs the app on `--target`, or asks for one. On iOS, `--team` writes the Apple team id into the generated project and builds it once with xcodebuild allowed to create the development profile and register the device, `--target` or the one connected device, before Capacitor runs it there. With `--live` it opens `protobase dev` on `localhost:--port` instead of its own files. `open` opens the native project in Xcode or Android Studio.
653
- - `build` makes a signed release, an `.aab` (Android) or `.ipa` (iOS), and copies it to `--out`, which ignores itself in git when the command creates it, as `<appId>-<version>-<build number>`. Android signs through Gradle with the keystore, alias and passwords in `PROTOBASE_ANDROID_KEYSTORE`, `PROTOBASE_ANDROID_KEY_ALIAS`, `PROTOBASE_ANDROID_KEYSTORE_PASSWORD` and `PROTOBASE_ANDROID_KEY_PASSWORD`; iOS runs Capacitor's build with `--team`, and `--certificate` with `--profile` for manual signing.
653
+ - `build` makes a signed release, an `.aab` (Android) or `.ipa` (iOS), and copies it to `--out`, which ignores itself in git when the command creates it, as `<appId>-<version>-<build number>`. Android signs through Gradle with the keystore, alias and passwords in `PROTOBASE_ANDROID_KEYSTORE`, `PROTOBASE_ANDROID_KEY_ALIAS`, `PROTOBASE_ANDROID_KEYSTORE_PASSWORD` and `PROTOBASE_ANDROID_KEY_PASSWORD`; iOS archives and exports with xcodebuild for `--team`, letting Xcode create certificates and profiles with the account in its settings, or with the App Store Connect API key in `PROTOBASE_IOS_API_KEY_PATH`, `PROTOBASE_IOS_API_KEY_ID` and `PROTOBASE_IOS_API_KEY_ISSUER`; `--certificate` with `--profile` signs manually through Capacitor's build.
654
654
  - A release calls an https server that is not this machine, and `--app-version` (one to three numbers) and `--build-number` (1 to 2100000000) are required.
655
655
  - npm and Capacitor get the CLI's environment without any variable named in the current directory's `.env*` files, nor the four `PROTOBASE_ANDROID_*` signing variables, which only Gradle gets, and with `CI=true`, which keeps Capacitor's telemetry off. The project's config, its extensions' configs and the web build run inside the CLI with no `PROTOBASE_ANDROID_*` variable left in its environment, even one the project's `.env` sets.
656
656
  - It prints the workspace, the API origin and the native fingerprint, which the app also sends in its user agent as `ProtobaseNative/<fingerprint>`: a hash of the native packages `@protobase/mobile` pins, at their versions, its own native contract, and the section's `plugins`, at the versions they give. A [mobile release](#mobile-releases) runs only on a binary of its own fingerprint. A Protobase release that pins the same native packages and keeps the same native contract keeps it; raise a plugin's version when the app's files start using a newer native feature of it, which changes it.
@@ -17,7 +17,7 @@ const { record, etag } = await api.get(orders, id)
17
17
  await api.update(orders, id, { status: 'shipped' }, etag) // 412 rejects with PreconditionFailedError
18
18
  ```
19
19
 
20
- Pass a resource builder to get its record type ([`InferRecord`](https://docs.protobase.net/api/schema/type-aliases/inferrecord/)), or a name for loose records. Filters are AIP-160 text or [`where`](https://docs.protobase.net/api/schema/variables/where/) results (printed with [`printFilter`](https://docs.protobase.net/api/schema/functions/printfilter/)). Errors are `ApiError` (`status`, `slug`, `errors`, `filterErrors` with spans, `blockedBy` for a delete other records point at), which `isApiError(error)` tells apart. Also: `search`, `create`, `remove`, `facets`, `series`, `histogram`, `map(resource, { latitude, longitude, zoom, filter?, orderBy?, fields? })` (the records in a map's view, or buckets of them, see [`:map`](api.md#maps)), `seek`, `reveal(resource, key, field)` (a [sensitive field](data-config.md#sensitive-fields)'s value), `generate(resource, key, action)` (runs an [AI action](ai.md#ai-actions) and resolves to the record as written), `meta(etag?, { signal? })`, `invoke(name, { body?, method?, path?, query? })` (one of the app's [API functions](../guides/api-functions.md#calling-a-function); its answer as parsed JSON, text, or a `Blob` for any other type), `history(resource, key, { pageSize?, pageToken? })` (a page of a record's [changes](history.md), newest first), `comments(resource, key, { pageSize?, pageToken? })`, `comment(resource, key, { body, mentions? })` and `mentionable(resource, key, query)` (a record's [comments](comments.md#the-endpoints), adding one, and the people it may mention), `form(name)`, `formChallenge(name)` and `submitForm(name, values, proof)` (a [public form](forms.md#routes)), `upload(resource, field, file, { onProgress?, signal? })` (a file for a [file field](files.md#uploading); its `value` goes in the next write, and `onProgress` uses XMLHttpRequest, since fetch reports no upload progress), `live({ onChanges, onResync, onRetry? })` (hears which records of the [live](live.md) resources change until the function it returns is called, connecting again by itself; every caller of one client shares one stream), `channel(resource, key)` (a record's [channel](channels.md#from-a-page), with its presence and broadcasts, every channel of the client on one socket).
20
+ Pass a resource builder to get its record type ([`InferRecord`](https://docs.protobase.net/api/schema/type-aliases/inferrecord/)), or a name for loose records. Filters are AIP-160 text or [`where`](https://docs.protobase.net/api/schema/variables/where/) results (printed with [`printFilter`](https://docs.protobase.net/api/schema/functions/printfilter/)), and a list whose query string passes 4,000 characters goes by `:search`. Errors are `ApiError` (`status`, `slug`, `errors`, `filterErrors` with spans, `blockedBy` for a delete other records point at), which `isApiError(error)` tells apart. Also: `search`, `create`, `remove`, `facets`, `series`, `histogram`, `map(resource, { latitude, longitude, zoom, filter?, orderBy?, fields? })` (the records in a map's view, or buckets of them, see [`:map`](api.md#maps)), `seek`, `reveal(resource, key, field)` (a [sensitive field](data-config.md#sensitive-fields)'s value), `generate(resource, key, action)` (runs an [AI action](ai.md#ai-actions) and resolves to the record as written), `meta(etag?, { signal? })`, `invoke(name, { body?, method?, path?, query? })` (one of the app's [API functions](../guides/api-functions.md#calling-a-function); its answer as parsed JSON, text, or a `Blob` for any other type), `history(resource, key, { pageSize?, pageToken? })` (a page of a record's [changes](history.md), newest first), `comments(resource, key, { pageSize?, pageToken? })`, `comment(resource, key, { body, mentions? })` and `mentionable(resource, key, query)` (a record's [comments](comments.md#the-endpoints), adding one, and the people it may mention), `form(name)`, `formChallenge(name)` and `submitForm(name, values, proof)` (a [public form](forms.md#routes)), `upload(resource, field, file, { onProgress?, signal? })` (a file for a [file field](files.md#uploading); its `value` goes in the next write, and `onProgress` uses XMLHttpRequest, since fetch reports no upload progress), `live({ onChanges, onResync, onRetry? })` (hears which records of the [live](live.md) resources change until the function it returns is called, connecting again by itself; every caller of one client shares one stream), `channel(resource, key)` (a record's [channel](channels.md#from-a-page), with its presence and broadcasts, every channel of the client on one socket).
21
21
 
22
22
  With an absolute `baseUrl`, such as another app's `https://desk.example.com/api/v1`, the `url` of each [file](files.md) in a record the client answers is absolute too, on that API's origin, where the API answers a path on its own origin. `routedClient(own, sourceOf)` is one client over several APIs: each call about a resource goes to the client `sourceOf(resource)` gives, else to `own`, as the admin app does for [remote resources](remote-resources.md), and `sourceMeta(meta, source)` keeps the part of another API's `/meta` an app draws.
23
23
 
@@ -100,7 +100,7 @@ In an app with [workflows](workflows.md), the endpoint also offers tools to find
100
100
  | `list_connections` | The person's own [connected accounts](workflows.md#connecting-an-account), by each connection the app's plugins declare: its key and label, and each account's `id`, scopes and `needs_sign_in`. A definition's `connections` binds an account by its `id`. It returns no token, and no one else's accounts. |
101
101
  | `create_workflow` | Creates a workflow from its `name`, `description` and `definition`, and returns its `id`, `version` and ETag. |
102
102
  | `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. |
103
- | `run_workflow` | Starts a run of a workflow's current version with an `input`, and returns its `run_id` and `status`. With `rerun_of`, a failed run's id, it [re-runs](workflows.md#re-running-a-failed-run) that run from its failed step instead, on its input; with `replay: true` too, it [replays](workflows.md#replaying-a-run) a run that ended, sending none of its outside writes. |
103
+ | `run_workflow` | Starts a run of a workflow's current version with an `input`, and returns its `run_id` and `status`. With `rerun_of`, a failed run's id, it [re-runs](workflows.md#re-running-a-failed-run) that run from its failed step instead, on its input; with `replay: true` too, it [replays](workflows.md#replaying-a-run) any ended run of the workflow on its current version, with that run's input, sending none of its outside writes. |
104
104
  | `enable_workflow` | Turns a workflow's trigger on (`enabled: true`) on the `version` it names, which must be the workflow's current version, or off, and returns its `enabled_version`. A workflow saved since that version is refused, so an approval never turns on a version the person did not see. One that uses a [connected account](workflows.md#connecting-an-account) is turned on only by the account's owner in the admin app: the tool answers `own-sign-in-only` and says so. |
105
105
  | `new_webhook_url` | Issues a new URL and secret for a workflow that starts from a [webhook trigger](workflows.md#appwebhook), keeping a `secret` the sender makes when given one, and returns `{ url, secret }`, shown this once; the old URL stops. |
106
106
 
@@ -105,6 +105,7 @@ lookup: {
105
105
  label: 'Company lookup',
106
106
  description: 'Finds a company by its registration number.',
107
107
  input: { type: 'object', required: ['number'], properties: { number: { type: 'string' } } },
108
+ writes: false,
108
109
  cache: { choice: 'force', ttl: '1 day', when: (input) => input.number !== '' },
109
110
  run: async ({ input, fetch }) => (await fetch(`https://registry.example/companies/${input.number}`)).json(),
110
111
  }
@@ -24,7 +24,7 @@ It adds five resources, over a `workflows` schema that [`protobase migration`](c
24
24
  | --- | --- |
25
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
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`, or with `rerun_of` to [re-run a failed run](#re-running-a-failed-run), and `replay: true` too to [replay a run](#replaying-a-run); it runs the version its workflow had then, a replay the replayed run's. Its status, output, error and timing are written by the runner. |
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), and `replay: true` too to [replay a run](#replaying-a-run); it runs the version its workflow had then, and a re-run or a replay the input of the run it names. Its status, output, error and timing are written by the runner. |
28
28
  | `workflow_steps` | Every step of every run. Read-only. |
29
29
  | `workflow_triggers` | The trigger of each enabled workflow that has one besides `manual`: its type, status and last error. Read-only. |
30
30
 
@@ -339,11 +339,15 @@ A kept step's card says it is from the earlier run, as it finished before the re
339
339
 
340
340
  ### Replaying a run
341
341
 
342
- A run that ended, succeeded or failed, offers *Replay* where it is shown. It opens the form for a new run with `workflow_id`, `rerun_of` and `replay` filled in; creating it, through the form or `POST /api/v1/workflow_runs` with `{ "workflow_id": "...", "rerun_of": "...", "replay": true }`, starts a run that runs from the start on the replayed run's version and input, so you can look into what formed a message, such as a mail sent with the wrong contents, without sending it again:
342
+ :::caution[Beta]
343
+ A replay holds back only the steps whose type declares that it writes, and a held-back step whose state shaped its result with `Output` in the replayed run gets no result.
344
+ :::
345
+
346
+ A run that ended, succeeded or failed, offers *Replay* where it is shown. It opens the form for a new run with `workflow_id`, `rerun_of` and `replay` filled in; creating it, through the form or `POST /api/v1/workflow_runs` with `{ "workflow_id": "...", "rerun_of": "...", "replay": true }`, starts a run from the start on the workflow as it is now and on the replayed run's input, acting as the same people as any run started now. A developer whose workflow sent a mail with the wrong contents fixes the steps that formed it, saves, and replays the run, again until the mail is right, without sending it:
343
347
 
344
- - a step whose type writes, such as `app:mail.send`, `app:records.create`, `app:records.update`, an `app:http` request with any method but `GET`, and a plugin's step that does not say it only reads, is not called: it gives what the replayed run's step of the same place gave, its output and variables, or a `null` output when that run has none there, and its logs say *Not sent: replay*;
348
+ - a step whose type writes, such as `app:mail.send`, `app:records.create`, `app:records.update`, an `app:http` request with any method but `GET`, and a plugin's step that does not say it only reads, is not called, and its logs say *Not sent: replay*. It takes the result of the replayed run's step of the same state, in the same Map item, and the same visit of that state, so states added or removed elsewhere do not change what it gets; its `Output`, `Assign` and `Next` then apply as the workflow has them now. A step whose visit failed fails with the same error and cause, which only a `Catch` takes, since every retry would give that error again. One the replayed run has no such step for, or whose state there was no Task or shaped its result with `Output`, gives a `null` output and goes on to its next state. <gap>A replay cannot give a held-back step the result of a state that shaped it with `Output` because steps do not record a Task's result before its `Output`.</gap>;
345
349
  - a step that only reads, such as `app:records.get`, `app:records.list`, an `app:http` `GET`, or a plugin's step that says so, is served from the [step cache](workflow-plugins.md#caching-results) when its type caches and an earlier result matches, saying so in its logs, with a line for each secret that changed since that result was kept; otherwise it runs again, as the same people a run of that version acts as;
346
- - Pass, Choice, Map, Succeed and Fail states compute as always, and a Wait goes on at once.
350
+ - Pass, Choice, Map, Succeed and Fail states compute as always, so a fixed composition shows in the held-back step's input, and a Wait goes on at once.
347
351
 
348
352
  A replay given an `input` of its own is refused with `rerun-input`, and one without `rerun_of` with `replay-without-run`. It is refused, and nothing starts, when the replayed run is not one of the workflow's runs in the person's tenant (409, as for a workflow that is not there), has not ended (`run-not-ended`), or when the person may not start runs of the workflow, read the run, or read all of its steps (404). A step holds back only as its type declares: a plugin's step that writes while it declares it only reads runs in a replay too, so a replay is no guard against a plugin that declares it wrongly.
349
353