@pithy-sh/core 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/naming/domains.d.ts +43 -3
- package/dist/naming/domains.d.ts.map +1 -1
- package/dist/naming/domains.js +62 -4
- package/dist/naming/feature.d.ts +126 -11
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +184 -30
- package/dist/naming/limits.d.ts +23 -7
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +22 -9
- package/dist/naming/provisionScope.d.ts +25 -11
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +28 -11
- package/dist/seed/seed.d.ts +11 -7
- package/dist/seed/seed.d.ts.map +1 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/identity.d.ts +9 -0
- package/dist/worker/identity.d.ts.map +1 -1
- package/dist/worker/identity.js +10 -1
- package/dist/workflow/host.d.ts +7 -0
- package/dist/workflow/host.d.ts.map +1 -1
- package/dist/workflow/host.js +5 -3
- package/dist/workflow/naming.d.ts +8 -0
- package/dist/workflow/naming.d.ts.map +1 -1
- package/dist/workflow/naming.js +15 -0
- package/package.json +1 -1
- package/src/naming/domains.ts +75 -5
- package/src/naming/feature.ts +274 -38
- package/src/naming/limits.ts +25 -8
- package/src/naming/provisionScope.ts +39 -14
- package/src/seed/seed.ts +11 -7
- package/src/version.generated.ts +1 -1
- package/src/worker/identity.ts +10 -0
- package/src/workflow/host.ts +15 -3
- package/src/workflow/naming.ts +25 -0
package/src/naming/feature.ts
CHANGED
|
@@ -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,
|
|
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
|
|
11
|
-
* resource, `<project>-f<issue>-<slug
|
|
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.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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:
|
|
113
|
+
function head(identity: FeatureHead): string {
|
|
65
114
|
assertValidProjectName(identity.project);
|
|
66
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
137
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/naming/limits.ts
CHANGED
|
@@ -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
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
|
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
|
|
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
|
-
* -
|
|
211
|
-
* -
|
|
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
|
-
(
|
|
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
|
-
*
|
|
175
|
-
* account-level value every environment binds, so
|
|
176
|
-
* `<project>-global-<secret>`.
|
|
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
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
*
|
|
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,15 +183,17 @@ 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
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
192
|
-
* operator's disk, and a set that needs one must
|
|
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>;
|
|
195
199
|
/**
|
package/src/version.generated.ts
CHANGED
package/src/worker/identity.ts
CHANGED
|
@@ -28,6 +28,16 @@ export const PROJECT_VAR = "PROJECT";
|
|
|
28
28
|
/** The var naming the environment this deployment serves — `dev`, `staging`, or `prod`. */
|
|
29
29
|
export const ENVIRONMENT_VAR = "ENVIRONMENT";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The var naming where a deployed Worker answers — `https://<host>`, no path, no trailing slash.
|
|
33
|
+
*
|
|
34
|
+
* Generated, never hand-set: `pithy worker sync` writes it from a declared domain, and `pithy provision
|
|
35
|
+
* --feature` writes a feature's from its `workers.dev` address (#643). It is the one statement of a feature's
|
|
36
|
+
* origin a running Worker can read, because the account's `workers.dev` subdomain is something only the
|
|
37
|
+
* Cloudflare API knows, and a Worker has no business calling it at module scope.
|
|
38
|
+
*/
|
|
39
|
+
export const BASE_URL_VAR = "BASE_URL";
|
|
40
|
+
|
|
31
41
|
/**
|
|
32
42
|
* The var carrying a Worker's **own** dev origin, from `pithy dev` to whatever launches that Worker.
|
|
33
43
|
*
|