@pithy-sh/core 0.10.0 → 0.11.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 (180) hide show
  1. package/dist/access/access.d.ts +172 -0
  2. package/dist/access/access.d.ts.map +1 -0
  3. package/dist/access/access.js +147 -0
  4. package/dist/access/acting.d.ts +49 -0
  5. package/dist/access/acting.d.ts.map +1 -0
  6. package/dist/access/acting.js +21 -0
  7. package/dist/access/policyCheck.d.ts +33 -0
  8. package/dist/access/policyCheck.d.ts.map +1 -0
  9. package/dist/access/policyCheck.js +48 -0
  10. package/dist/access/usage.d.ts +83 -0
  11. package/dist/access/usage.d.ts.map +1 -0
  12. package/dist/access/usage.js +104 -0
  13. package/dist/access/visibility.d.ts +66 -0
  14. package/dist/access/visibility.d.ts.map +1 -0
  15. package/dist/access/visibility.js +86 -0
  16. package/dist/audit/auditEvent.d.ts +5 -0
  17. package/dist/audit/auditEvent.d.ts.map +1 -1
  18. package/dist/audit/auditEvent.js +2 -0
  19. package/dist/capability/bindings.d.ts +47 -2
  20. package/dist/capability/bindings.d.ts.map +1 -1
  21. package/dist/capability/bindings.js +117 -7
  22. package/dist/capability/capability.d.ts +98 -28
  23. package/dist/capability/capability.d.ts.map +1 -1
  24. package/dist/capability/manifest.d.ts +2 -1
  25. package/dist/capability/manifest.d.ts.map +1 -1
  26. package/dist/capability/manifest.js +1 -1
  27. package/dist/capability/settings.d.ts +86 -2
  28. package/dist/capability/settings.d.ts.map +1 -1
  29. package/dist/capability/settings.js +80 -1
  30. package/dist/cli/commandCatalog.generated.d.ts +184 -22
  31. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  32. package/dist/cli/commandCatalog.generated.js +196 -22
  33. package/dist/cli/commandFacts.d.ts +1 -1
  34. package/dist/cli/commandFacts.d.ts.map +1 -1
  35. package/dist/cli/commandFacts.js +1 -0
  36. package/dist/controlPlane/capability.d.ts.map +1 -1
  37. package/dist/controlPlane/capability.js +12 -2
  38. package/dist/controlPlane/context.d.ts +5 -0
  39. package/dist/controlPlane/context.d.ts.map +1 -1
  40. package/dist/controlPlane/context.js +3 -1
  41. package/dist/controlPlane/data/connection.d.ts +47 -0
  42. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  43. package/dist/controlPlane/data/connection.js +55 -1
  44. package/dist/controlPlane/http/cors.d.ts +4 -4
  45. package/dist/controlPlane/http/cors.js +4 -4
  46. package/dist/controlPlane/http/guard.d.ts +11 -0
  47. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  48. package/dist/controlPlane/http/guard.js +17 -1
  49. package/dist/controlPlane/http/handlers.d.ts +60 -0
  50. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  51. package/dist/controlPlane/http/handlers.js +31 -2
  52. package/dist/controlPlane/http/readiness.d.ts +33 -0
  53. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  54. package/dist/controlPlane/http/readiness.js +39 -0
  55. package/dist/controlPlane/http/responses.d.ts +147 -0
  56. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  57. package/dist/controlPlane/http/responses.js +61 -4
  58. package/dist/controlPlane/http/routes.d.ts +1 -0
  59. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  60. package/dist/controlPlane/http/routes.js +17 -3
  61. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  62. package/dist/controlPlane/http/verify.js +2 -1
  63. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  64. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  65. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  66. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  67. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  68. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  69. package/dist/controlPlane/scope/scope.d.ts +13 -2
  70. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  71. package/dist/controlPlane/scope/scope.js +14 -3
  72. package/dist/createBackend.d.ts.map +1 -1
  73. package/dist/createBackend.js +7 -2
  74. package/dist/createEntrypoint.d.ts.map +1 -1
  75. package/dist/createEntrypoint.js +5 -3
  76. package/dist/entitlement/allowance.d.ts +25 -0
  77. package/dist/entitlement/allowance.d.ts.map +1 -0
  78. package/dist/entitlement/allowance.js +80 -0
  79. package/dist/entitlement/entitlement.d.ts +41 -0
  80. package/dist/entitlement/entitlement.d.ts.map +1 -1
  81. package/dist/entitlement/meter.d.ts +57 -0
  82. package/dist/entitlement/meter.d.ts.map +1 -0
  83. package/dist/entitlement/meter.js +13 -0
  84. package/dist/error/http.d.ts +144 -0
  85. package/dist/error/http.d.ts.map +1 -1
  86. package/dist/error/payload.d.ts +288 -0
  87. package/dist/error/payload.d.ts.map +1 -1
  88. package/dist/error/payload.js +100 -4
  89. package/dist/http/authContext.d.ts +7 -0
  90. package/dist/http/authContext.d.ts.map +1 -1
  91. package/dist/http/authContext.js +8 -1
  92. package/dist/http/scopeGates.d.ts +20 -0
  93. package/dist/http/scopeGates.d.ts.map +1 -0
  94. package/dist/http/scopeGates.js +36 -0
  95. package/dist/index.d.ts +1 -1
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +2 -2
  98. package/dist/logger/record.d.ts +84 -0
  99. package/dist/logger/record.d.ts.map +1 -1
  100. package/dist/migrations/capabilitySets.d.ts +23 -0
  101. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  102. package/dist/migrations/capabilitySets.js +74 -0
  103. package/dist/migrations/registry.d.ts +26 -0
  104. package/dist/migrations/registry.d.ts.map +1 -1
  105. package/dist/migrations/registry.js +38 -7
  106. package/dist/migrations/state.d.ts +60 -0
  107. package/dist/migrations/state.d.ts.map +1 -0
  108. package/dist/migrations/state.js +46 -0
  109. package/dist/naming/feature.d.ts +27 -5
  110. package/dist/naming/feature.d.ts.map +1 -1
  111. package/dist/naming/feature.js +36 -6
  112. package/dist/naming/limits.d.ts +47 -3
  113. package/dist/naming/limits.d.ts.map +1 -1
  114. package/dist/naming/limits.js +47 -3
  115. package/dist/naming/provisionScope.d.ts +6 -0
  116. package/dist/naming/provisionScope.d.ts.map +1 -1
  117. package/dist/naming/provisionScope.js +8 -3
  118. package/dist/naming/resourceNames.d.ts +21 -2
  119. package/dist/naming/resourceNames.d.ts.map +1 -1
  120. package/dist/naming/resourceNames.js +21 -3
  121. package/dist/seed/writeD1.d.ts +7 -1
  122. package/dist/seed/writeD1.d.ts.map +1 -1
  123. package/dist/seed/writeD1.js +9 -3
  124. package/dist/seed/writeKv.d.ts +2 -0
  125. package/dist/seed/writeKv.d.ts.map +1 -1
  126. package/dist/seed/writeKv.js +6 -2
  127. package/dist/version.generated.d.ts +1 -1
  128. package/dist/version.generated.js +1 -1
  129. package/dist/worker/serviceLoopback.d.ts +36 -0
  130. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  131. package/dist/worker/serviceLoopback.js +74 -0
  132. package/dist/workflow/faults.d.ts +12 -1
  133. package/dist/workflow/faults.d.ts.map +1 -1
  134. package/dist/workflow/faults.js +22 -15
  135. package/package.json +2 -2
  136. package/src/access/access.ts +298 -0
  137. package/src/access/acting.ts +59 -0
  138. package/src/access/policyCheck.ts +77 -0
  139. package/src/access/usage.ts +141 -0
  140. package/src/access/visibility.ts +109 -0
  141. package/src/audit/auditEvent.ts +4 -0
  142. package/src/capability/bindings.ts +159 -13
  143. package/src/capability/capability.ts +99 -29
  144. package/src/capability/settings.ts +146 -2
  145. package/src/cli/commandCatalog.generated.ts +212 -22
  146. package/src/cli/commandFacts.ts +1 -0
  147. package/src/controlPlane/capability.ts +25 -1
  148. package/src/controlPlane/context.ts +4 -0
  149. package/src/controlPlane/data/connection.ts +90 -0
  150. package/src/controlPlane/http/cors.ts +4 -4
  151. package/src/controlPlane/http/guard.ts +21 -0
  152. package/src/controlPlane/http/handlers.ts +41 -0
  153. package/src/controlPlane/http/readiness.ts +50 -0
  154. package/src/controlPlane/http/responses.ts +140 -2
  155. package/src/controlPlane/http/routes.ts +25 -1
  156. package/src/controlPlane/http/verify.ts +2 -0
  157. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  158. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  159. package/src/controlPlane/scope/scope.ts +14 -2
  160. package/src/createBackend.ts +24 -6
  161. package/src/createEntrypoint.ts +9 -3
  162. package/src/entitlement/allowance.ts +85 -0
  163. package/src/entitlement/entitlement.ts +38 -0
  164. package/src/entitlement/meter.ts +69 -0
  165. package/src/error/payload.ts +211 -4
  166. package/src/http/authContext.ts +13 -1
  167. package/src/http/scopeGates.ts +34 -0
  168. package/src/index.ts +1 -0
  169. package/src/migrations/capabilitySets.ts +87 -0
  170. package/src/migrations/registry.ts +46 -2
  171. package/src/migrations/state.ts +94 -0
  172. package/src/naming/feature.ts +46 -7
  173. package/src/naming/limits.ts +35 -3
  174. package/src/naming/provisionScope.ts +19 -1
  175. package/src/naming/resourceNames.ts +52 -5
  176. package/src/seed/writeD1.ts +13 -4
  177. package/src/seed/writeKv.ts +6 -2
  178. package/src/version.generated.ts +1 -1
  179. package/src/worker/serviceLoopback.ts +105 -0
  180. package/src/workflow/faults.ts +51 -24
