@pithy-sh/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The request and response contract of the host's dispatch route ({@link ./dispatchRoute.ts}).
|
|
8
|
+
*
|
|
9
|
+
* Declared here rather than on the route line for the reason every capability's `http/schemas.ts`
|
|
10
|
+
* exists (CLAUDE.md §HTTP): the route declares *that* it validates, this declares *what*, and the
|
|
11
|
+
* loopback dispatcher on the other end of the wire builds its body against the same object.
|
|
12
|
+
*
|
|
13
|
+
* **The path is here too, and that is deliberate.** An address is as much of the wire contract as a
|
|
14
|
+
* body is, and putting it beside the route would make the dispatcher import the route module to know
|
|
15
|
+
* where to POST — which is the module that imports the dispatcher to resolve a binding. One import
|
|
16
|
+
* cycle, for a template string neither end owns more than the other.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The reserved namespace the host's dispatch route lives under, beside `/__pithy/dev-login`. */
|
|
20
|
+
export const WORKFLOW_DISPATCH_BASE = "/__pithy/workflows";
|
|
21
|
+
|
|
22
|
+
/** The registered path pattern. `:binding` is the host's own Workflow binding name. */
|
|
23
|
+
export const WORKFLOW_DISPATCH_ROUTE = `${WORKFLOW_DISPATCH_BASE}/:binding`;
|
|
24
|
+
|
|
25
|
+
/** The concrete path for one binding — what the loopback dispatcher POSTs to. */
|
|
26
|
+
export function workflowDispatchPath(binding: string): string {
|
|
27
|
+
return `${WORKFLOW_DISPATCH_BASE}/${encodeURIComponent(binding)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The path segment naming which of the host's own Workflow bindings to start an instance on. */
|
|
31
|
+
export const WorkflowDispatchParams = z
|
|
32
|
+
.object({
|
|
33
|
+
binding: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.max(64)
|
|
37
|
+
.regex(
|
|
38
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/,
|
|
39
|
+
"A binding name is an identifier: letters, digits and underscores, not starting with a digit.",
|
|
40
|
+
)
|
|
41
|
+
.describe(
|
|
42
|
+
"The Workflow binding on this host's own env — `EMAIL_SENDER`, not a `<capability>/<job>` key. The loopback dispatcher stands in for exactly one binding, and a binding is what it knows itself by.",
|
|
43
|
+
),
|
|
44
|
+
})
|
|
45
|
+
.describe("The path parameters of the host dispatch route.");
|
|
46
|
+
export type WorkflowDispatchParams = z.infer<typeof WorkflowDispatchParams>;
|
|
47
|
+
|
|
48
|
+
/** The body: the instance id and the payload, exactly as `create({ id, params })` takes them. */
|
|
49
|
+
export const WorkflowDispatchRequest = z
|
|
50
|
+
.object({
|
|
51
|
+
id: z
|
|
52
|
+
.string()
|
|
53
|
+
.min(1)
|
|
54
|
+
.max(128)
|
|
55
|
+
.optional()
|
|
56
|
+
.describe(
|
|
57
|
+
"The instance id to start under. Optional because the platform's own `create` allows omitting it — and relayed rather than minted here, because an id a dispatcher did not choose is an instance the caller's row cannot name (pithy-sh/pithy#342). Every Pithy dispatcher passes one.",
|
|
58
|
+
),
|
|
59
|
+
params: z
|
|
60
|
+
.unknown()
|
|
61
|
+
.describe(
|
|
62
|
+
"The instance payload. Unconstrained on the wire and validated on arrival against the declaring spec's own schema, so a malformed payload fails with the field named rather than inside a running instance.",
|
|
63
|
+
),
|
|
64
|
+
})
|
|
65
|
+
.describe("A request to start one instance of a Workflow this host hosts.");
|
|
66
|
+
export type WorkflowDispatchRequest = z.infer<typeof WorkflowDispatchRequest>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The answer: which binding was started, under which id.
|
|
70
|
+
*
|
|
71
|
+
* A response object rather than an interface, for the reason every admin response is one (CLAUDE.md
|
|
72
|
+
* §HTTP): the caller is across a process boundary, and a TypeScript interface is erased before it can
|
|
73
|
+
* help. Nothing here carries a codec — an answer must parse back to exactly what went in.
|
|
74
|
+
*/
|
|
75
|
+
export const WorkflowDispatchResponse = z
|
|
76
|
+
.object({
|
|
77
|
+
binding: z.string().describe("The binding an instance was started on — the one the request named."),
|
|
78
|
+
id: z.string().optional().describe("The instance id, when the request named one."),
|
|
79
|
+
started: z
|
|
80
|
+
.literal(true)
|
|
81
|
+
.describe("Always true. The route answers 202 once Cloudflare has accepted the instance, never on completion."),
|
|
82
|
+
})
|
|
83
|
+
.describe("What the host answers when it has accepted a dispatch.");
|
|
84
|
+
export type WorkflowDispatchResponse = z.infer<typeof WorkflowDispatchResponse>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The durable-job seam. A capability declares the Cloudflare Workflows it owns as a map of
|
|
8
|
+
* job name → {@link WorkflowSpec}; `createBackend` collects every capability's map into one
|
|
9
|
+
* registry, derives each spec's binding so a missing one fails at boot, and serves a typed
|
|
10
|
+
* dispatcher on `c.var.workflows`.
|
|
11
|
+
*
|
|
12
|
+
* The spec is the single description of a job. The CLI reads it to write the host worker's
|
|
13
|
+
* `workflows` array and its cron triggers; the dispatcher reads it to validate params and find
|
|
14
|
+
* the binding; `naming.ts` reads it to resolve the per-environment script name. One declaration,
|
|
15
|
+
* no drift between the wrangler config, the provisioner, and the call site.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The minimal structural shape of a Cloudflare Workflow binding: enough to start an instance,
|
|
20
|
+
* and nothing more. Typed structurally rather than against `Workflow<P>` from
|
|
21
|
+
* `@cloudflare/workers-types` so core gains no runtime dependency and a test injects a plain
|
|
22
|
+
* object literal — the same posture `@pithy-sh/media` already takes for its AI binding.
|
|
23
|
+
*/
|
|
24
|
+
export interface WorkflowBinding {
|
|
25
|
+
/** Start a new instance. `params` is the validated payload the Workflow receives as `event.payload`. */
|
|
26
|
+
create(options?: { id?: string; params?: unknown }): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One durable job a capability registers. Keyed in {@link WorkflowSpecMap} by its job name, which
|
|
31
|
+
* is also the second segment of the `<capability>/<job>` dispatch key.
|
|
32
|
+
*/
|
|
33
|
+
export interface WorkflowSpec<Params extends z.ZodType = z.ZodType> {
|
|
34
|
+
/**
|
|
35
|
+
* The binding name the Workflow is bound to in the Worker env (e.g. `MEDIA_IMAGE_TO_TEXT`).
|
|
36
|
+
* `createBackend` derives a `workflow` {@link BindingSpec} from it, so a job whose binding is
|
|
37
|
+
* absent surfaces as a startup failure rather than a silent no-op at dispatch time.
|
|
38
|
+
*/
|
|
39
|
+
binding: string;
|
|
40
|
+
/**
|
|
41
|
+
* The instance parameters, validated **at dispatch** — before the binding is touched. A malformed
|
|
42
|
+
* trigger therefore fails at the call site with `core/invalid_workflow_params`, rather than inside
|
|
43
|
+
* a running instance where the only signal is a failed step and a retry budget burning down.
|
|
44
|
+
*/
|
|
45
|
+
params: Params;
|
|
46
|
+
/**
|
|
47
|
+
* A cron expression. Present means the CLI writes a `triggers.crons` entry into the host worker's
|
|
48
|
+
* resolved `wrangler.jsonc` and `createEntrypoint` routes the `scheduled` event here. Absent means
|
|
49
|
+
* dispatch-only. A scheduled job stays manually triggerable either way — a backfill nobody can run
|
|
50
|
+
* on demand is untestable.
|
|
51
|
+
*/
|
|
52
|
+
schedule?: string;
|
|
53
|
+
/**
|
|
54
|
+
* The exported `WorkflowEntrypoint` subclass in the host worker that runs this job — the
|
|
55
|
+
* `class_name` the CLI writes into the host's `workflows` array. Omit only for a job whose host
|
|
56
|
+
* config is hand-maintained.
|
|
57
|
+
*/
|
|
58
|
+
className?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Treat the derived binding as optional, so a project that has not provisioned the host worker
|
|
61
|
+
* still boots. Defaults to false. `@pithy-sh/media` sets it: its enrichment Workflows appear only
|
|
62
|
+
* once `pithy media provision` has run, and the app must serve every other route until then.
|
|
63
|
+
*/
|
|
64
|
+
optional?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A capability's durable jobs: job name → {@link WorkflowSpec}. The peer of `databases` and `kvNamespaces`. */
|
|
68
|
+
export type WorkflowSpecMap = Record<string, WorkflowSpec>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A spec resolved against the capability that declared it — what the registry holds and the
|
|
72
|
+
* dispatcher looks up. `key` is the `<capability>/<job>` dispatch key.
|
|
73
|
+
*/
|
|
74
|
+
export interface RegisteredWorkflow {
|
|
75
|
+
/** The dispatch key, `<capability>/<job>`. */
|
|
76
|
+
key: string;
|
|
77
|
+
/** The capability that declared the job — the first key segment and the script-name segment. */
|
|
78
|
+
capability: string;
|
|
79
|
+
/** The job name — the second key segment. */
|
|
80
|
+
job: string;
|
|
81
|
+
/** The spec as declared. */
|
|
82
|
+
spec: WorkflowSpec;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The project-wide registry: dispatch key → {@link RegisteredWorkflow}. */
|
|
86
|
+
export type WorkflowRegistry = Record<string, RegisteredWorkflow>;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* **The one statement of what a terminal Workflow step writes into its error text, and the one reader of it.**
|
|
6
|
+
*
|
|
7
|
+
* A step's error text is the whole channel. The engine records the throw's message and nothing else —
|
|
8
|
+
* no fields, no cause, no payload — so every operator-facing thing a `PithyError` carries across a
|
|
9
|
+
* durable boundary has to ride inside that one string. `classifiedSteps` used to write
|
|
10
|
+
* `${code}: ${message}`, which meant `action` — the remedy line every other `PithyError` reaching the
|
|
11
|
+
* CLI prints under the problem line — died at the step (pithy-sh/pithy#353).
|
|
12
|
+
*
|
|
13
|
+
* Both ends of that channel live here. {@link encodeWorkflowStepMessage} is called by
|
|
14
|
+
* `classifiedSteps`; {@link decodeWorkflowStepMessage} is called by `@pithy-sh/cloudflare`'s
|
|
15
|
+
* `kitSentence`. **Neither end restates the format.** Two packages that agree about a string by each
|
|
16
|
+
* writing it down is not agreement, it is a coincidence with a maintenance schedule — so the format is
|
|
17
|
+
* the thing being called, not a rule at two call sites, and `stepFailure.test.ts` pins the wire it
|
|
18
|
+
* produces to a hand-written literal so a change to either end has to be a change to both.
|
|
19
|
+
*
|
|
20
|
+
* ## The encoding, and why it is a separator rather than JSON
|
|
21
|
+
*
|
|
22
|
+
* `${code}: ${message}` — unchanged, byte for byte, from what #349 captured — then, when there is one,
|
|
23
|
+
* a separator and the action.
|
|
24
|
+
*
|
|
25
|
+
* A JSON payload was the other candidate, and it is the more obvious answer: unambiguous, extensible,
|
|
26
|
+
* no grammar to get wrong. It loses on the surface nobody controls. **A step's raw text is read by a
|
|
27
|
+
* human, in the Cloudflare dashboard, at three in the morning**, and that reader is the whole reason
|
|
28
|
+
* the message was ever put in the throw. `{"code":"secrets/already_exists","message":"Secret
|
|
29
|
+
* 'api-token' already exists.","action":"Use \`update\` to change an existing secret."}` is strictly
|
|
30
|
+
* worse to read than the two lines it would replace, and it would make every instance already recorded
|
|
31
|
+
* unreadable to the new reader and every instance recorded by the new writer unreadable to the old one.
|
|
32
|
+
* A separator costs a grammar and keeps the sentence.
|
|
33
|
+
*
|
|
34
|
+
* ## Why the separator is a newline
|
|
35
|
+
*
|
|
36
|
+
* A printable delimiter — ` | `, ` :: ` — is ambiguous, and nothing at the reader can resolve it: a
|
|
37
|
+
* message that happens to contain the delimiter splits in the wrong place and the operator reads half
|
|
38
|
+
* a sentence with the other half presented as the remedy. A newline is not ambiguous, because a
|
|
39
|
+
* promoted message may not contain one. That rule is older than this encoding — #349 declined a
|
|
40
|
+
* newline-carrying sentence precisely because it *would forge the CLI's action line* — and it is what
|
|
41
|
+
* makes the split total rather than best-effort. The second line stops being a forgery and becomes the
|
|
42
|
+
* field it always looked like.
|
|
43
|
+
*
|
|
44
|
+
* It also renders. The CLI prints a problem line then an action line, and the dashboard now shows the
|
|
45
|
+
* same two lines the operator's terminal does.
|
|
46
|
+
*
|
|
47
|
+
* **The forgery concern does not disappear; it is bounded, and it was always this size.** A step's text
|
|
48
|
+
* arrives from a Worker whose code we did not write, and the reader promotes it only when it carries a
|
|
49
|
+
* kit throw name and this code grammar. Anyone who can forge that can already forge the problem line.
|
|
50
|
+
* The action line is exactly as trustworthy as the sentence above it — no more, and no less.
|
|
51
|
+
*
|
|
52
|
+
* ## Captured, not assumed
|
|
53
|
+
*
|
|
54
|
+
* The engine embeds the throw's text in its step record raw — it does not JSON-quote it, and it does
|
|
55
|
+
* not escape a newline. Read off a real local Workflows engine (wrangler 4.115.0, 2026-08-14) by
|
|
56
|
+
* driving a Workflow that threw exactly this shape and fetching the instance from the dev session's
|
|
57
|
+
* own Local Explorer instance endpoint:
|
|
58
|
+
*
|
|
59
|
+
* ```text
|
|
60
|
+
* Step threw a NonRetryableError with message "NonRetryableError: secrets/already_exists: Secret
|
|
61
|
+
* 'api-token' already exists.
|
|
62
|
+
* Use `update` to change an existing secret."
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* The same run with no action produced #349's captured text byte for byte, which is the compatibility
|
|
66
|
+
* claim stated as a measurement rather than a hope.
|
|
67
|
+
*
|
|
68
|
+
* **`detail` is not here and never will be.** It is the security boundary: client-safe text in
|
|
69
|
+
* `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
|
|
70
|
+
* `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
|
|
71
|
+
* includes it. This stops losing a field that was always meant for this reader.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The separator between the step's sentence and its action. One newline, stated once.
|
|
76
|
+
*
|
|
77
|
+
* Exported so a test can assert the wire rather than ask the code what it produces — and so the plant
|
|
78
|
+
* that proves the gate can fail is a one-character edit here.
|
|
79
|
+
*/
|
|
80
|
+
export const WORKFLOW_STEP_SEPARATOR = "\n";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The most either half may run to before it stops being treated as one.
|
|
84
|
+
*
|
|
85
|
+
* The text comes from a Worker over an API, and what it says decides how many bytes land in a terminal,
|
|
86
|
+
* a log line, and an audit row. Over the bound a half is not truncated — it is declined, because half a
|
|
87
|
+
* sentence read as the reason is worse than a general one with the reason underneath it.
|
|
88
|
+
*/
|
|
89
|
+
export const MAX_WORKFLOW_STEP_TEXT = 512;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Any line break. The separator is one specific break, so every other one — a bare `\r`, a second
|
|
93
|
+
* newline — is a shape this encoding never produces, and an unrecognized shape is declined.
|
|
94
|
+
*/
|
|
95
|
+
const LINE_BREAK = /[\n\r]/;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `domain/reason` grammar of every kit and adopter error code, mirroring `codeSegment` in
|
|
99
|
+
* ../error/payload. Anchored at the start, so it is the prefix or it is nothing.
|
|
100
|
+
*/
|
|
101
|
+
const CODE_PREFIX = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)*\/[a-z][a-z0-9]*(?:_[a-z0-9]+)*): /;
|
|
102
|
+
|
|
103
|
+
/** What a terminal step's text carries across the boundary: the code, the sentence, and the remedy. */
|
|
104
|
+
export interface WorkflowStepMessage {
|
|
105
|
+
/** The `PithyError` code the step raised — `secrets/already_exists`. */
|
|
106
|
+
code: string;
|
|
107
|
+
/** The public sentence. `PithyError.payload.message`, written to be safe for a client. */
|
|
108
|
+
message: string;
|
|
109
|
+
/** The remedy, when the raising error stated one. The CLI's action line. */
|
|
110
|
+
action?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Split a leading `<domain>/<reason>: ` off a text.
|
|
115
|
+
*
|
|
116
|
+
* Exported because the code grammar has exactly one statement and two readers: this encoding, and
|
|
117
|
+
* `@pithy-sh/cloudflare`'s handling of a bare `PithyError` throw, whose text is `payload.message` and
|
|
118
|
+
* carries no encoding at all. A second regex over there is how the two would drift.
|
|
119
|
+
*/
|
|
120
|
+
export function splitWorkflowStepCode(text: string): { code?: string; rest: string } {
|
|
121
|
+
const matched = CODE_PREFIX.exec(text);
|
|
122
|
+
if (matched === null) return { rest: text };
|
|
123
|
+
return { code: matched[1], rest: text.slice(matched[0].length) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
|
|
128
|
+
*
|
|
129
|
+
* An action is appended only when there is one to append and it is a single line. An action carrying a
|
|
130
|
+
* break would encode a shape the reader declines, taking the sentence down with it — so the sentence
|
|
131
|
+
* is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
|
|
132
|
+
*/
|
|
133
|
+
export function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string {
|
|
134
|
+
const stated = `${fields.code}: ${fields.message}`;
|
|
135
|
+
const action = fields.action?.trim();
|
|
136
|
+
if (action === undefined || action === "" || LINE_BREAK.test(action)) return stated;
|
|
137
|
+
return `${stated}${WORKFLOW_STEP_SEPARATOR}${action}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read a terminal step's text back, or `null` when it is not this encoding.
|
|
142
|
+
*
|
|
143
|
+
* Strict on purpose. No code prefix, an empty sentence, a half over the bound, a stray `\r`, or a
|
|
144
|
+
* second separator means the text is not something {@link encodeWorkflowStepMessage} wrote, and text
|
|
145
|
+
* the kit did not write does not become an operator's sentence — it stays in `detail`, where the raw
|
|
146
|
+
* platform text belongs.
|
|
147
|
+
*/
|
|
148
|
+
export function decodeWorkflowStepMessage(text: string): WorkflowStepMessage | null {
|
|
149
|
+
const { code, rest } = splitWorkflowStepCode(text.trim());
|
|
150
|
+
if (code === undefined) return null;
|
|
151
|
+
|
|
152
|
+
const cut = rest.indexOf(WORKFLOW_STEP_SEPARATOR);
|
|
153
|
+
const message = (cut === -1 ? rest : rest.slice(0, cut)).trim();
|
|
154
|
+
const action = cut === -1 ? undefined : rest.slice(cut + WORKFLOW_STEP_SEPARATOR.length).trim();
|
|
155
|
+
|
|
156
|
+
if (message === "" || message.length > MAX_WORKFLOW_STEP_TEXT || LINE_BREAK.test(message)) return null;
|
|
157
|
+
if (action === undefined) return { code, message };
|
|
158
|
+
if (action === "" || action.length > MAX_WORKFLOW_STEP_TEXT || LINE_BREAK.test(action)) return null;
|
|
159
|
+
return { code, message, action };
|
|
160
|
+
}
|