@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.
Files changed (39) hide show
  1. package/dist/naming/domains.d.ts +43 -3
  2. package/dist/naming/domains.d.ts.map +1 -1
  3. package/dist/naming/domains.js +62 -4
  4. package/dist/naming/feature.d.ts +126 -11
  5. package/dist/naming/feature.d.ts.map +1 -1
  6. package/dist/naming/feature.js +184 -30
  7. package/dist/naming/limits.d.ts +23 -7
  8. package/dist/naming/limits.d.ts.map +1 -1
  9. package/dist/naming/limits.js +22 -9
  10. package/dist/naming/provisionScope.d.ts +25 -11
  11. package/dist/naming/provisionScope.d.ts.map +1 -1
  12. package/dist/naming/provisionScope.js +28 -11
  13. package/dist/seed/seed.d.ts +36 -7
  14. package/dist/seed/seed.d.ts.map +1 -1
  15. package/dist/version.generated.d.ts +1 -1
  16. package/dist/version.generated.js +1 -1
  17. package/dist/worker/identity.d.ts +9 -0
  18. package/dist/worker/identity.d.ts.map +1 -1
  19. package/dist/worker/identity.js +10 -1
  20. package/dist/workflow/faults.d.ts +34 -2
  21. package/dist/workflow/faults.d.ts.map +1 -1
  22. package/dist/workflow/faults.js +42 -18
  23. package/dist/workflow/host.d.ts +7 -0
  24. package/dist/workflow/host.d.ts.map +1 -1
  25. package/dist/workflow/host.js +5 -3
  26. package/dist/workflow/naming.d.ts +8 -0
  27. package/dist/workflow/naming.d.ts.map +1 -1
  28. package/dist/workflow/naming.js +15 -0
  29. package/package.json +1 -1
  30. package/src/naming/domains.ts +75 -5
  31. package/src/naming/feature.ts +274 -38
  32. package/src/naming/limits.ts +25 -8
  33. package/src/naming/provisionScope.ts +39 -14
  34. package/src/seed/seed.ts +36 -7
  35. package/src/version.generated.ts +1 -1
  36. package/src/worker/identity.ts +10 -0
  37. package/src/workflow/faults.ts +61 -4
  38. package/src/workflow/host.ts +15 -3
  39. package/src/workflow/naming.ts +25 -0
@@ -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
  *
@@ -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
- /** The half of a Workflow step this kit uses: run a named body, or serve its journalled result. */
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
- ): WorkflowStepLike {
193
- return {
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
  }
@@ -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({ project: params.project, capability: params.capability, env: params.env });
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);
@@ -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