@@ -34,11 +34,26 @@ import { assertValidProjectName, kebab } from "./resource";
34
34
  * implementation rather than two that drift.
35
35
  */
36
36
 
37
- /** A Cloudflare resource kind a feature can provision. All two characters — `MAX_FEATURE_KIND` in `./limits`. */
38
- export type FeatureResourceKind = "d1" | "kv" | "r2";
37
+ /** A Cloudflare resource kind Pithy names and creates — in a feature, and in every declared environment. */
38
+ export type FeatureResourceKind = "d1" | "kv" | "r2" | "queue";
39
39
 
40
40
  /** Every feature resource kind, for tests and for callers that enumerate. */
41
- export const FEATURE_RESOURCE_KINDS: readonly FeatureResourceKind[] = ["d1", "kv", "r2"];
41
+ export const FEATURE_RESOURCE_KINDS: readonly FeatureResourceKind[] = ["d1", "kv", "r2", "queue"];
42
+
43
+ /**
44
+ * **The segment each kind ends a feature resource's name with — never more than `MAX_FEATURE_KIND` (#662).**
45
+ *
46
+ * `d1`, `kv` and `r2` spell themselves. A queue is `q`, and that is load-bearing: `MAX_FEATURE_KIND` is a term in
47
+ * `FEATURE_DERIVED_PROJECT_NAME`, so spelling it `queue` would take the project budget from 26 to 23 — and a
48
+ * project name is stable forever once anything is provisioned, so a smaller budget orphans every resource of a
49
+ * project already named against the old one. `feature.test.ts` fails on a segment longer than the budget.
50
+ */
51
+ export const FEATURE_KIND_SEGMENT: Readonly<Record<FeatureResourceKind, string>> = {
52
+ d1: "d1",
53
+ kv: "kv",
54
+ r2: "r2",
55
+ queue: "q",
56
+ };
42
57
 
