@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
@@ -2,13 +2,20 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { ValidationError } from "../error/pithyError";
5
- import { featureMarker } from "./environment";
6
- import { MAX_ISSUE_DIGITS, NAMESPACE_LIMITS } from "./limits";
7
- import { assertValidProjectName, fitSegment, kebab } from "./resource";
5
+ import { featureMarker, isFeatureMarker } from "./environment";
6
+ import { FEATURE_TAIL_SEPARATOR, MAX_ISSUE_DIGITS, NAMESPACE_LIMITS } from "./limits";
7
+ import { assertValidProjectName, kebab } from "./resource";
8
8
 
9
9
  /**
10
- * The names a feature environment provisions under: `<project>-f<issue>-<slug>-<binding>-<kind>` for a
11
- * resource, `<project>-f<issue>-<slug>-<worker>` for a Worker script.
10
+ * The names a feature environment provisions under: `<project>-f<issue>-<slug>--<binding>-<kind>` for a
11
+ * resource, `<project>-f<issue>-<slug>--<worker>` for a Worker script.
12
+ *
13
+ * **Two hyphens after the slug, and a project with no `f<digits>` segment (#643).** Together they make every
14
+ * feature name parse back to exactly one (project, issue, slug): the double hyphen is something no other name
15
+ * Pithy composes can hold ({@link FEATURE_TAIL_SEPARATOR}), and the first `f<digits>` segment is the issue's,
16
+ * because the project may carry none ({@link assertFeatureProject}). Before them, project `acme-f12-x`'s `prod`
17
+ * and project `acme`'s branch `feature/12-x-prod` composed every name between them, and a branch whose slug
18
+ * was a hyphen-prefix of a sibling's could compose the sibling's database. See {@link parseFeatureName}.
12
19
  *
13
20
  * **No environment segment, and no Worker segment.** A feature *is* an environment, and two Workers that
14
21
  * declare the same binding are meant to share one resource — so the binding name, not the topology, is
@@ -16,9 +23,12 @@ import { assertValidProjectName, fitSegment, kebab } from "./resource";
16
23
  *
17
24
  * Every name here is **derived and recomputed**, never stored: `provision` and `destroy` both compute the
18
25
  * same string from `(identity, binding, kind)`, which is what lets teardown reconcile exactly rather than
19
- * scan a prefix a hyphenated sibling slug could ambiguously match. That is also why these names
20
- * **truncate** where a capability's Workflow refuses: a feature name addresses nothing that outlives the
21
- * feature, and failing a CI run because a branch slug was long would be the worse failure.
26
+ * scan a prefix a hyphenated sibling slug could ambiguously match.
27
+ *
28
+ * **Never truncated (#643).** These names used to fit a long slug with a hash, on the reasoning that a feature
29
+ * name addresses nothing that outlives the feature. But a short hash is a slug some sibling branch can have
30
+ * whole, so a fitted name was a name two features could compose. A slug that does not fit is refused instead,
31
+ * naming the maximum ({@link assertFeatureSlugFits}), and every feature name is exactly its feature's.
22
32
  *
23
33
  * It lives in `core`, beside the rest of the naming rule, so the CLI and the facade compose one
24
34
  * implementation rather than two that drift.
@@ -40,9 +50,6 @@ export interface FeatureIdentity {
40
50
  slug: string;
41
51
  }
42
52
 
43
- /** Below this the slug segment isn't worth keeping legible; the trailing segment is truncated to give it room. */
44
- const MIN_SLUG_BUDGET = 3;
45
-
46
53
  /**
47
54
  * Refuse an issue number the budgets were not derived against.
48
55
  *
@@ -60,22 +67,139 @@ function assertIssue(issue: string): void {
60
67
  });
61
68
  }
62
69
 
70
+ /**
71
+ * **The issue as a name carries it: its digits, without leading zeros (#643).** `feature/0643-foo` and
72
+ * `feature/643-foo` are one issue, so they are one feature and compose one set of names. Read as strings they
73
+ * were two features, and their rate-limit namespaces collided every time. `0` stays `0`.
74
+ */
75
+ export function canonicalIssue(issue: string): string {
76
+ assertIssue(issue);
77
+ return issue.replace(/^0+(?=[0-9])/, "");
78
+ }
79
+
80
+ /**
81
+ * The `f<digits>` segment a project name carries, or `null` — the shape a feature's issue takes (#643).
82
+ *
83
+ * Read after kebabbing, because the kebab is what reaches an account.
84
+ */
85
+ export function featureMarkerInProjectName(project: string): string | null {
86
+ return kebab(project).split("-").find(isFeatureMarker) ?? null;
87
+ }
88
+
89
+ /**
90
+ * **Refuse features for a project whose name carries an `f<digits>` segment (#643).**
91
+ *
92
+ * A feature name is read left to right: the project, then the first `f<digits>` segment, which is the issue.
93
+ * A project named `acme-f12-x` puts that segment inside itself, and then its feature 3 on `y` and project `acme`'s
94
+ * feature 12 on `x-f3-y` are one string. Environment names already obey this rule ({@link isFeatureMarker}), so
95
+ * the project obeys it too, where the two shapes meet.
96
+ *
97
+ * **Refused here, not at `requireProjectName`.** Such a project's staging and prod are untouched: no name they
98
+ * compose holds {@link FEATURE_TAIL_SEPARATOR}, so no feature anywhere can compose one of theirs. Refusing the
99
+ * name everywhere would break every command of a project that is otherwise sound. `pithy init` refuses a new one,
100
+ * and `pithy doctor` names the segment an existing one carries. Nothing is renamed.
101
+ */
102
+ export function assertFeatureProject(project: string): void {
103
+ const marker = featureMarkerInProjectName(project);
104
+ if (marker === null) return;
105
+ throw new ValidationError({
106
+ message: `"${project}" can't have feature environments. Its name carries ${marker}, the shape a feature's issue takes.`,
107
+ action: "Staging and prod are unaffected. Features need a project name with no f and a number as one segment.",
108
+ detail: `A feature name is <project>-f<issue>-<slug>--<thing>, read from the first f<digits> segment. With one inside "${kebab(project)}", another project's feature could compose this project's feature names.`,
109
+ });
110
+ }
111
+
63
112
  /** The `<project>-f<issue>` head both feature shapes share, with the project held to the one project rule. */
