@pithy-sh/core 0.7.3 → 0.8.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/dist/naming/domains.d.ts +43 -3
- package/dist/naming/domains.d.ts.map +1 -1
- package/dist/naming/domains.js +62 -4
- package/dist/naming/feature.d.ts +126 -11
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +184 -30
- package/dist/naming/limits.d.ts +23 -7
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +22 -9
- package/dist/naming/provisionScope.d.ts +25 -11
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +28 -11
- package/dist/seed/seed.d.ts +36 -7
- package/dist/seed/seed.d.ts.map +1 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/identity.d.ts +9 -0
- package/dist/worker/identity.d.ts.map +1 -1
- package/dist/worker/identity.js +10 -1
- package/dist/workflow/faults.d.ts +34 -2
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +42 -18
- package/dist/workflow/host.d.ts +7 -0
- package/dist/workflow/host.d.ts.map +1 -1
- package/dist/workflow/host.js +5 -3
- package/dist/workflow/naming.d.ts +8 -0
- package/dist/workflow/naming.d.ts.map +1 -1
- package/dist/workflow/naming.js +15 -0
- package/package.json +1 -1
- package/src/naming/domains.ts +75 -5
- package/src/naming/feature.ts +274 -38
- package/src/naming/limits.ts +25 -8
- package/src/naming/provisionScope.ts +39 -14
- package/src/seed/seed.ts +36 -7
- package/src/version.generated.ts +1 -1
- package/src/worker/identity.ts +10 -0
- package/src/workflow/faults.ts +61 -4
- package/src/workflow/host.ts +15 -3
- package/src/workflow/naming.ts +25 -0
package/src/worker/identity.ts
CHANGED
|
@@ -28,6 +28,16 @@ export const PROJECT_VAR = "PROJECT";
|
|
|
28
28
|
/** The var naming the environment this deployment serves — `dev`, `staging`, or `prod`. */
|
|
29
29
|
export const ENVIRONMENT_VAR = "ENVIRONMENT";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The var naming where a deployed Worker answers — `https://<host>`, no path, no trailing slash.
|
|
33
|
+
*
|
|
34
|
+
* Generated, never hand-set: `pithy worker sync` writes it from a declared domain, and `pithy provision
|
|
35
|
+
* --feature` writes a feature's from its `workers.dev` address (#643). It is the one statement of a feature's
|
|
36
|
+
* origin a running Worker can read, because the account's `workers.dev` subdomain is something only the
|
|
37
|
+
* Cloudflare API knows, and a Worker has no business calling it at module scope.
|
|
38
|
+
*/
|
|
39
|
+
export const BASE_URL_VAR = "BASE_URL";
|
|
40
|
+
|
|
31
41
|
/**
|
|
32
42
|
* The var carrying a Worker's **own** dev origin, from `pithy dev` to whatever launches that Worker.
|
|
33
43
|
*
|
package/src/workflow/faults.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { d1TransientFault } from "../data/withD1Retry";
|
|
5
|
-
import { PithyError } from "../error/pithyError";
|
|
5
|
+
import { InternalError, PithyError } from "../error/pithyError";
|
|
6
6
|
import { encodeWorkflowStepMessage } from "./stepMessage";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -153,10 +153,43 @@ export function classifyWorkflowFault(error: unknown, policy: WorkflowRetryPolic
|
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* The half of a Workflow step this kit uses: run a named body, or serve its journalled result.
|
|
158
|
+
*
|
|
159
|
+
* **`sleep` is optional here and required on what {@link classifiedSteps} hands back, and the asymmetry
|
|
160
|
+
* is the truth rather than a convenience.** The platform's own `WorkflowStep` always offers one. What
|
|
161
|
+
* does not are this kit's hand-written test doubles, which exist to exercise the classification and
|
|
162
|
+
* nothing else — and demanding a primitive the classifier never classifies would cost every one of them
|
|
163
|
+
* a line of noise. Handing an optional one back instead would push a null check into the one caller that
|
|
164
|
+
* genuinely pauses.
|
|
165
|
+
*/
|
|
157
166
|
export interface WorkflowStepLike {
|
|
158
167
|
/** Run a named step, or return its journalled result if this instance already completed it. */
|
|
159
168
|
do<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
169
|
+
/**
|
|
170
|
+
* Pause the instance for `durationMs` and resume it afterwards — possibly in a fresh isolate, which
|
|
171
|
+
* is the whole reason a caller reaches for it rather than for a timer. Absent only on a test double.
|
|
172
|
+
*/
|
|
173
|
+
sleep?(name: string, durationMs: number): Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A step runner that can pause — what {@link classifiedSteps} hands back, always.
|
|
178
|
+
*
|
|
179
|
+
* The durable pause is why this type exists rather than being a convenience over the one above. A `sleep`
|
|
180
|
+
* is journalled, so a wait survives the instance being evicted during it; a `setTimeout` inside one step
|
|
181
|
+
* burns that step's wall clock and dies with the instance holding it.
|
|
182
|
+
*
|
|
183
|
+
* **It was introduced on a stronger claim, and the claim did not survive contact (pithy-sh/pithy#647).** The
|
|
184
|
+
* argument was that a Secrets Store binding answers the same for its isolate's whole life, so only a resume
|
|
185
|
+
* into a fresh isolate could ever see a write land. Measured against a real store on 2026-09-25, that is
|
|
186
|
+
* false: a binding refreshes inside a live isolate roughly 1.3-1.5s after the REST write, proved by holding
|
|
187
|
+
* two isolates warm through a write and watching each serve the old value before it and the new one after,
|
|
188
|
+
* over 328 samples. The durability argument above is the true one and is sufficient on its own.
|
|
189
|
+
*/
|
|
190
|
+
export interface DurableStepLike extends WorkflowStepLike {
|
|
191
|
+
/** Pause the instance for `durationMs`, then resume. */
|
|
192
|
+
sleep(name: string, durationMs: number): Promise<void>;
|
|
160
193
|
}
|
|
161
194
|
|
|
162
195
|
/**
|
|
@@ -189,8 +222,8 @@ export function classifiedSteps(
|
|
|
189
222
|
step: WorkflowStepLike,
|
|
190
223
|
policy: WorkflowRetryPolicy,
|
|
191
224
|
terminalError: TerminalErrorConstructor,
|
|
192
|
-
):
|
|
193
|
-
|
|
225
|
+
): DurableStepLike {
|
|
226
|
+
const runner: DurableStepLike = {
|
|
194
227
|
do<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
195
228
|
return step.do(name, async () => {
|
|
196
229
|
try {
|
|
@@ -215,5 +248,29 @@ export function classifiedSteps(
|
|
|
215
248
|
}
|
|
216
249
|
});
|
|
217
250
|
},
|
|
251
|
+
/**
|
|
252
|
+
* Forwarded, and deliberately unclassified. A pause raises no fault of the capability's — the
|
|
253
|
+
* instance either resumes or it is gone — so there is nothing here for a policy to decide.
|
|
254
|
+
*
|
|
255
|
+
* A runner that cannot pause is refused, and the refusal is raised **through `do`** rather than
|
|
256
|
+
* thrown from here. A throw from here is a throw the engine never classifies, so it is free to
|
|
257
|
+
* re-drive the instance — and re-driving is the unbounded loop the caller is spending its sleeps to
|
|
258
|
+
* avoid. Going through `do` makes the refusal terminal on its first attempt, like every other
|
|
259
|
+
* answer that cannot change.
|
|
260
|
+
*/
|
|
261
|
+
sleep(name: string, durationMs: number): Promise<void> {
|
|
262
|
+
const pause = step.sleep;
|
|
263
|
+
if (pause === undefined) {
|
|
264
|
+
return runner.do<void>(`${name}/cannot-pause`, async () => {
|
|
265
|
+
throw new InternalError({
|
|
266
|
+
message: "This Workflow step runner cannot pause.",
|
|
267
|
+
action: "Give the step runner a `sleep`; the platform's own `WorkflowStep` always has one.",
|
|
268
|
+
detail: `step '${name}' asked to pause ${durationMs}ms through a runner that declares no sleep`,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return pause.call(step, name, durationMs);
|
|
273
|
+
},
|
|
218
274
|
};
|
|
275
|
+
return runner;
|
|
219
276
|
}
|
package/src/workflow/host.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { InternalError } from "../error/pithyError";
|
|
5
|
+
import type { FeatureIdentity } from "../naming/feature";
|
|
5
6
|
import { ENVIRONMENT_VAR, PROJECT_VAR } from "../worker/identity";
|
|
6
7
|
import { type WorkflowHostNameParts, workflowHostName, workflowScriptName } from "./naming";
|
|
7
8
|
import type { WorkflowRegistry } from "./spec";
|
|
@@ -118,6 +119,12 @@ export interface WorkflowHostParams {
|
|
|
118
119
|
capability: string;
|
|
119
120
|
/** The target environment (`dev` | `staging` | `production`). */
|
|
120
121
|
env: string;
|
|
122
|
+
/**
|
|
123
|
+
* The feature this host serves, when it serves one (#643). Its name then takes the feature's head —
|
|
124
|
+
* `<project>-f<issue>-<slug>-<capability>` — as the Workflows derived with the same parts do; `env` still
|
|
125
|
+
* says `feature`, and that is what the host's `ENVIRONMENT` var is stamped with.
|
|
126
|
+
*/
|
|
127
|
+
feature?: FeatureIdentity;
|
|
121
128
|
/** D1 binding name → database id. An unlisted binding keeps the template's id. */
|
|
122
129
|
databaseIds?: Record<string, string>;
|
|
123
130
|
/**
|
|
@@ -195,7 +202,12 @@ export function resolveWorkflowHost(template: WorkflowHostTemplate, params: Work
|
|
|
195
202
|
const resolved = structuredClone(template);
|
|
196
203
|
const remote = new Set(params.remoteBindings ?? []);
|
|
197
204
|
|
|
198
|
-
resolved.name = workflowHostName({
|
|
205
|
+
resolved.name = workflowHostName({
|
|
206
|
+
project: params.project,
|
|
207
|
+
capability: params.capability,
|
|
208
|
+
env: params.env,
|
|
209
|
+
...(params.feature ? { feature: params.feature } : {}),
|
|
210
|
+
});
|
|
199
211
|
|
|
200
212
|
if (resolved.d1_databases) {
|
|
201
213
|
resolved.d1_databases = resolved.d1_databases.map((entry) => ({
|
|
@@ -293,7 +305,7 @@ export function hostWorkflowsFor(
|
|
|
293
305
|
registry: WorkflowRegistry,
|
|
294
306
|
parts: WorkflowHostNameParts,
|
|
295
307
|
): { workflows: HostWorkflowBinding[]; crons: string[] } {
|
|
296
|
-
const { project, capability, env } = parts;
|
|
308
|
+
const { project, capability, env, feature } = parts;
|
|
297
309
|
const owned = Object.values(registry).filter((entry) => entry.capability === capability);
|
|
298
310
|
const workflows: HostWorkflowBinding[] = [];
|
|
299
311
|
const crons: string[] = [];
|
|
@@ -307,7 +319,7 @@ export function hostWorkflowsFor(
|
|
|
307
319
|
}
|
|
308
320
|
workflows.push({
|
|
309
321
|
binding: entry.spec.binding,
|
|
310
|
-
name: workflowScriptName({ project, capability, job: entry.job, env }),
|
|
322
|
+
name: workflowScriptName({ project, capability, job: entry.job, env, ...(feature ? { feature } : {}) }),
|
|
311
323
|
class_name: entry.spec.className,
|
|
312
324
|
});
|
|
313
325
|
if (entry.spec.schedule) crons.push(entry.spec.schedule);
|
package/src/workflow/naming.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { InternalError } from "../error/pithyError";
|
|
5
|
+
import { type FeatureIdentity, featureWorkerName, featureWorkflowName } from "../naming/feature";
|
|
5
6
|
import { MAX_CAPABILITY_JOB, NAMESPACE_LIMITS } from "../naming/limits";
|
|
6
7
|
import { assertValidProjectName, kebab } from "../naming/resource";
|
|
7
8
|
import { NAME_SEGMENT } from "../naming/segment";
|
|
@@ -109,6 +110,21 @@ function head(project: string, env: string): string {
|
|
|
109
110
|
return `${kebab(project)}-${env}`;
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The feature a name is composed for, held to the project the parts name — two projects in one set of parts is
|
|
115
|
+
* a name composed for somebody else's feature.
|
|
116
|
+
*/
|
|
117
|
+
function featureOf(parts: WorkflowHostNameParts): FeatureIdentity {
|
|
118
|
+
const feature = parts.feature as FeatureIdentity;
|
|
119
|
+
if (feature.project !== parts.project) {
|
|
120
|
+
throw new InternalError({
|
|
121
|
+
message: "A feature's Workflow name was asked for with two different projects.",
|
|
122
|
+
detail: `parts name project "${parts.project}" and a feature of project "${feature.project}".`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return feature;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
/** The identity of a capability's deployment in one environment. */
|
|
113
129
|
export interface WorkflowHostNameParts {
|
|
114
130
|
/** The project name — the root `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed. */
|
|
@@ -117,6 +133,13 @@ export interface WorkflowHostNameParts {
|
|
|
117
133
|
capability: string;
|
|
118
134
|
/** The deployment environment (e.g. `staging`). */
|
|
119
135
|
env: string;
|
|
136
|
+
/**
|
|
137
|
+
* **The feature this deployment is, when it is one (#643).** A feature's host Worker and Workflows take the
|
|
138
|
+
* feature's head — `<project>-f<issue>-<slug>-…` — not `<project>-feature-…`, which every open branch would
|
|
139
|
+
* share: Worker and Workflow names are account-wide, so a second branch's deploy would replace the first's.
|
|
140
|
+
* `env` stays `feature`, which is what the host's `ENVIRONMENT` var says; only the names change.
|
|
141
|
+
*/
|
|
142
|
+
feature?: FeatureIdentity;
|
|
120
143
|
}
|
|
121
144
|
|
|
122
145
|
/** The identity of one durable job's Workflow in one environment. */
|
|
@@ -139,6 +162,7 @@ export function workflowScriptName(parts: WorkflowScriptNameParts): string {
|
|
|
139
162
|
assertSegment(parts.capability, "capability");
|
|
140
163
|
assertSegment(parts.job, "job");
|
|
141
164
|
assertCapabilityJob(parts.capability, parts.job);
|
|
165
|
+
if (parts.feature) return featureWorkflowName(featureOf(parts), parts.capability, parts.job);
|
|
142
166
|
return assertLength(`${head(parts.project, parts.env)}-${parts.capability}-${parts.job}`, MAX_WORKFLOW_NAME_BYTES);
|
|
143
167
|
}
|
|
144
168
|
|
|
@@ -151,6 +175,7 @@ export function workflowScriptName(parts: WorkflowScriptNameParts): string {
|
|
|
151
175
|
*/
|
|
152
176
|
export function workflowHostName(parts: WorkflowHostNameParts): string {
|
|
153
177
|
assertSegment(parts.capability, "capability");
|
|
178
|
+
if (parts.feature) return featureWorkerName(featureOf(parts), parts.capability);
|
|
154
179
|
return assertLength(`${head(parts.project, parts.env)}-${parts.capability}`, MAX_WORKER_NAME_BYTES);
|
|
155
180
|
}
|
|
156
181
|
|