43
58
  /** The identity of a feature — everything a feature name is derived from. */
44
59
  export interface FeatureIdentity {
@@ -292,10 +307,11 @@ export function assertFeatureSlugFits(identity: FeatureIdentity, shapes: readonl
292
307
  }
293
308
 
294
309
  /**
295
- * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>--<binding>-<kind>`.
310
+ * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>--<binding>-<kind>`, the
311
+ * kind spelled by {@link FEATURE_KIND_SEGMENT}.
296
312
  *
297
- * Held to **R2's 63**, the strictest of the three kinds a feature provisions, so one shape is legal for
298
- * all of them: lowercase, hyphenated, alphanumeric at both ends. A D1 or KV name could be longer, but a
313
+ * Held to **R2's 63**, the strictest of the kinds a feature provisions and a queue's number too, so one shape is
314
+ * legal for all of them: lowercase, hyphenated, alphanumeric at both ends. A D1 or KV name could be longer, but a
299
315
  * feature that provisions a bucket and a database wants them recognizably the same name.
300
316
  */
301
317
  export function featureResourceName(identity: FeatureIdentity, binding: string, kind: FeatureResourceKind): string {
@@ -303,7 +319,7 @@ export function featureResourceName(identity: FeatureIdentity, binding: string,
303
319
  identity,
304
320
  kebab(identity.slug),
305
321
  kebab(binding),
306
- `-${kind}`,
322
+ `-${FEATURE_KIND_SEGMENT[kind]}`,
307
323
  NAMESPACE_LIMITS.r2.maxLength,
308
324
  NAMESPACE_LIMITS.r2.label,
309
325
  );
@@ -364,6 +380,29 @@ export function featureWorkerName(identity: FeatureIdentity, app: string): strin
364
380
  );
365
381
  }
366
382
 
383
+ /** The `<thing>` a feature's Artifacts namespace ends with, after {@link FEATURE_TAIL_SEPARATOR} (#673). */
384
+ export const FEATURE_ARTIFACTS_THING = "artifacts";
385
+
386
+ /**
387
+ * A feature's **Artifacts namespace** — `<project>-f<issue>-<slug>--artifacts` (#673).
388
+ *
389
+ * A declared environment's namespace is `<project>-<env>` and has no `<thing>`. A feature's needs one, because the
390
+ * separator and what follows it are what make a name a feature name: {@link isFeatureOwnedName} reads this one back
391
+ * as this feature's, so `pithy feature destroy` reaps the repos of exactly this branch and of no sibling. Held to the
392
+ * namespace's 63, the same number the rest of a feature's names are derived against, and `artifacts` is shorter than
393
+ * the shortest binding tail, so it moves no slug budget.
394
+ */
395
+ export function featureArtifactsNamespace(identity: FeatureIdentity): string {
396
+ return composeFeatureName(
397
+ identity,
398
+ kebab(identity.slug),
399
+ FEATURE_ARTIFACTS_THING,
400
+ "",
401
+ NAMESPACE_LIMITS.artifactsNamespace.maxLength,
402
+ NAMESPACE_LIMITS.artifactsNamespace.label,
403
+ );
404
+ }
405
+
367
406
  /**
368
407
  * The deployed name of one of a feature's **Workflows** — `<project>-f<issue>-<slug>--<capability>-<job>` (#643).
369
408
  *
@@ -107,6 +107,35 @@ export const NAMESPACE_LIMITS = {
107
107
  * no longer hashes one at 63.
108
108
  */
109
109
  kv: { label: "a KV namespace title", maxLength: 512, minLength: 1, policy: "truncate", source: "cloudflare" },