64
- function head(identity: FeatureIdentity): string {
113
+ function head(identity: FeatureHead): string {
65
114
  assertValidProjectName(identity.project);
66
- assertIssue(identity.issue);
67
- return `${kebab(identity.project)}-${featureMarker(identity.issue)}`;
115
+ assertFeatureProject(identity.project);
116
+ return `${kebab(identity.project)}-${featureMarker(canonicalIssue(identity.issue))}`;
117
+ }
118
+
119
+ /** A feature name, read back: which project, issue and slug it belongs to, and what it names. */
120
+ export interface ParsedFeatureName {
121
+ /** The project segment, kebabbed. */
122
+ project: string;
123
+ /** The issue, canonical. */
124
+ issue: string;
125
+ /** The slug segment: the whole slug, since no feature name is truncated. */
126
+ slug: string;
127
+ /** Everything after {@link FEATURE_TAIL_SEPARATOR}: the binding and kind, Worker, Workflow or entry. */
128
+ thing: string;
68
129
  }
69
130
 
70
131
  /**
71
- * Fit `<head>-<slug>-<tail><fixed>` into `budget`, truncating the slug first and the tail only if the
72
- * tail is what is eating the name. `fixed` is never touched — it carries the kind suffix, which is the
73
- * only thing telling a `DB` bucket from a `DB` database.
132
+ * **Read a feature name back into its owner, or `null` when it is not one (#643).**
74
133
  *
75
- * Deterministic in its inputs and hash-disambiguated on both variable segments, so two long inputs
76
- * sharing a prefix still produce two different names.
134
+ * Exact, because the shape leaves one reading: a feature name holds {@link FEATURE_TAIL_SEPARATOR} exactly once,
135
+ * everything before it is `<project>-f<issue>-<slug>`, and the first `f<digits>` segment there is the issue,
136
+ * since a project may carry none. Nothing else Pithy composes holds the separator at all.
77
137
  */
78
- function composeFeatureName(headSegment: string, slug: string, tail: string, fixed: string, budget: number): string {
138
+ export function parseFeatureName(name: string): ParsedFeatureName | null {
139
+ const at = name.indexOf(FEATURE_TAIL_SEPARATOR);
140
+ if (at < 0 || name.indexOf(FEATURE_TAIL_SEPARATOR, at + 1) >= 0) return null;
141
+ const thing = name.slice(at + FEATURE_TAIL_SEPARATOR.length);
142
+ const segments = name.slice(0, at).split("-");
143
+ const marker = segments.findIndex(isFeatureMarker);
144
+ if (thing === "" || marker < 1 || marker === segments.length - 1) return null;
145
+ if (segments.some((segment) => segment === "")) return null;
146
+ return {
147
+ project: segments.slice(0, marker).join("-"),
148
+ issue: (segments[marker] as string).slice(1).replace(/^0+(?=[0-9])/, ""),
149
+ slug: segments.slice(marker + 1).join("-"),
150
+ thing,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * **Is this name one this feature composes?** The ownership check the isolation gates ask (#643).
156
+ *
157
+ * Parsed, never prefix-matched, and exact: the name's project, issue and slug are this feature's, character for
158
+ * character. `<project>-f<issue>-` is shared by every branch of one issue, so a prefix match reached siblings; and
159
+ * a fitted form of the slug, which this used to accept, is a short hash some sibling can have as its whole slug
160
+ * (`feature/12-login` owned `feature/12-c`). No feature name is fitted any more ({@link composeFeatureName}), so
161
+ * there is nothing but the slug itself to accept.
162
+ */
163
+ export function isFeatureOwnedName(identity: FeatureIdentity, name: string): boolean {
164
+ const parsed = parseFeatureName(name);
165
+ if (parsed === null) return false;
166
+ return (
167
+ parsed.project === kebab(identity.project) &&
168
+ parsed.issue === canonicalIssue(identity.issue) &&
169
+ parsed.slug === kebab(identity.slug)
170
+ );
171
+ }
172
+
173
+ /** A feature's project and issue: everything its names are derived from but the slug. */
174
+ export type FeatureHead = Pick<FeatureIdentity, "project" | "issue">;
175
+
176
+ /**
177
+ * **The longest slug one feature name leaves room for** — `limit` less `<project>-f<issue>-`, the
178
+ * {@link FEATURE_TAIL_SEPARATOR}, and `thing`, which is everything after it. Zero or less when `thing` leaves none.
179
+ */
180
+ export function featureSlugRoom(parts: FeatureHead, thing: string, limit: number): number {
181
+ return limit - head(parts).length - 1 - FEATURE_TAIL_SEPARATOR.length - thing.length;
182
+ }
183
+
184
+ /**
185
+ * Compose `<head>-<slug>--<tail><fixed>` whole, or refuse (#643).
186
+ *
187
+ * **Never truncated.** A fitted slug was a short hash, and a hash is a slug some sibling branch can have: the
188
+ * review of 4828e1fc found `feature/12-login` owning `feature/12-c`'s names, and two long slugs of one issue
189
+ * composing one database. A name that does not fit is refused, naming the longest slug that does, and
190
+ * `pithy feature create` and `pithy provision --feature` refuse the branch before anything exists
191
+ * ({@link assertFeatureSlugFits}). This is the backstop behind them: no path composes a feature name that is not
192
+ * exactly its feature's.
193
+ */
194
+ function composeFeatureName(
195
+ parts: FeatureHead,
196
+ slug: string,
197
+ tail: string,
198
+ fixed: string,
199
+ limit: number,
200
+ label: string,
201
+ ): string {
202
+ const headSegment = head(parts);
79
203
  for (const [role, value] of [
80
204
  ["slug", slug],
81
205
  ["binding or worker", tail],
@@ -90,22 +214,85 @@ function composeFeatureName(headSegment: string, slug: string, tail: string, fix
90
214
  });
91
215
  }
92
216
 
93
- const inner = budget - fixed.length;
94
- let tailSegment = tail;
95
- let slugBudget = inner - headSegment.length - 1 - (1 + tailSegment.length);
217
+ const thing = `${tail}${fixed}`;
218
+ const room = featureSlugRoom(parts, thing, limit);
219
+ if (room < 1) {
220
+ throw new ValidationError({
221
+ message: `"${thing}" is too long for a feature's ${label.replace(/^an? /, "")}.`,
222
+ action: `Shorten it. ${label[0]?.toUpperCase()}${label.slice(1)} stops at ${limit}, and ${headSegment}-<slug>-- comes first.`,
223
+ detail: `${headSegment}-<slug>--${thing} leaves ${room} characters for a slug.`,
224
+ });
225
+ }
226
+ if (slug.length > room) {
227
+ throw new ValidationError({
228
+ message: `Slug "${slug}" is ${slug.length} characters. ${label[0]?.toUpperCase()}${label.slice(1)} ${headSegment}-<slug>--${thing} leaves room for at most ${room}.`,
229
+ action: "Use a shorter branch slug.",
230
+ detail: `${headSegment}-${slug}--${thing} is ${headSegment.length + 1 + slug.length + FEATURE_TAIL_SEPARATOR.length + thing.length} characters; the limit is ${limit}. Feature names are never truncated.`,
231
+ });
232
+ }
233
+ return `${headSegment}-${slug}${FEATURE_TAIL_SEPARATOR}${thing}`;
234
+ }
96
235
 
97
- if (slugBudget < MIN_SLUG_BUDGET) {
98
- // The tail is eating the name — truncate it too, reserving the slug its minimum.
99
- const tailBudget = inner - headSegment.length - 1 - MIN_SLUG_BUDGET - 1;
100
- tailSegment = fitSegment(tailSegment, Math.max(1, tailBudget));
101
- slugBudget = inner - headSegment.length - 1 - (1 + tailSegment.length);
236
+ /** One kind of name a feature composes, as far as its slug budget reads it. */
237
+ export interface FeatureNameShape {
238
+ /** How an error names the namespace, e.g. `a Workflow name`. */
239
+ label: string;
240
+ /** The longest name the namespace takes. */
241
+ limit: number;
242
+ /** Everything after {@link FEATURE_TAIL_SEPARATOR}, e.g. `email-suppressions-d1`. */
243
+ thing: string;
244
+ }
245
+
246
+ /** The longest slug every one of `shapes` leaves room for, for this project and issue, and the shape that sets it. */
247
+ export function tightestFeatureShape(
248
+ parts: FeatureHead,
249
+ shapes: readonly FeatureNameShape[],
250
+ ): { max: number; shape: FeatureNameShape | null } {
251
+ let max = Number.POSITIVE_INFINITY;
252
+ let shape: FeatureNameShape | null = null;
253
+ for (const candidate of shapes) {
254
+ const room = featureSlugRoom(parts, candidate.thing, candidate.limit);
255
+ if (room < max) {
256
+ max = room;
257
+ shape = candidate;
258
+ }
102
259
  }
260
+ return { max, shape };
261
+ }
103
262
 
104
- return `${headSegment}-${fitSegment(slug, Math.max(1, slugBudget))}-${tailSegment}${fixed}`;
263
+ /** The longest slug a feature of this project and issue may have, across every name it composes. */
264
+ export function maxFeatureSlug(parts: FeatureHead, shapes: readonly FeatureNameShape[]): number {
265
+ return tightestFeatureShape(parts, shapes).max;
105
266
  }
106
267
 
107
268
  /**
108
- * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>-<binding>-<kind>`.
269
+ * **Refuse a branch whose slug cannot fit every name its feature composes (#643).** The one refusal
270
+ * `pithy feature create` and `pithy provision --feature` make, before a worktree or a resource exists, with the
271
+ * project's maximum in the message. {@link composeFeatureName} refuses the same slug one name at a time; this says
272
+ * it once, for all of them.
273
+ */
274
+ export function assertFeatureSlugFits(identity: FeatureIdentity, shapes: readonly FeatureNameShape[]): void {
275
+ const { max, shape } = tightestFeatureShape(identity, shapes);
276
+ const slug = kebab(identity.slug);
277
+ if (shape === null || slug.length <= max) return;
278
+ const issue = canonicalIssue(identity.issue);
279
+ const project = kebab(identity.project);
280
+ if (max < 1) {
281
+ throw new ValidationError({
282
+ message: `"${shape.thing}" leaves no room for a slug in ${project}'s features.`,
283
+ action: `Shorten it. ${shape.label[0]?.toUpperCase()}${shape.label.slice(1)} stops at ${shape.limit}.`,
284
+ detail: `${head(identity)}-<slug>--${shape.thing} leaves ${max} characters for a slug.`,
285
+ });
286
+ }
287
+ throw new ValidationError({
288
+ message: `Slug "${slug}" is ${slug.length} characters. Feature slugs in ${project} stop at ${max} characters at issue ${issue}.`,
289
+ action: `Name the branch feature/${issue}-<slug of ${max} or fewer>. Feature names are never truncated.`,
290
+ detail: `${shape.label} ${head(identity)}-<slug>--${shape.thing} is held to ${shape.limit} characters.`,
291
+ });
292
+ }
293
+
294
+ /**
295
+ * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>--<binding>-<kind>`.
109
296
  *
110
297
  * Held to **R2's 63**, the strictest of the three kinds a feature provisions, so one shape is legal for
111
298
  * all of them: lowercase, hyphenated, alphanumeric at both ends. A D1 or KV name could be longer, but a
@@ -113,17 +300,18 @@ function composeFeatureName(headSegment: string, slug: string, tail: string, fix
113
300
  */
114
301
  export function featureResourceName(identity: FeatureIdentity, binding: string, kind: FeatureResourceKind): string {
115
302
  return composeFeatureName(
116
- head(identity),
303
+ identity,
117
304
  kebab(identity.slug),
118
305
  kebab(binding),
119
306
  `-${kind}`,
120
307
  NAMESPACE_LIMITS.r2.maxLength,
308
+ NAMESPACE_LIMITS.r2.label,
121
309
  );
122
310
  }
123
311
 
124
312
  /**
125
313
  * The CF Secrets Store entry name holding one of a feature's **environment-scoped** secrets —
126
- * `<project>-f<issue>-<slug>-<secret>`.
314
+ * `<project>-f<issue>-<slug>--<secret>`.
127
315
  *
128
316
  * A Cloudflare account has one Secrets Store, flat and unpartitionable, so the entry name is the only
129
317
  * partition there is. A feature therefore needs its own names for the same reason it needs its own
@@ -133,21 +321,22 @@ export function featureResourceName(identity: FeatureIdentity, binding: string,
133
321
  * no documented Cloudflare cap, and holding it to the strictest kind's would hash
134
322
  * `secrets-encryption-keys` down to nothing for no reason.
135
323
  *
136
- * A `global` secret is not named here at all: it is one account-level value every environment binds,
137
- * so a feature binds the project's `<project>-global-<secret>` rather than minting a second copy.
324
+ * A `global` secret is named here too since #643: a feature shares nothing with any other environment, so
325
+ * its entry for a global secret is its own rather than the project's `<project>-global-<secret>`.
138
326
  */
139
327
  export function featureSecretEntryName(identity: FeatureIdentity, secret: string): string {
140
328
  return composeFeatureName(
141
- head(identity),
329
+ identity,
142
330
  kebab(identity.slug),
143
331
  kebab(secret),
144
332
  "",
145
333
  NAMESPACE_LIMITS.secretEntry.maxLength,
334
+ NAMESPACE_LIMITS.secretEntry.label,
146
335
  );
147
336
  }
148
337
 
149
338
  /**
150
- * The Worker **script name** for one of a feature's workers — `<project>-f<issue>-<slug>-<worker>`.
339
+ * The Worker **script name** for one of a feature's workers — `<project>-f<issue>-<slug>--<worker>`.
151
340
  *
152
341
  * This is the name the feature's Workers deploy under, and therefore the name a sibling's `service`
153
342
  * binding must target, so RPC inside a feature environment reaches that feature's deployment rather than
@@ -159,11 +348,58 @@ export function featureSecretEntryName(identity: FeatureIdentity, secret: string
159
348
  * has a charset rule and no length rule, so nothing upstream was going to stop it either.
160
349
  *
161
350
  * **`app` is the `apps/<app>` directory, never the deploy name.** A scaffolded Worker deploys as
162
- * `<project>-<app>`, and handing that here composed `<project>-f<issue>-<slug>-<project>-<app>` — the
351
+ * `<project>-<app>`, and handing that here composed `<project>-f<issue>-<slug>--<project>-<app>` — the
163
352
  * project twice, spent out of the budget above (#587). This function takes a string and cannot tell the
164
353
  * two apart, so the provisioning path reaches it only through `featureScope`, which is handed both names
165
354
  * and picks.
166
355
  */
167
356
  export function featureWorkerName(identity: FeatureIdentity, app: string): string {
168
- return composeFeatureName(head(identity), kebab(identity.slug), kebab(app), "", NAMESPACE_LIMITS.worker.maxLength);
357
+ return composeFeatureName(
358
+ identity,
359
+ kebab(identity.slug),
360
+ kebab(app),
361
+ "",
362
+ NAMESPACE_LIMITS.worker.maxLength,
363
+ NAMESPACE_LIMITS.worker.label,
364
+ );
365
+ }
366
+
367
+ /**
368
+ * The deployed name of one of a feature's **Workflows** — `<project>-f<issue>-<slug>--<capability>-<job>` (#643).
369
+ *
370
+ * A Workflow name is account-wide, like a Worker's, so a feature's email host cannot run
371
+ * `<project>-feature-email-send`: every open branch would deploy the same Workflow over every other's. It takes
372
+ * the feature's head instead, exactly as its host Worker does ({@link featureWorkerName}), so the Worker and the
373
+ * Workflows it hosts are recognizably one feature's and teardown recomputes both from the identity.
374
+ *
375
+ * Held to the **Workflow** limit ({@link NAMESPACE_LIMITS.workflow}), and fitted the way every feature name is:
376
+ * the slug gives way first, then the `<capability>-<job>` tail, deterministically.
377
+ */
378
+ export function featureWorkflowName(identity: FeatureIdentity, capability: string, job: string): string {
379
+ return composeFeatureName(
380
+ identity,
381
+ kebab(identity.slug),
382
+ `${kebab(capability)}-${kebab(job)}`,
383
+ "",
384
+ NAMESPACE_LIMITS.workflow.maxLength,
385
+ NAMESPACE_LIMITS.workflow.label,
386
+ );
387
+ }
388
+
389
+ /**
390
+ * The name of one of a feature's **Vectorize indexes** — `<project>-f<issue>-<slug>--<thing>` (#643).
391
+ *
392
+ * An index name is account-wide, so a feature's vector host cannot bind `<project>-feature-vector-<index>`: every
393
+ * open branch would read and write one index. Held to the Vectorize limit and fitted the way every feature name
394
+ * is, so teardown recomputes it from the identity.
395
+ */
396
+ export function featureVectorizeIndexName(identity: FeatureIdentity, thing: string): string {
397
+ return composeFeatureName(
398
+ identity,
399
+ kebab(identity.slug),
400
+ kebab(thing),
401
+ "",
402
+ NAMESPACE_LIMITS.vectorizeIndex.maxLength,
403
+ NAMESPACE_LIMITS.vectorizeIndex.label,
404
+ );
169
405
  }
@@ -33,6 +33,9 @@ export type LimitSource = "cloudflare" | "pithy";
33
33
  * **`truncate`** where Pithy recomputes the name from the same inputs on every command — a D1
34
34
  * database, a KV namespace, a bucket, a secret entry, a token. Truncation is deterministic and
35
35
  * hash-disambiguated, so provision and teardown still agree, and a failed CI run is avoided.
36
+ *
37
+ * **A feature's names are refused in every namespace, whatever its policy here (#643).** A fitted slug is a
38
+ * short hash, and a hash is a slug another branch can have whole; see `composeFeatureName` in `./feature`.
36
39
  */
37
40
  export type OverflowPolicy = "refuse" | "truncate";
38
41
 
@@ -162,11 +165,25 @@ export const MAX_ISSUE_DIGITS = 6;
162
165
  /**
163
166
  * The slug length a feature resource name keeps verbatim at the worst legal project name.
164
167
  *
165
- * Twelve, because that is where a feature slug stops being readable in a listing: below it,
166
- * `fitSegment` has to fall back to a short head plus a six-hex disambiguator, and
167
- * `acme-f95-medi-8f21c4-db-d1` tells a human nothing about which branch owns the database.
168
+ * Eleven — it was twelve until {@link FEATURE_TAIL_SEPARATOR} took a character of it (#643). About where a
169
+ * feature slug stops being readable in a listing. A slug past a feature's budget is refused, not fitted (#643),
170
+ * so this is the room the project-name cap guarantees every project's features at the worst legal input.
171
+ */
172
+ export const MIN_LEGIBLE_SLUG = 11;
173
+
174
+ /**
175
+ * **What stands between a feature's slug and whatever it names: two hyphens (#643).**
176
+ *
177
+ * `<project>-f<issue>-<slug>--<thing>`. Nothing else Pithy composes can contain it: every other segment is
178
+ * kebabbed, and a kebab never holds two hyphens in a row. So a name holding it is a feature's and a name without
179
+ * it is not — `acme`'s `staging` can never compose a feature's name, whatever its project or environment is
180
+ * called — and it ends the slug unambiguously, so `feature/12-x` binding `PROD_DB` and `feature/12-x-prod`
181
+ * binding `DB` no longer compose one database between them.
182
+ *
183
+ * It cost the slug a character: {@link MIN_LEGIBLE_SLUG} gave one up so {@link FEATURE_DERIVED_PROJECT_NAME},
184
+ * and with it every project name already accepted, stays where it was.
168
185
  */
169
- export const MIN_LEGIBLE_SLUG = 12;
186
+ export const FEATURE_TAIL_SEPARATOR = "--";
170
187
 
171
188
  /**
172
189
  * The binding length a feature resource name keeps verbatim at the worst legal project name.
@@ -199,7 +216,7 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
199
216
  NAMESPACE_LIMITS.workflow.maxLength - 1 - MAX_ENVIRONMENT_NAME - 1 - MAX_CAPABILITY_JOB;
200
217
 
201
218
  /**
202
- * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>-<binding>-<kind>`,
219
+ * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>--<binding>-<kind>`,
203
220
  * against R2's 63 (the strictest of the three kinds a feature provisions, and the same number a
204
221
  * feature Worker script gets).
205
222
  *
@@ -207,8 +224,8 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
207
224
  * 63 an R2 bucket name (NAMESPACE_LIMITS.r2)
208
225
  * - 2 `-f`
209
226
  * - 6 the issue number, six digits — `f999999` (MAX_ISSUE_DIGITS)
210
- * - 13 `-` + a legible slug (MIN_LEGIBLE_SLUG)
211
- * - 13 `-` + a binding kept whole (MIN_VERBATIM_BINDING)
227
+ * - 12 `-` + a legible slug (MIN_LEGIBLE_SLUG)
228
+ * - 14 `--` + a binding kept whole (FEATURE_TAIL_SEPARATOR, MIN_VERBATIM_BINDING)
212
229
  * - 3 `-` + the kind, `d1` | `kv` | `r2`
213
230
  * ----
214
231
  * 26
@@ -219,5 +236,5 @@ export const FEATURE_DERIVED_PROJECT_NAME =
219
236
  2 -
220
237
  MAX_ISSUE_DIGITS -
221
238
  (1 + MIN_LEGIBLE_SLUG) -
222
- (1 + MIN_VERBATIM_BINDING) -
239
+ (FEATURE_TAIL_SEPARATOR.length + MIN_VERBATIM_BINDING) -
223
240
  (1 + MAX_FEATURE_KIND);
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { InternalError, ValidationError } from "../error/pithyError";
5
+ import type { WorkflowHostNameParts } from "../workflow/naming";
5
6
  import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE, isFeatureMarker } from "./environment";
6
7
  import {
7
8
  type FeatureIdentity,
@@ -171,11 +172,22 @@ export interface ProvisionScope {
171
172
  /**
172
173
  * This scope's CF Secrets Store entry name for a declared secret.
173
174
  *
174
- * A `global` secret is the one name that does **not** take the scope's segment: it is a single
175
- * account-level value every environment binds, so every scope resolves it to the project's
176
- * `<project>-global-<secret>`. Scoping it would mint a second copy of a value defined as one.
175
+ * In a declared environment a `global` secret is the one name that does **not** take the scope's segment:
176
+ * it is a single account-level value every declared environment binds, so it resolves to the project's
177
+ * `<project>-global-<secret>`. A feature is the exception — it shares nothing with any other environment,
178
+ * so its entry for a `global` secret is its own (#643). See {@link featureScope}.
177
179
  */
178
180
  secretEntry(secret: string, secretScope: SecretNameScope): string;
181
+ /**
182
+ * What a capability's host Worker and its Workflows are named from in this scope — handed, with the
183
+ * capability, to `workflowHostName` and `workflowScriptName`. It is what an app Worker's cross-script
184
+ * `workflows` entry and the host's own config are both composed from (#643).
185
+ *
186
+ * On the scope for the reason every other name is: a declared environment composes
187
+ * `<project>-<env>-<capability>[-<job>]`, and a feature composes `<project>-f<issue>-<slug>-…`, because
188
+ * `<project>-feature-email-send` would be one Workflow every open branch deployed over every other's.
189
+ */
190
+ readonly workflowHost: Omit<WorkflowHostNameParts, "capability">;
179
191
  }
180
192
 
181
193
  /** The wrangler binding array a resource kind's name is composed for. */
@@ -331,6 +343,7 @@ export function environmentScope(project: string, environment: string): Provisio
331
343
  },
332
344
  secretEntry: (secret, secretScope) =>
333
345
  secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
346
+ workflowHost: { project, env: environment },
334
347
  };
335
348
  }
336
349
 
@@ -341,14 +354,16 @@ export function environmentScope(project: string, environment: string): Provisio
341
354
  * Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
342
355
  * in a declared environment's stanza unexpressible rather than merely discouraged.
343
356
  *
344
- * **A feature takes a binding's {@link BindingNaming} and ignores it — and that is the asymmetry against
345
- * `secretEntry` one line below, which honors `global` here as everywhere.** A global *secret* is a value
346
- * defined once for the project, so a feature binds the project's copy rather than minting a second: it is
347
- * **read**. A global *database* is a resource a feature would **migrate** — `provisionFeature` runs
348
- * `pithy migrate` against everything it names — so honoring `global` here would point one branch's
349
- * schema changes at the project's live suppression list, and point teardown at it afterwards. A feature
350
- * owns every resource it names, exactly so that destroying the feature destroys them all; there is no
351
- * shared name to match and nothing for teardown to spare.
357
+ * **A feature takes a binding's {@link BindingNaming} and ignores it, and a secret's `global` scope with it.**
358
+ * A global *database* is a resource a feature would **migrate** — `provisionFeature` runs `pithy migrate`
359
+ * against everything it names — so honoring `global` would point one branch's schema changes at the project's
360
+ * live suppression list, and point teardown at it afterwards. A global *secret* went the other way until #643:
361
+ * a feature bound the project's copy. It no longer does. **Nothing is shared between a feature and any other
362
+ * feature, staging or prod** — the account and its one Secrets Store are the only containers that cannot be
363
+ * split, and every entry inside the store is split. So a feature names its own entry for a `global` secret
364
+ * too: a mintable one is minted for it, and a supplied one is reported missing under the feature's name rather
365
+ * than bound to the value every environment reads. A feature owns everything it names, exactly so that
366
+ * destroying the feature destroys it all; there is no shared name to match and nothing for teardown to spare.
352
367
  *
353
368
  * `resource` is ignored for the same reason it is moot: a feature's `<thing>` segment is the binding plus
354
369
  * the kind, and the whole name is recomputed on teardown rather than looked up, so nothing outside the
@@ -372,8 +387,10 @@ export function featureScope(identity: FeatureIdentity): ProvisionScope {
372
387
  // The directory, not the deploy name: the head already carries the project, and the deploy name
373
388
  // usually leads with it too, which composed `<project>-f<issue>-<slug>-<project>-<app>` (#587).
374
389
  worker: ({ app }) => featureWorkerName(identity, app),
375
- secretEntry: (secret, secretScope) =>
376
- secretEntryName(identity.project, secret, secretScope, () => featureSecretEntryName(identity, secret)),
390
+ // Every entry the feature's, `global` included: see the docstring above.
391
+ secretEntry: (secret) => featureSecretEntryName(identity, secret),
392
+ // `env` is the stanza — what the host's `ENVIRONMENT` var says — and `feature` is what its names take.
393
+ workflowHost: { project: identity.project, env: FEATURE_ENVIRONMENT, feature: identity },
377
394
  };
378
395
  }
379
396
 
@@ -401,6 +418,14 @@ export function featureScope(identity: FeatureIdentity): ProvisionScope {
401
418
  */
402
419
  export function featureWorkerScriptNames(identity: FeatureIdentity, worker: ProvisionWorkerNames): string[] {
403
420
  const current = featureScope(identity).worker(worker);
404
- const beforeSingleProject = featureWorkerName(identity, worker.script);
421
+ let beforeSingleProject: string;
422
+ try {
423
+ beforeSingleProject = featureWorkerName(identity, worker.script);
424
+ } catch (error) {
425
+ // Too long to compose whole (#643). The release that deployed that shape fitted it with a hash instead, so no
426
+ // exact name here would reach it anyway; teardown reaches the current name, which always composes.
427
+ if (error instanceof ValidationError) return [current];
428
+ throw error;
429
+ }
405
430
  return current === beforeSingleProject ? [current] : [current, beforeSingleProject];
406
431
  }
package/src/seed/seed.ts CHANGED
@@ -163,8 +163,10 @@ export interface SeedPrepareContext {
163
163
  /** The project name (the root `pithy.config.ts` `name`), for messages and per-project lookups. */
164
164
  project: string;
165
165
  /**
166
- * Where this Worker answers locally — `http://localhost:8807` and the like. `null` outside `dev`, and
167
- * `null` in a checkout that was never allocated a port block.
166
+ * Where this Worker answers — in `dev`, locally, `http://localhost:8807` and the like; off `dev`, its own
167
+ * address (#643): a declared environment's declared one, a feature's `https://<script>.<subdomain>.workers.dev`.
168
+ * `pithy seed --host` overrides it. `null` in a checkout that was never allocated a port block, and for a
169
+ * deployed Worker with no address at all.
168
170
  *
169
171
  * The one address a fixture cannot write down. A dev port is *allocated*, not configured: a checkout
170
172
  * reserves a block and pins one port per Worker into `.dev.config.json`, so `http://localhost:8787` is
@@ -181,17 +183,44 @@ export interface SeedPrepareContext {
181
183
  * moves. Identity must not.
182
184
  *
183
185
  * **`null` is an answer, never a guess.** A plain clone that has never run `pithy dev`, a Worker added
184
- * after the block was pinned, or any environment but `dev` — a deployed environment's address is
185
- * declared rather than allocated, and `pithy env` is what answers it. An invented origin would be
186
- * indistinguishable from a real one, so a set that cannot work without one refuses and says so.
186
+ * after the block was pinned, or a deployed Worker that declares no address and has none to derive. An
187
+ * invented origin would be indistinguishable from a real one, so a set that cannot work without one
188
+ * refuses and says so.
187
189
  */
188
190
  origin: string | null;
189
191
  /**
190
192
  * Read one of this environment's secrets by name, or `undefined` when it is not set. Local dev resolves
191
- * every secret from `.dev.vars`, so this answers there; a deployed environment's secrets are not on the
192
- * operator's disk, and a set that needs one must therefore be `dev`-only.
193
+ * every secret from the dev secrets file, so this answers there; a deployed environment's secrets are not
194
+ * on the operator's disk, so there it refuses, and a set that needs one must be `dev`-only. A feature is a
195
+ * deployed environment here too (#643): its secrets exist only in its own Cloudflare stores, which the CLI
196
+ * can write and never read.
193
197
  */
194
198
  secret: (name: string) => Promise<string | undefined>;
199
+ /**
200
+ * The value **this run just minted** for a secret, or `undefined` — never what the environment holds.
201
+ *
202
+ * `pithy provision` creates a random value for every `cf-secrets-store` secret the registry declares
203
+ * mintable, writes it into the account's Secrets Store, and lets it go. The Secrets Store is write-only
204
+ * from the CLI, so that write is the only moment the value exists anywhere the toolchain can see it —
205
+ * and provisioning migrates and seeds in the same process, a few steps later. A fixture that must
206
+ * *seal* something at creation time (encrypt a private key under the environment's key-encryption
207
+ * secret, derive an id that the running app will re-derive) can only do it then. Afterwards the value
208
+ * is unrecoverable by design.
209
+ *
210
+ * **This is not {@link secret}, and the two are not interchangeable.** `secret` answers what the
211
+ * environment holds and refuses outside `dev`, absolutely (#159); that rule is untouched. This answers
212
+ * the strictly narrower question *did this run create one, a moment ago, in memory*. A set asking here
213
+ * for a secret this run did not mint gets `undefined` wherever it runs, in `dev` or anywhere else.
214
+ *
215
+ * **Synchronous, because it cannot fetch.** There is no file to open and no request to make; a promise
216
+ * in this signature would suggest otherwise.
217
+ *
218
+ * **Empty is ordinary.** A re-run mints nothing — absence is checked before anything is generated — and
219
+ * a standalone `pithy seed` mints nothing at all. Both answer `undefined` for every name, and neither
220
+ * is an error. A set that needs a minted value and is handed none says so itself; nothing is invented
221
+ * on its behalf.
222
+ */
223
+ mintedThisRun: (name: string) => string | undefined;
195
224
  /**
196
225
  * The developer's machine-local preferences for this project, read from the Pithy config directory
197
226
  * (`~/.config/pithy/<project>/dev.json`, or `%APPDATA%\pithy\<project>\dev.json` on Windows), parsed but
@@ -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.7.3";
16
+ export const PACKAGE_VERSION = "0.8.0";