110
+ /**
111
+ * A Queue name: **1 to 63 characters**, lowercase letters, digits and dashes, starting and ending with a letter
112
+ * or number ([Queues · Get started](https://developers.cloudflare.com/queues/get-started/), verified
113
+ * 2026-10-18). Truncated rather than refused: Pithy recomputes a queue's name from its binding on every run,
114
+ * as it does a bucket's, so a deterministic hash is safe (#662). The same 63 as R2, so a feature's queue fits
115
+ * the shape {@link FEATURE_DERIVED_PROJECT_NAME} is derived against and moves no budget.
116
+ */
117
+ queue: { label: "a Queue name", maxLength: 63, minLength: 1, policy: "truncate", source: "cloudflare" },
118
+ /**
119
+ * An Artifacts namespace: **2 to 63 characters**, starting with a letter or digit, the rest letters, digits, `.`,
120
+ * `_` and `-` ([Artifacts · Limits](https://developers.cloudflare.com/artifacts/platform/limits/), verified
121
+ * 2026-10-18). Truncated rather than refused: the kit derives it from the project and the environment on every
122
+ * run, like an R2 bucket name, and the longest project and environment compose 34 characters, so it never is (#673).
123
+ */
124
+ artifactsNamespace: {
125
+ label: "an Artifacts namespace name",
126
+ maxLength: 63,
127
+ minLength: 2,
128
+ policy: "truncate",
129
+ source: "cloudflare",
130
+ },
131
+ /**
132
+ * An Artifacts repo name. **The charset is Cloudflare's** — a letter or digit, then letters, digits, `.`, `_` and
133
+ * `-` ([Artifacts · Limits](https://developers.cloudflare.com/artifacts/platform/limits/), verified 2026-10-18) —
134
+ * and **the length is ours**: that page states one for a namespace and none for a repo, so 2 to 63 mirrors the
135
+ * namespace rather than inventing a second number. Refused, never truncated: a repo name is either a UUID or a
136
+ * name somebody chose, and a name somebody chose is never silently shortened (#673).
137
+ */
138
+ artifactsRepo: { label: "an Artifacts repo name", maxLength: 63, minLength: 2, policy: "refuse", source: "pithy" },
110
139
  /**
111
140
  * A D1 database name: `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` in the OpenAPI schema, and **no documented
112
141
  * length cap** — the limits page has no row for it. So the ceiling here is {@link MAX_PITHY_NAME}.
@@ -196,7 +225,10 @@ export const FEATURE_TAIL_SEPARATOR = "--";
196
225
  */
197
226
  export const MIN_VERBATIM_BINDING = 12;
198
227
 
199
- /** The longest feature resource kind — `d1`, `kv`, `r2` are all two characters. `feature.test.ts` pins it. */
228
+ /**
229
+ * The longest feature resource kind segment — `d1`, `kv`, `r2`, and a queue's `q` (#662). `feature.test.ts` pins
230
+ * every kind's `FEATURE_KIND_SEGMENT` to it, so a longer segment fails rather than shrinking every project's budget.
231
+ */
200
232
  export const MAX_FEATURE_KIND = 2;
201
233
 
202
234
  /**
@@ -217,7 +249,7 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
217
249
 
218
250
  /**
219
251
  * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>--<binding>-<kind>`,
220
- * against R2's 63 (the strictest of the three kinds a feature provisions, and the same number a
252
+ * against R2's 63 (the strictest of the kinds a feature provisions, a queue's number too, and the same number a
221
253
  * feature Worker script gets).
222
254
  *
223
255
  * ```
@@ -226,7 +258,7 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
226
258
  * - 6 the issue number, six digits — `f999999` (MAX_ISSUE_DIGITS)
227
259
  * - 12 `-` + a legible slug (MIN_LEGIBLE_SLUG)
228
260
  * - 14 `--` + a binding kept whole (FEATURE_TAIL_SEPARATOR, MIN_VERBATIM_BINDING)
229
- * - 3 `-` + the kind, `d1` | `kv` | `r2`
261
+ * - 3 `-` + the kind, `d1` | `kv` | `r2` | `q`
230
262
  * ----
231
263
  * 26
232
264
  * ```
@@ -8,6 +8,7 @@ import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE, isFeatureMar
8
8
  import {
9
9
  type FeatureIdentity,
10
10
  type FeatureResourceKind,
11
+ featureArtifactsNamespace,
11
12
  featureResourceName,
12
13
  featureSecretEntryName,
13
14
  featureWorkerName,
@@ -189,10 +190,21 @@ export interface ProvisionScope {
189
190
  * `<project>-feature-email-send` would be one Workflow every open branch deployed over every other's.
190
191
  */
191
192
  readonly workflowHost: Omit<WorkflowHostNameParts, "capability">;
193
+ /**
194
+ * The Artifacts namespace an `artifacts` binding names in this scope (#673): `<project>-<env>` in a declared
195
+ * environment, `<project>-f<issue>-<slug>--artifacts` in a feature. On the scope for the reason every other name
196
+ * is: the stanza a run writes and the namespace it writes into it are one decision.
197
+ */
198
+ readonly artifactsNamespace: string;
192
199
  }
193
200
 
194
201
  /** The wrangler binding array a resource kind's name is composed for. */
195
- const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2"> = { d1: "d1", kv: "kv", r2: "r2" };
202
+ const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2" | "queue"> = {
203
+ d1: "d1",
204
+ kv: "kv",
205
+ r2: "r2",
206
+ queue: "queue",
207
+ };
196
208
 
197
209
  /**
198
210
  * The one branch every scope shares: a `global` secret resolves to the project's single
@@ -345,6 +357,7 @@ export function environmentScope(project: string, environment: string): Provisio
345
357
  secretEntry: (secret, secretScope) =>
346
358
  secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
347
359
  workflowHost: { project, env: environment },
360
+ artifactsNamespace: names.artifactsNamespace(),
348
361
  };
349
362
  }
350
363
 
@@ -392,6 +405,11 @@ export function featureScope(identity: FeatureIdentity): ProvisionScope {
392
405
  secretEntry: (secret) => featureSecretEntryName(identity, secret),
393
406
  // `env` is the stanza — what the host's `ENVIRONMENT` var says — and `feature` is what its names take.
394
407
  workflowHost: { project: identity.project, env: FEATURE_ENVIRONMENT, feature: identity },
408
+ // The feature's own, read back as its own by `isFeatureOwnedName`, so teardown reaps no sibling's repos. A
409
+ // getter, so a slug too long for it refuses where the namespace is asked for, never where the scope is made.
410
+ get artifactsNamespace() {
411
+ return featureArtifactsNamespace(identity);
412
+ },
395
413
  };
396
414
  }
397
415
 
@@ -4,7 +4,13 @@
4
4
  import { InternalError, ValidationError } from "../error/pithyError";
5
5
  import { workflowHostName, workflowScriptName } from "../workflow/naming";
6
6
  import { assertValidEnvironment, GLOBAL_SCOPE } from "./environment";
7
- import { type FeatureIdentity, type FeatureResourceKind, featureResourceName, featureWorkerName } from "./feature";
7
+ import {
8
+ type FeatureIdentity,
9
+ type FeatureResourceKind,
10
+ featureArtifactsNamespace,
11
+ featureResourceName,
12
+ featureWorkerName,
13
+ } from "./feature";
8
14
  import { NAMESPACE_LIMITS, type NamespaceLimit } from "./limits";
9
15
  import { assertValidProjectName, kebab, resourceName } from "./resource";
10
16
 
@@ -57,6 +63,8 @@ export interface ScopedNames {
57
63
  kv(binding: string): string;
58
64
  /** An R2 bucket. Cloudflare's 63 — the one namespace where that number was always right. */
59
65
  r2(binding: string): string;
66
+ /** A Queue. Cloudflare's 63, truncated with a stable hash like a bucket (#662). */
67
+ queue(binding: string): string;
60
68
  /** A Vectorize index. 64 bytes, refused rather than truncated: a renamed index has no vectors. */
61
69
  vectorizeIndex(index: string): string;
62
70
  /** A Secrets Store entry. No Cloudflare cap; Pithy's ceiling. */
@@ -65,15 +73,33 @@ export interface ScopedNames {
65
73
  apiToken(profile: string): string;
66
74
  }
67
75
 
76
+ /**
77
+ * The names in one declared environment: everything a scope has, and the one name that exists per environment
78
+ * and never at `global`.
79
+ */
80
+ export interface EnvironmentNames extends ScopedNames {
81
+ /**
82
+ * The Artifacts namespace — `<project>-<env>`, with no `<thing>` (#673). One per environment, so a feature's
83
+ * repos never sit beside production's and the per-namespace rate limit splits per environment. There is no
84
+ * global one, because a namespace shared across environments is exactly that mix.
85
+ */
86
+ artifactsNamespace(): string;
87
+ }
88
+
68
89
  /** The names a feature environment provisions under, with the project already bound. */
69
90
  export interface FeatureNames {
70
- /** A feature's D1, KV, or R2 resource — `<project>-f<issue>-<slug>-<binding>-<kind>`. */
91
+ /** A feature's D1, KV, R2 or Queue resource — `<project>-f<issue>-<slug>--<binding>-<kind>`. */
71
92
  resource(binding: string, kind: FeatureResourceKind): string;
72
93
  /**
73
94
  * A feature's Worker script — `<project>-f<issue>-<slug>-<app>`, from the `apps/<app>` directory and
74
95
  * never the deploy name, which already leads with the project (#587).
75
96
  */
76
97
  worker(app: string): string;
98
+ /**
99
+ * A feature's Artifacts namespace — `<project>-f<issue>-<slug>--artifacts` (#673). The tail is what makes it a
100
+ * feature name `isFeatureOwnedName` reads back, so teardown reaps this feature's repos and no sibling's.
101
+ */
102
+ artifactsNamespace(): string;
77
103
  }
78
104
 
79
105
  /** Everything a project can be asked to name. Created once, from the project, by {@link resourceNames}. */
@@ -81,7 +107,7 @@ export interface ProjectNames {
81
107
  /** The kebabbed project name every name below leads with. */
82
108
  readonly project: string;
83
109
  /** The names in one environment. Throws if the environment is not one this scheme accepts. */
84
- env(environment: string): ScopedNames;
110
+ env(environment: string): EnvironmentNames;
85
111
  /** The names shared by every environment — the `global` scope, without typing the word. */
86
112
  readonly global: ScopedNames;
87
113
  /** The names a feature environment provisions under. */
@@ -134,6 +160,22 @@ function scopedName(project: string, scope: string, thing: string, limit: Namesp
134
160
  return name;
135
161
  }
136
162
 
163
+ /**
164
+ * `<project>-<env>`, under the Artifacts namespace rule. The project and environment are both validated by now, and
165
+ * the longest of each compose 34 characters, so the cap is a check rather than a truncation that ever happens.
166
+ */
167
+ function artifactsNamespaceName(project: string, environment: string): string {
168
+ const limit = NAMESPACE_LIMITS.artifactsNamespace;
169
+ const name = `${project}-${environment}`;
170
+ if (name.length > limit.maxLength || name.length < limit.minLength) {
171
+ throw new InternalError({
172
+ message: `"${name}" does not fit ${limit.label}, which takes ${limit.minLength} to ${limit.maxLength} characters.`,
173
+ action: "Shorten the project or environment name.",
174
+ });
175
+ }
176
+ return name;
177
+ }
178
+
137
179
  /** The getters for one scope. `global` and every environment share this, so nothing special-cases either. */
138
180
  function scopedNames(project: string, scope: string): ScopedNames {
139
181
  return {
@@ -142,6 +184,7 @@ function scopedNames(project: string, scope: string): ScopedNames {
142
184
  d1: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.d1),
143
185
  kv: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.kv),
144
186
  r2: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.r2),
187
+ queue: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.queue),
145
188
  vectorizeIndex: (index) => scopedName(project, scope, index, NAMESPACE_LIMITS.vectorizeIndex),
146
189
  secretEntry: (secret) => scopedName(project, scope, secret, NAMESPACE_LIMITS.secretEntry),
147
190
  apiToken: (profile) => scopedName(project, scope, profile, NAMESPACE_LIMITS.apiToken),
@@ -161,9 +204,12 @@ export function resourceNames(project: string): ProjectNames {
161
204
 
162
205
  return {
163
206
  project: normalized,
164
- env(environment: string): ScopedNames {
207
+ env(environment: string): EnvironmentNames {
165
208
  assertValidEnvironment(environment);
166
- return scopedNames(normalized, environment);
209
+ return {
210
+ ...scopedNames(normalized, environment),
211
+ artifactsNamespace: () => artifactsNamespaceName(normalized, environment),
212
+ };
167
213
  },
168
214
  global: scopedNames(normalized, GLOBAL_SCOPE),
169
215
  feature(identity): FeatureNames {
@@ -171,6 +217,7 @@ export function resourceNames(project: string): ProjectNames {
171
217
  return {
172
218
  resource: (binding, kind) => featureResourceName(full, binding, kind),
173
219
  worker: (app) => featureWorkerName(full, app),
220
+ artifactsNamespace: () => featureArtifactsNamespace(full),
174
221
  };
175
222
  },
176
223
  };
@@ -17,8 +17,14 @@ export interface SeedD1Options {
17
17
  export interface SeedD1Result {
18
18
  /** The table seeded. */
19
19
  table: string;
20
- /** The number of rows validated (and, unless `dryRun`, written). */
20
+ /** The number of rows the fixture holds, every one validated. */
21
21
  rows: number;
22
+ /**
23
+ * The number of rows this call inserted. Under `INSERT OR IGNORE` a row whose key is already there is
24
+ * not, so a re-run writes none, and says so here rather than repeating `rows` (#764). Always `0` under
25
+ * `dryRun`.
26
+ */
27
+ written: number;
22
28
  }
23
29
 
24
30
  /**
@@ -89,15 +95,18 @@ export async function seedD1Group(
89
95
  });
90
96
 
91
97
  if (options.dryRun || encoded.length === 0) {
92
- return { table: group.table, rows: encoded.length };
98
+ return { table: group.table, rows: encoded.length, written: 0 };
93
99
  }
94
100
 
101
+ let written = 0n;
95
102
  for (const chunk of chunkRowsByBoundParameters(encoded, columnsPerRow(encoded))) {
96
- await db
103
+ const results = await db
97
104
  .insertInto(group.table)
98
105
  .orIgnore()
99
106
  .values(chunk as never)
100
107
  .execute();
108
+ // D1's `meta.changes`: the rows the statement inserted, which an ignored conflict is not.
109
+ for (const result of results) written += result.numInsertedOrUpdatedRows ?? 0n;
101
110
  }
102
- return { table: group.table, rows: encoded.length };
111
+ return { table: group.table, rows: encoded.length, written: Number(written) };
103
112
  }
@@ -19,6 +19,8 @@ export interface SeedKvResult {
19
19
  store: string;
20
20
  /** The number of entries validated (and, unless `dryRun`, considered for writing). */
21
21
  entries: number;
22
+ /** The number of entries this call put. An existing key is left alone and not counted (#764). Always `0` under `dryRun`. */
23
+ written: number;
22
24
  }
23
25
 
24
26
  /**
@@ -87,13 +89,15 @@ export async function seedKvGroup<V extends z.ZodType, K extends z.ZodObject, M
87
89
  });
88
90
 
89
91
  if (options.dryRun) {
90
- return { store: group.store, entries: group.entries.length };
92
+ return { store: group.store, entries: group.entries.length, written: 0 };
91
93
  }
92
94
 
95
+ let written = 0;
93
96
  for (const entry of group.entries) {
94
97
  // Never overwrite an existing key — the KV counterpart of D1's INSERT OR IGNORE.
95
98
  if (await keyExists(store, entry.key)) continue;
96
99
  await store.put(entry.key, entry.value, entry.metadata !== undefined ? { metadata: entry.metadata } : {});
100
+ written += 1;
97
101
  }
98
- return { store: group.store, entries: group.entries.length };
102
+ return { store: group.store, entries: group.entries.length, written };
99
103
  }
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/core";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.10.0";
16
+ export const PACKAGE_VERSION = "0.11.0";
@@ -0,0 +1,105 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { commandName } from "../cli/command";
6
+ import { UpstreamError } from "../error/pithyError";
7
+ import { ENVIRONMENT_VAR } from "./identity";
8
+
9
+ /**
10
+ * **A service binding under `pithy dev`: the call reaches the local target on its pinned port (#617).**
11
+ *
12
+ * Deployed, `env.APP_B.fetch(request)` is a service binding: Cloudflare runs the call inside the runtime, into
13
+ * the script the binding names. Locally there is no such script. `wrangler dev` would look for one in its own
14
+ * cross-process registry — the one `pithy dev` never relies on — and a Worker started on its own with
15
+ * `pithy dev --app app-a` would find nothing there at all. What does exist is the target itself, on the port its
16
+ * feature pinned for it, and `pithy dev` knows that address before anything starts.
17
+ *
18
+ * So `pithy dev` publishes one var, {@link SERVICE_ORIGINS_VAR}: a JSON map from each service binding this
19
+ * Worker declares to its target's local origin. Under `dev`, and only there, each of those bindings is replaced
20
+ * with a stand-in whose `fetch` sends the same request to that origin. The call site is byte-identical either
21
+ * way. A target that is not running refuses the connection, so the call fails at once as
22
+ * `core/upstream_failed`, naming the binding and the address, rather than hanging on a registry lookup.
23
+ *
24
+ * **Only `dev`, and only what was published.** The var is written by `pithy dev` and by nothing else, and the
25
+ * gate reads `ENVIRONMENT` off the request env as the Workflow loopback does (`workflow/dispatch.ts`): a
26
+ * deployment that lost its `ENVIRONMENT` var never opens it.
27
+ */
28
+
29
+ /** The var `pithy dev` publishes: each service binding's local target, as JSON. */
30
+ export const SERVICE_ORIGINS_VAR = "PITHY_SERVICE_ORIGINS";
31
+
32
+ /** The one environment the stand-in is made in. */
33
+ const LOCAL = "dev";
34
+
35
+ /** What the var carries: binding name to an `http(s)` origin. */
36
+ const ServiceOrigins = z
37
+ .record(
38
+ z.string().describe("A service binding's name in the Worker env."),
39
+ z.url({ protocol: /^https?$/ }).describe("The local origin of the Worker it calls, as `pithy dev` pinned it."),
40
+ )
41
+ .describe("Each service binding's local target, published by `pithy dev`.");
42
+
43
+ /** The transport. The runtime's `fetch`, or a test's. */
44
+ export type ServiceLoopbackFetch = (request: Request) => Promise<Response>;
45
+
46
+ /** A Fetcher's one method that a plain `fetch` target uses. */
47
+ interface LocalService {
48
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
49
+ }
50
+
51
+ /** A stand-in for one service binding: every request sent to `origin`, its path, method, headers and body kept. */
52
+ function localService(binding: string, origin: string, send: ServiceLoopbackFetch): LocalService {
53
+ const target = new URL(origin);
54
+ return {
55
+ async fetch(input, init) {
56
+ const request = new Request(input, init);
57
+ const url = new URL(request.url);
58
+ url.protocol = target.protocol;
59
+ url.host = target.host;
60
+ try {
61
+ return await send(new Request(url, request));
62
+ } catch (cause) {
63
+ throw new UpstreamError(
64
+ {
65
+ message: `The Worker ${binding} calls is not answering.`,
66
+ action: `Check that it is running under ${commandName("dev")} at ${target.origin}.`,
67
+ detail: `${request.method} ${url.href} failed: ${String(cause)}`,
68
+ },
69
+ { cause },
70
+ );
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Options for {@link withLocalServices}. */
77
+ export interface LocalServicesOptions {
78
+ /** The transport. Defaults to the runtime's `fetch`. */
79
+ fetch?: ServiceLoopbackFetch;
80
+ }
81
+
82
+ /**
83
+ * The env a request runs with: under `pithy dev`, each published service binding replaced with its local
84
+ * stand-in; anywhere else, the env unchanged — the same object, so nothing outside `dev` pays for this.
85
+ */
86
+ export function withLocalServices(
87
+ env: Record<string, unknown>,
88
+ options: LocalServicesOptions = {},
89
+ ): Record<string, unknown> {
90
+ if (env[ENVIRONMENT_VAR] !== LOCAL) return env;
91
+ const published = env[SERVICE_ORIGINS_VAR];
92
+ if (typeof published !== "string") return env;
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(published);
96
+ } catch {
97
+ return env;
98
+ }
99
+ const origins = ServiceOrigins.safeParse(parsed);
100
+ if (!origins.success || Object.keys(origins.data).length === 0) return env;
101
+ const send = options.fetch ?? ((request: Request) => fetch(request));
102
+ const local: Record<string, unknown> = { ...env };
103
+ for (const [binding, origin] of Object.entries(origins.data)) local[binding] = localService(binding, origin, send);
104
+ return local;
105
+ }
@@ -192,6 +192,27 @@ export interface DurableStepLike extends WorkflowStepLike {
192
192
  sleep(name: string, durationMs: number): Promise<void>;
193
193
  }
194
194
 
195
+ /**
196
+ * The platform step's configured `do`: a retry policy ahead of the body. Every `WorkflowStep` has it; a
197
+ * test double that only takes two arguments does not, which is why {@link ClassifiedSteps.once} is the one
198
+ * place it is reached for.
199
+ */
200
+ interface ConfiguredStepLike {
201
+ do<T>(name: string, config: { retries: { limit: number; delay: number } }, fn: () => Promise<T>): Promise<T>;
202
+ }
203
+
204
+ /** What {@link classifiedSteps} hands back: a pausing runner, and a step that runs exactly once. */
205
+ export interface ClassifiedSteps extends DurableStepLike {
206
+ /**
207
+ * Run a named step with **no retries**, still classified (pithy-sh/pithy#602).
208
+ *
209
+ * For a body that mutates somebody else's system — a provider roll — where a platform re-running the
210
+ * step after an eviction would do it twice. The classification still decides what the instance's
211
+ * failure says; it can no longer decide on a retry, because there is none to give.
212
+ */
213
+ once<T>(name: string, fn: () => Promise<T>): Promise<T>;
214
+ }
215
+
195
216
  /**
196
217
  * The platform's terminal-error class — `NonRetryableError` from `cloudflare:workflows`, taken as a
197
218
  * parameter.
@@ -222,31 +243,37 @@ export function classifiedSteps(
222
243
  step: WorkflowStepLike,
223
244
  policy: WorkflowRetryPolicy,
224
245
  terminalError: TerminalErrorConstructor,
225
- ): DurableStepLike {
226
- const runner: DurableStepLike = {
246
+ ): ClassifiedSteps {
247
+ const classify =
248
+ <T>(fn: () => Promise<T>) =>
249
+ async (): Promise<T> => {
250
+ try {
251
+ return await fn();
252
+ } catch (error) {
253
+ const fault = classifyWorkflowFault(error, policy);
254
+ if (fault.disposition === "retry") throw error;
255
+ // The one sanctioned non-`PithyError` throw in the kit: a platform contract, at the durable
256
+ // boundary, carrying the `PithyError` it replaced as its `cause` so nothing is lost in this
257
+ // process. Across the boundary the text is the whole channel — the engine records it and
258
+ // discards the throw — so what an operator is owed is encoded into it, at one stated site,
259
+ // and read back at one (./stepMessage). The `cause` does not travel; `detail` never did.
260
+ const terminal = new terminalError(
261
+ encodeWorkflowStepMessage({
262
+ code: fault.code,
263
+ message: messageOf(error),
264
+ action: pithyActionOf(error),
265
+ }),
266
+ );
267
+ terminal.cause = error;
268
+ throw terminal;
269
+ }
270
+ };
271
+ const runner: ClassifiedSteps = {
227
272
  do<T>(name: string, fn: () => Promise<T>): Promise<T> {
228
- return step.do(name, async () => {
229
- try {
230
- return await fn();
231
- } catch (error) {
232
- const fault = classifyWorkflowFault(error, policy);
233
- if (fault.disposition === "retry") throw error;
234
- // The one sanctioned non-`PithyError` throw in the kit: a platform contract, at the durable
235
- // boundary, carrying the `PithyError` it replaced as its `cause` so nothing is lost in this
236
- // process. Across the boundary the text is the whole channel — the engine records it and
237
- // discards the throw — so what an operator is owed is encoded into it, at one stated site,
238
- // and read back at one (./stepMessage). The `cause` does not travel; `detail` never did.
239
- const terminal = new terminalError(
240
- encodeWorkflowStepMessage({
241
- code: fault.code,
242
- message: messageOf(error),
243
- action: pithyActionOf(error),
244
- }),
245
- );
246
- terminal.cause = error;
247
- throw terminal;
248
- }
249
- });
273
+ return step.do(name, classify(fn));
274
+ },
275
+ once<T>(name: string, fn: () => Promise<T>): Promise<T> {
276
+ return (step as unknown as ConfiguredStepLike).do(name, { retries: { limit: 0, delay: 0 } }, classify(fn));
250
277
  },
251
278
  /**
252
279
  * Forwarded, and deliberately unclassified. A pause raises no fault of the capability's — the