@pithy-sh/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { MAX_ENVIRONMENT_NAME } from "./environment";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What Cloudflare will actually accept as a name, per namespace — and, where it publishes nothing,
|
|
8
|
+
* what Pithy chooses instead.
|
|
9
|
+
*
|
|
10
|
+
* Pithy used to hold one number, 63, against every namespace it writes into, justified as "R2's
|
|
11
|
+
* cap". That is true of R2 and of nothing else. It over-truncated a KV title by a factor of eight
|
|
12
|
+
* and truncated D1 databases, Secrets Store entries, and API token labels against limits that do
|
|
13
|
+
* not exist — `secrets-encryption-keys` came out of it as `secrets-encryp-91c2e9`, hashed for
|
|
14
|
+
* nothing. A truncated name is not merely ugly: it is the name teardown must recompute exactly, and
|
|
15
|
+
* the name a human has to recognize in a dashboard listing that cannot be filtered.
|
|
16
|
+
*
|
|
17
|
+
* Every row below carries its source. `cloudflare` means the number is documented (limits page,
|
|
18
|
+
* OpenAPI schema, or wrangler's own constant, verified 2026-07-31); `pithy` means Cloudflare
|
|
19
|
+
* publishes no cap and this is our ceiling, chosen and stated as ours.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Where a limit comes from — Cloudflare's documentation, or Pithy's own judgment. */
|
|
23
|
+
export type LimitSource = "cloudflare" | "pithy";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to do with a name that will not fit.
|
|
27
|
+
*
|
|
28
|
+
* **`refuse`** where the name is a durable address: a Workflow (renaming orphans every running
|
|
29
|
+
* instance), a Worker script (renaming orphans the deployment and every `service` binding pointing
|
|
30
|
+
* at it), a Vectorize index (renaming orphans the vectors — they cannot be re-embedded for free).
|
|
31
|
+
* A build error naming the limit is strictly better than a silent rename.
|
|
32
|
+
*
|
|
33
|
+
* **`truncate`** where Pithy recomputes the name from the same inputs on every command — a D1
|
|
34
|
+
* database, a KV namespace, a bucket, a secret entry, a token. Truncation is deterministic and
|
|
35
|
+
* hash-disambiguated, so provision and teardown still agree, and a failed CI run is avoided.
|
|
36
|
+
*/
|
|
37
|
+
export type OverflowPolicy = "refuse" | "truncate";
|
|
38
|
+
|
|
39
|
+
/** One namespace's rule: how long a name may be, and what happens when it would be longer. */
|
|
40
|
+
export interface NamespaceLimit {
|
|
41
|
+
/** How an error message names this namespace, e.g. `an R2 bucket name`. */
|
|
42
|
+
readonly label: string;
|
|
43
|
+
/** The longest name the namespace accepts. Pithy composes ASCII only, so characters are bytes. */
|
|
44
|
+
readonly maxLength: number;
|
|
45
|
+
/** The shortest name the namespace accepts. Only R2 states one; everywhere else it is 1. */
|
|
46
|
+
readonly minLength: number;
|
|
47
|
+
/** What happens to a name that would exceed {@link maxLength}. */
|
|
48
|
+
readonly policy: OverflowPolicy;
|
|
49
|
+
/** Whether {@link maxLength} is Cloudflare's number or Pithy's. */
|
|
50
|
+
readonly source: LimitSource;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pithy's ceiling for a namespace Cloudflare does not cap — D1 database names, Secrets Store
|
|
55
|
+
* entries, API token labels.
|
|
56
|
+
*
|
|
57
|
+
* **This is our number, not Cloudflare's.** No published cap is not the same as no cap: a name is
|
|
58
|
+
* read by humans in unfilterable listings, echoed in CLI output, and pasted into wrangler configs,
|
|
59
|
+
* so it needs an end. 128 is twice the longest limit Cloudflare does publish (64) and roughly twice
|
|
60
|
+
* the longest name Pithy can compose today, which leaves room for a capability with a long tail
|
|
61
|
+
* without ever reaching for the truncator.
|
|
62
|
+
*/
|
|
63
|
+
export const MAX_PITHY_NAME = 128;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The rule per namespace. Read it, do not re-derive it — a limit enforced in two places eventually
|
|
67
|
+
* disagrees with itself, and the number an error quotes must be the number the check used.
|
|
68
|
+
*/
|
|
69
|
+
export const NAMESPACE_LIMITS = {
|
|
70
|
+
/**
|
|
71
|
+
* A Workflow name: 64 characters, `^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`. Documented on the Workflows
|
|
72
|
+
* limits page, `maxLength: 64` in the OpenAPI schema, and `MAX_WORKFLOW_NAME_LENGTH` in wrangler.
|
|
73
|
+
*/
|
|
74
|
+
workflow: { label: "a Workflow name", maxLength: 64, minLength: 1, policy: "refuse", source: "cloudflare" },
|
|
75
|
+
/**
|
|
76
|
+
* A Worker script name: 255 in general, **63 once workers.dev is on**, alphanumeric and dashes
|
|
77
|
+
* with no leading or trailing dash. Pithy holds the 63: it is the only number that stays true
|
|
78
|
+
* after an adopter enables a workers.dev subdomain, and a script cannot be renamed afterwards.
|
|
79
|
+
* Deliberately one *less* than the Workflow cap — they are different resources with different
|
|
80
|
+
* numbers, and collapsing them would loosen this one.
|
|
81
|
+
*/
|
|
82
|
+
worker: { label: "a Worker script name", maxLength: 63, minLength: 1, policy: "refuse", source: "cloudflare" },
|
|
83
|
+
/**
|
|
84
|
+
* An R2 bucket: 3–63 characters, lowercase letters, digits and hyphens, and it must **start and
|
|
85
|
+
* end alphanumeric**. The one namespace where 63 was always the right answer.
|
|
86
|
+
*/
|
|
87
|
+
r2: { label: "an R2 bucket name", maxLength: 63, minLength: 3, policy: "truncate", source: "cloudflare" },
|
|
88
|
+
/**
|
|
89
|
+
* A Vectorize index: 64 bytes, `^([a-z]+[a-z0-9_-]*[a-z0-9]+)$` — it must start with a letter,
|
|
90
|
+
* which the project rule already guarantees. Refused rather than truncated because a renamed
|
|
91
|
+
* index is an empty index until everything is re-embedded.
|
|
92
|
+
*/
|
|
93
|
+
vectorizeIndex: {
|
|
94
|
+
label: "a Vectorize index name",
|
|
95
|
+
maxLength: 64,
|
|
96
|
+
minLength: 2,
|
|
97
|
+
policy: "refuse",
|
|
98
|
+
source: "cloudflare",
|
|
99
|
+
},
|
|
100
|
+
/**
|
|
101
|
+
* A KV namespace title: 512 characters, no pattern at all — spaces and mixed case are accepted
|
|
102
|
+
* (`workers-kv_namespace_title.maxLength` in the OpenAPI schema). Pithy still composes a kebab
|
|
103
|
+
* title, for the same reason it composes every other name the same way; what changed is that it
|
|
104
|
+
* no longer hashes one at 63.
|
|
105
|
+
*/
|
|
106
|
+
kv: { label: "a KV namespace title", maxLength: 512, minLength: 1, policy: "truncate", source: "cloudflare" },
|
|
107
|
+
/**
|
|
108
|
+
* A D1 database name: `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` in the OpenAPI schema, and **no documented
|
|
109
|
+
* length cap** — the limits page has no row for it. So the ceiling here is {@link MAX_PITHY_NAME}.
|
|
110
|
+
*/
|
|
111
|
+
d1: { label: "a D1 database name", maxLength: MAX_PITHY_NAME, minLength: 1, policy: "truncate", source: "pithy" },
|
|
112
|
+
/**
|
|
113
|
+
* A Secrets Store entry name: "cannot contain spaces", wrangler enforces `/^[A-z0-9-_]+$/`, and
|
|
114
|
+
* **no documented length cap**. Ours, therefore — {@link MAX_PITHY_NAME}.
|
|
115
|
+
*/
|
|
116
|
+
secretEntry: {
|
|
117
|
+
label: "a Secrets Store entry name",
|
|
118
|
+
maxLength: MAX_PITHY_NAME,
|
|
119
|
+
minLength: 1,
|
|
120
|
+
policy: "truncate",
|
|
121
|
+
source: "pithy",
|
|
122
|
+
},
|
|
123
|
+
/**
|
|
124
|
+
* A Cloudflare API token name: a free-text label with **no documented cap**. Ours —
|
|
125
|
+
* {@link MAX_PITHY_NAME} — and it matters least of all, since nothing addresses a token by name.
|
|
126
|
+
*/
|
|
127
|
+
apiToken: {
|
|
128
|
+
label: "a Cloudflare API token name",
|
|
129
|
+
maxLength: MAX_PITHY_NAME,
|
|
130
|
+
minLength: 1,
|
|
131
|
+
policy: "truncate",
|
|
132
|
+
source: "pithy",
|
|
133
|
+
},
|
|
134
|
+
} as const satisfies Record<string, NamespaceLimit>;
|
|
135
|
+
|
|
136
|
+
/** Every namespace the naming facade can compose into. */
|
|
137
|
+
export type Namespace = keyof typeof NAMESPACE_LIMITS;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The longest `<capability>-<job>` tail any capability registry declares today. Read off the
|
|
141
|
+
* registries, not guessed: `media-audio-transcribe` and `media-video-transcribe` are both 22, ahead
|
|
142
|
+
* of `payments-reconcile` (18), `vector-reprocess` (16), `support-classify` (16), `email-schedule`
|
|
143
|
+
* (14), `secrets-rotate` (14), `storage-sweep` (13), `testers-daily` (13).
|
|
144
|
+
*
|
|
145
|
+
* {@link WORKFLOW_DERIVED_PROJECT_NAME} is derived against it, so the two move together — and
|
|
146
|
+
* `workflowScriptName` refuses a longer tail rather than letting a new capability silently
|
|
147
|
+
* invalidate the project-name cap every existing project was accepted under.
|
|
148
|
+
*/
|
|
149
|
+
export const MAX_CAPABILITY_JOB = "media-audio-transcribe".length;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Digits a feature's issue number may carry. Six — `f999999`, just under a million open issues in one
|
|
153
|
+
* repository, which nothing this toolkit will serve is going to exhaust.
|
|
154
|
+
*
|
|
155
|
+
* The count is not free: every digit reserved here costs one character of **every** adopter's project
|
|
156
|
+
* name, because {@link FEATURE_DERIVED_PROJECT_NAME} is computed against it and a project name cannot
|
|
157
|
+
* be changed once anything is provisioned. Seven digits would buy ten million issues nobody asked for
|
|
158
|
+
* and charge every project a character for them.
|
|
159
|
+
*/
|
|
160
|
+
export const MAX_ISSUE_DIGITS = 6;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The slug length a feature resource name keeps verbatim at the worst legal project name.
|
|
164
|
+
*
|
|
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
|
+
*/
|
|
169
|
+
export const MIN_LEGIBLE_SLUG = 12;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The binding length a feature resource name keeps verbatim at the worst legal project name.
|
|
173
|
+
*
|
|
174
|
+
* Also twelve — `MEDIA_BUCKET`, `SESSIONS`, `CACHE`, `ASSETS`, `DB`, `ROOMS` all fit. It is
|
|
175
|
+
* deliberately **not** the longest binding Pithy declares (`EMAIL_SUPPRESSIONS`, 18): reserving
|
|
176
|
+
* that would cost every adopter six characters of project name to protect a purely cosmetic
|
|
177
|
+
* property, since a longer binding truncates deterministically and stays unique through its hash,
|
|
178
|
+
* and teardown recomputes the name rather than matching a prefix.
|
|
179
|
+
*/
|
|
180
|
+
export const MIN_VERBATIM_BINDING = 12;
|
|
181
|
+
|
|
182
|
+
/** The longest feature resource kind — `d1`, `kv`, `r2` are all two characters. `feature.test.ts` pins it. */
|
|
183
|
+
export const MAX_FEATURE_KIND = 2;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What the longest Workflow name leaves a project.
|
|
187
|
+
*
|
|
188
|
+
* ```
|
|
189
|
+
* 64 a Workflow name (NAMESPACE_LIMITS.workflow)
|
|
190
|
+
* - 1 the hyphen before the environment
|
|
191
|
+
* - 7 the longest environment, `staging` (MAX_ENVIRONMENT_NAME)
|
|
192
|
+
* - 1 the hyphen before the tail
|
|
193
|
+
* - 22 the longest <capability>-<job>, `media-audio-transcribe`
|
|
194
|
+
* ----
|
|
195
|
+
* 33
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export const WORKFLOW_DERIVED_PROJECT_NAME =
|
|
199
|
+
NAMESPACE_LIMITS.workflow.maxLength - 1 - MAX_ENVIRONMENT_NAME - 1 - MAX_CAPABILITY_JOB;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>-<binding>-<kind>`,
|
|
203
|
+
* against R2's 63 (the strictest of the three kinds a feature provisions, and the same number a
|
|
204
|
+
* feature Worker script gets).
|
|
205
|
+
*
|
|
206
|
+
* ```
|
|
207
|
+
* 63 an R2 bucket name (NAMESPACE_LIMITS.r2)
|
|
208
|
+
* - 2 `-f`
|
|
209
|
+
* - 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)
|
|
212
|
+
* - 3 `-` + the kind, `d1` | `kv` | `r2`
|
|
213
|
+
* ----
|
|
214
|
+
* 26
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export const FEATURE_DERIVED_PROJECT_NAME =
|
|
218
|
+
NAMESPACE_LIMITS.r2.maxLength -
|
|
219
|
+
2 -
|
|
220
|
+
MAX_ISSUE_DIGITS -
|
|
221
|
+
(1 + MIN_LEGIBLE_SLUG) -
|
|
222
|
+
(1 + MIN_VERBATIM_BINDING) -
|
|
223
|
+
(1 + MAX_FEATURE_KIND);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE } from "./environment";
|
|
5
|
+
import {
|
|
6
|
+
type FeatureIdentity,
|
|
7
|
+
type FeatureResourceKind,
|
|
8
|
+
featureResourceName,
|
|
9
|
+
featureSecretEntryName,
|
|
10
|
+
featureWorkerName,
|
|
11
|
+
} from "./feature";
|
|
12
|
+
import { resourceNames } from "./resourceNames";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **What a provisioning run names its resources, and which `env.<name>` stanza it writes them into —
|
|
16
|
+
* as one object, never two arguments.**
|
|
17
|
+
*
|
|
18
|
+
* Provisioning was specified for ephemeral feature environments and parameterized by `--env` before it
|
|
19
|
+
* was generalized to named ones. That left the namer and the target stanza as independent inputs, and
|
|
20
|
+
* A feature's namer with a declared environment beside it was the reachable consequence: it composed
|
|
21
|
+
* `<project>-f<issue>-<slug>-db` and wrote it in as `staging`'s `DB`. Nothing refused it, the ids went
|
|
22
|
+
* into a checked-in `wrangler.jsonc`, and a remote migrate ran against them.
|
|
23
|
+
*
|
|
24
|
+
* A guard against that combination would be a list of forbidden pairs. This is the invariant instead:
|
|
25
|
+
* **a scope is chosen once, and it carries both halves.** There is no call path that takes an
|
|
26
|
+
* environment name and a naming scheme separately, so there is no pair left to get wrong.
|
|
27
|
+
*
|
|
28
|
+
* Two scopes exist, and they are the two kinds of environment a project has:
|
|
29
|
+
*
|
|
30
|
+
* - {@link environmentScope} — a **declared** environment (`staging`, `prod`, whatever the root
|
|
31
|
+
* `pithy.config.ts` lists). Names are `<project>-<env>-<thing>`, the rule every other namer in the
|
|
32
|
+
* kit follows, so the environment segment of a name is always the stanza the name lives in.
|
|
33
|
+
* - {@link featureScope} — one branch's **ephemeral** environment. Names are
|
|
34
|
+
* `<project>-f<issue>-<slug>-<thing>`, with no environment segment because a feature *is* an
|
|
35
|
+
* environment, and the `f<issue>` marker is what teardown recomputes and what keeps a feature's
|
|
36
|
+
* database from ever being mistaken for a deployed one's.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a secret's value differs per environment or is one value every environment binds.
|
|
41
|
+
*
|
|
42
|
+
* The same two words `@pithy-sh/secrets`' `SecretScope` uses, restated here as a literal union rather
|
|
43
|
+
* than imported: core carries the secret-registry *seam* and not the package, for the same reason
|
|
44
|
+
* `SecretRegistryEntrySeam` types `backend` as a `string`.
|
|
45
|
+
*/
|
|
46
|
+
export type SecretNameScope = "environment" | "global";
|
|
47
|
+
|
|
48
|
+
/** Where a provisioning run's resources are named, and where their ids are written. */
|
|
49
|
+
export interface ProvisionScope {
|
|
50
|
+
/** The `env.<stanza>` key in each Worker's config that this scope's ids are written into. */
|
|
51
|
+
readonly stanza: string;
|
|
52
|
+
/**
|
|
53
|
+
* **Are this scope's ids source, or a build artifact?**
|
|
54
|
+
*
|
|
55
|
+
* The same provisioning step produces both, and the difference is not cosmetic. A declared
|
|
56
|
+
* environment's ids are long-lived facts about the repository: they belong in the tracked
|
|
57
|
+
* `wrangler.jsonc`, under review, in a pull request a human reads. A feature's are facts about one
|
|
58
|
+
* job — the branch is deleted, the resources are destroyed, and the ids name nothing afterwards.
|
|
59
|
+
*
|
|
60
|
+
* Writing a feature's into the tracked file was correct as designed *in CI*, where the checkout is
|
|
61
|
+
* throwaway, and an expectation everywhere else: a developer's worktree carried a modified tracked
|
|
62
|
+
* file they never edited, with no note saying it must not be committed, and `git add -A` put ids for
|
|
63
|
+
* deleted resources onto `main`. So a build artifact goes somewhere untracked instead, and the
|
|
64
|
+
* question "which is this?" is answered here, once, by the same object that answers "what is it
|
|
65
|
+
* called?" and "which stanza does it go in?" — because those three answers have to agree.
|
|
66
|
+
*/
|
|
67
|
+
readonly source: boolean;
|
|
68
|
+
/** This scope's Cloudflare name for a provisionable binding. */
|
|
69
|
+
resource(binding: string, kind: FeatureResourceKind): string;
|
|
70
|
+
/** The script name a Worker deploys under in this scope. */
|
|
71
|
+
worker(worker: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* This scope's CF Secrets Store entry name for a declared secret.
|
|
74
|
+
*
|
|
75
|
+
* A `global` secret is the one name that does **not** take the scope's segment: it is a single
|
|
76
|
+
* account-level value every environment binds, so every scope resolves it to the project's
|
|
77
|
+
* `<project>-global-<secret>`. Scoping it would mint a second copy of a value defined as one.
|
|
78
|
+
*/
|
|
79
|
+
secretEntry(secret: string, secretScope: SecretNameScope): string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The wrangler binding array a resource kind's name is composed for. */
|
|
83
|
+
const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2"> = { d1: "d1", kv: "kv", r2: "r2" };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The one branch every scope shares: a `global` secret resolves to the project's single
|
|
87
|
+
* `<project>-global-<secret>` entry, and everything else to the scope's own name for it.
|
|
88
|
+
*
|
|
89
|
+
* Written once here rather than in each scope, because "global is not scoped" is a property of the
|
|
90
|
+
* secret and not of the environment asking — and two copies of it is how one of them would drift.
|
|
91
|
+
*/
|
|
92
|
+
function secretEntryName(project: string, secret: string, secretScope: SecretNameScope, scoped: () => string): string {
|
|
93
|
+
if (secretScope === GLOBAL_SCOPE) return resourceNames(project).global.secretEntry(secret);
|
|
94
|
+
return scoped();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A declared environment's scope — `staging`, `prod`, or whatever the root `pithy.config.ts` lists.
|
|
99
|
+
*
|
|
100
|
+
* The environment is validated here, once, before a single name exists: it is the middle segment of
|
|
101
|
+
* every name below, so an illegal one must fail before anything is created rather than at the fourth
|
|
102
|
+
* getter that happened to use it.
|
|
103
|
+
*
|
|
104
|
+
* **A Worker's script name is wrangler's own, `<script>-<env>`, deliberately.** It is the one name here
|
|
105
|
+
* that is not `<project>-<env>-<thing>`, because `wrangler deploy --env staging` has always appended the
|
|
106
|
+
* environment to the top-level `name` — so every project that ever deployed is already at that address.
|
|
107
|
+
* A Worker script is a `refuse`-policy namespace precisely because renaming one orphans the deployment
|
|
108
|
+
* and every `service` binding pointing at it; composing a "more correct" name here would do exactly
|
|
109
|
+
* that, silently, on the next deploy. The name is written out rather than left implicit so a service
|
|
110
|
+
* binding and a deploy agree on one string that is in the file.
|
|
111
|
+
*/
|
|
112
|
+
export function environmentScope(project: string, environment: string): ProvisionScope {
|
|
113
|
+
assertValidEnvironment(environment);
|
|
114
|
+
const names = resourceNames(project).env(environment);
|
|
115
|
+
return {
|
|
116
|
+
stanza: environment,
|
|
117
|
+
// Long-lived ids for an environment the project ships to: reviewed, committed, kept.
|
|
118
|
+
source: true,
|
|
119
|
+
resource: (binding, kind) => names[KIND_NAMER[kind]](binding),
|
|
120
|
+
worker: (worker) => `${worker}-${environment}`,
|
|
121
|
+
secretEntry: (secret, secretScope) =>
|
|
122
|
+
secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One feature's scope — the ephemeral environment a branch gets, named from the branch rather than from
|
|
128
|
+
* an environment.
|
|
129
|
+
*
|
|
130
|
+
* Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
|
|
131
|
+
* in a declared environment's stanza unexpressible rather than merely discouraged.
|
|
132
|
+
*/
|
|
133
|
+
export function featureScope(identity: FeatureIdentity): ProvisionScope {
|
|
134
|
+
return {
|
|
135
|
+
stanza: FEATURE_ENVIRONMENT,
|
|
136
|
+
// One job's ids, for resources `destroy` deletes. Never a tracked file.
|
|
137
|
+
source: false,
|
|
138
|
+
resource: (binding, kind) => featureResourceName(identity, binding, kind),
|
|
139
|
+
worker: (worker) => featureWorkerName(identity, worker),
|
|
140
|
+
secretEntry: (secret, secretScope) =>
|
|
141
|
+
secretEntryName(identity.project, secret, secretScope, () => featureSecretEntryName(identity, secret)),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError, ValidationError } from "../error/pithyError";
|
|
5
|
+
import { MAX_ENVIRONMENT_NAME } from "./environment";
|
|
6
|
+
import {
|
|
7
|
+
FEATURE_DERIVED_PROJECT_NAME,
|
|
8
|
+
MAX_CAPABILITY_JOB,
|
|
9
|
+
NAMESPACE_LIMITS,
|
|
10
|
+
WORKFLOW_DERIVED_PROJECT_NAME,
|
|
11
|
+
} from "./limits";
|
|
12
|
+
import { NAME_SEGMENT } from "./segment";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One naming rule, in one module: **`<project>-<env>-<thing>`**, kebab-case.
|
|
16
|
+
*
|
|
17
|
+
* Every Cloudflare namespace Pithy writes into is flat and account-wide — D1, KV, R2, Worker
|
|
18
|
+
* scripts, Workflows, the single Secrets Store, the API-token list. None of them can be
|
|
19
|
+
* partitioned, so the *name* is the partition, and the project segment is the only thing keeping
|
|
20
|
+
* two Pithy projects in one account from adopting or overwriting each other's resources.
|
|
21
|
+
*
|
|
22
|
+
* **Project first** because that is the ownership boundary everything turns on: teardown, the
|
|
23
|
+
* token listing's prefix filter, and the reaper's reservation all key on it. **Environment second**
|
|
24
|
+
* because these end up in listings nobody can filter — `wrangler d1 list`, the account dashboard,
|
|
25
|
+
* the Secrets Store — and sorting by name should group a project's resources and then that
|
|
26
|
+
* project's environments, so everything belonging to prod sits together. Environment last
|
|
27
|
+
* would scatter prod through the listing, interleaved with staging, which is exactly when
|
|
28
|
+
* someone acts on the wrong one.
|
|
29
|
+
*
|
|
30
|
+
* A `global`-scoped thing puts the literal `global` in the environment slot rather than omitting
|
|
31
|
+
* it, so the scheme has no exception to remember.
|
|
32
|
+
*
|
|
33
|
+
* This module is in `core` because it has no dependencies and every layer needs it — capabilities,
|
|
34
|
+
* the CLI, and the Workers runtime alike. It therefore uses **no `node:` builtin**: core is bundled
|
|
35
|
+
* into the adopter's Worker, where `node:crypto` is not free.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The default budget of the un-namespaced {@link resourceName} composer: the strictest cap of any
|
|
40
|
+
* namespace Pithy writes into, which is R2's 63.
|
|
41
|
+
*
|
|
42
|
+
* A caller that knows its namespace does **not** use this — it passes that namespace's own limit
|
|
43
|
+
* from `./limits`, or, better, goes through the facade in `./resourceNames`, which cannot pick the
|
|
44
|
+
* wrong budget. This number is the conservative answer for a call site that has no namespace at all.
|
|
45
|
+
*/
|
|
46
|
+
export const MAX_RESOURCE_NAME = NAMESPACE_LIMITS.r2.maxLength;
|
|
47
|
+
|
|
48
|
+
/** Below this the trailing segment is not worth keeping legible, so a name this tight is refused instead. */
|
|
49
|
+
const MIN_THING_BUDGET = 1;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The cap on a project name — **derived, not chosen, and derived from two shapes rather than one**.
|
|
53
|
+
*
|
|
54
|
+
* A project name has to survive every name it will ever be the head of, so it gets the *smaller* of
|
|
55
|
+
* the two worst cases (`./limits` carries both derivations with their arithmetic):
|
|
56
|
+
*
|
|
57
|
+
* - {@link WORKFLOW_DERIVED_PROJECT_NAME} — `<project>-<env>-<capability>-<job>` against a Workflow's
|
|
58
|
+
* 64 characters, after the longest environment and the longest `<capability>-<job>`.
|
|
59
|
+
* - {@link FEATURE_DERIVED_PROJECT_NAME} — `<project>-f<issue>-<slug>-<binding>-<kind>` against R2's
|
|
60
|
+
* 63, with room for the reserved issue digits, a legible slug, and a binding kept whole.
|
|
61
|
+
*
|
|
62
|
+
* Neither number is repeated here. Each is a computed constant with its arithmetic in `./limits`, and a
|
|
63
|
+
* copy in this sentence is a copy that goes stale the first time a term of the derivation moves.
|
|
64
|
+
*
|
|
65
|
+
* The feature shape is the binding one, and it used to be invisible: the cap was derived from the
|
|
66
|
+
* Workflow alone, so a project inside it could still compose a feature name whose slug was hashed
|
|
67
|
+
* away to nothing. Taking the minimum is the only version that is true of both.
|
|
68
|
+
*
|
|
69
|
+
* **Why the cap belongs on the project name rather than on each composed name:** without it the
|
|
70
|
+
* refusal lands at the first `pithy <capability> provision`, by which time `pithy add` has created
|
|
71
|
+
* real R2 buckets and D1 databases under a name that fits *their* budget. The project is
|
|
72
|
+
* half-provisioned, and the only documented remedy — renaming — orphans everything already made.
|
|
73
|
+
* Capped here, `pithy init` and `requireProjectName` refuse it before anything is written.
|
|
74
|
+
*/
|
|
75
|
+
export const MAX_PROJECT_NAME = Math.min(WORKFLOW_DERIVED_PROJECT_NAME, FEATURE_DERIVED_PROJECT_NAME);
|
|
76
|
+
|
|
77
|
+
/** Lowercase, collapse any run of non-`[a-z0-9]` to one `-`, and trim leading/trailing `-`. */
|
|
78
|
+
export function kebab(input: string): string {
|
|
79
|
+
return input
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
82
|
+
.replace(/^-+|-+$/g, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A stable 6-hex-character digest, FNV-1a. Pure arithmetic rather than `node:crypto` so this module
|
|
87
|
+
* runs unchanged in a Worker. It is a *disambiguator for truncated names*, never a security
|
|
88
|
+
* primitive — collision resistance beyond "two different capability names look different" is not
|
|
89
|
+
* claimed and must not be relied on.
|
|
90
|
+
*/
|
|
91
|
+
export function hash6(input: string): string {
|
|
92
|
+
let h = 0x811c9dc5;
|
|
93
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
94
|
+
h ^= input.charCodeAt(i);
|
|
95
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
96
|
+
}
|
|
97
|
+
return h.toString(16).padStart(8, "0").slice(0, 6);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fit a kebab segment into `budget` characters: verbatim when it fits, otherwise a truncated head
|
|
102
|
+
* plus a short stable hash so it stays distinct and within budget. Deterministic in `segment`
|
|
103
|
+
* alone, so two commands computing the same name agree without storing it. The hash shrinks for
|
|
104
|
+
* tiny budgets, and the result never exceeds `budget` nor trails a hyphen.
|
|
105
|
+
*/
|
|
106
|
+
export function fitSegment(segment: string, budget: number): string {
|
|
107
|
+
if (budget <= 0) return "";
|
|
108
|
+
if (segment.length <= budget) return segment;
|
|
109
|
+
if (budget < 4) return hash6(segment).slice(0, budget);
|
|
110
|
+
const hashLen = Math.min(6, Math.max(2, budget - 2));
|
|
111
|
+
const headLen = Math.max(1, budget - hashLen - 1);
|
|
112
|
+
return `${segment.slice(0, headLen).replace(/-+$/, "")}-${hash6(segment).slice(0, hashLen)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The parts of a provisioned name. Every field is required — an absent segment is a naming bug, not a default. */
|
|
116
|
+
export interface ResourceNameParts {
|
|
117
|
+
/**
|
|
118
|
+
* The project name — the root `pithy.config.ts` `name`, resolved by `requireProjectName` and
|
|
119
|
+
* **never guessed**. This is the ownership boundary; a wrong value here writes into another
|
|
120
|
+
* project's resources.
|
|
121
|
+
*/
|
|
122
|
+
project: string;
|
|
123
|
+
/** The environment (`dev`, `staging`, `prod`), or the literal `global` for a value shared across all of them. */
|
|
124
|
+
env: string;
|
|
125
|
+
/** What this resource is — a capability, a binding, a secret, a token profile. The only segment ever truncated. */
|
|
126
|
+
thing: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compose `<project>-<env>-<thing>`, kebab-cased and fitted to `budget`.
|
|
131
|
+
*
|
|
132
|
+
* **Only `thing` is ever truncated.** The project and environment segments are taken verbatim,
|
|
133
|
+
* because a hashed project segment would let two long project names share a prefix — and the token
|
|
134
|
+
* listing filters on exactly that prefix, so a collision there means one project enumerating and
|
|
135
|
+
* acting on another's credentials. If project and environment alone cannot fit, that is refused
|
|
136
|
+
* with the limit named rather than quietly hashed into an ambiguous name.
|
|
137
|
+
*
|
|
138
|
+
* `budget` defaults to {@link MAX_RESOURCE_NAME}; a namespace with its own limit passes it in.
|
|
139
|
+
*
|
|
140
|
+
* The project segment is held to {@link isValidProjectName} — the same rule the Worker and Workflow
|
|
141
|
+
* namers apply — so a project this accepts is one every namespace accepts. It is a backstop, not the
|
|
142
|
+
* gate: the real refusal belongs at `pithy init` and `requireProjectName`, before anything is written.
|
|
143
|
+
*/
|
|
144
|
+
export function resourceName(parts: ResourceNameParts, budget: number = MAX_RESOURCE_NAME): string {
|
|
145
|
+
const project = kebab(parts.project);
|
|
146
|
+
const env = kebab(parts.env);
|
|
147
|
+
const thing = kebab(parts.thing);
|
|
148
|
+
|
|
149
|
+
for (const [role, value] of [
|
|
150
|
+
["project", project],
|
|
151
|
+
["environment", env],
|
|
152
|
+
["thing", thing],
|
|
153
|
+
] as const) {
|
|
154
|
+
if (!value) {
|
|
155
|
+
throw new ValidationError({
|
|
156
|
+
message: `A resource name needs a ${role}.`,
|
|
157
|
+
action: role === "project" ? "Set `name` in pithy.config.ts." : undefined,
|
|
158
|
+
detail: `resourceName received an empty ${role} segment (raw: ${JSON.stringify(parts)})`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
assertValidProjectName(parts.project);
|
|
164
|
+
|
|
165
|
+
const head = `${project}-${env}`;
|
|
166
|
+
const thingBudget = budget - head.length - 1;
|
|
167
|
+
if (thingBudget < MIN_THING_BUDGET) {
|
|
168
|
+
throw new InternalError({
|
|
169
|
+
message: `The project and environment names alone exceed the ${budget}-character limit for this resource.`,
|
|
170
|
+
action: "Shorten `name` in pithy.config.ts.",
|
|
171
|
+
detail: `"${head}" leaves ${thingBudget} characters for "${thing}"`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return `${head}-${fitSegment(thing, thingBudget)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Could every Cloudflare name this project generates be created? The one rule, in one place, so no
|
|
180
|
+
* namespace re-derives a looser version of it.
|
|
181
|
+
*
|
|
182
|
+
* **Read after kebabbing**, for the same reason {@link isReservedProjectName} is: the raw string never
|
|
183
|
+
* reaches an account, only what the composer made of it. `Acme Corp` is legal (it becomes `acme-corp`);
|
|
184
|
+
* `2026 Launch` is not, and neither answer can be read off the raw characters.
|
|
185
|
+
*
|
|
186
|
+
* **Two dimensions, one answer: charset and length.** Both have the same failure mode and so must have
|
|
187
|
+
* the same home — {@link NAME_SEGMENT} for the charset, {@link MAX_PROJECT_NAME} for the number. The
|
|
188
|
+
* charset is the shared segment rule rather than a project-specific one: a project name is the head of
|
|
189
|
+
* every composed name, so it is held to exactly what every other segment is held to.
|
|
190
|
+
*
|
|
191
|
+
* The rule matters at the *source* — `pithy init` and `requireProjectName` — because the namespaces
|
|
192
|
+
* disagree about it. A digit-leading project composes a legal D1 database name, a legal KV title, and a
|
|
193
|
+
* legal bucket name, so `pithy add` provisions all three for real; only the first host-worker deploy
|
|
194
|
+
* refuses it, by which point the documented remedy — rename the project — orphans everything already
|
|
195
|
+
* created. A 33-character project fails exactly the same way, one deploy later. One rule, enforced
|
|
196
|
+
* before anything is written, is the only version of this that is safe.
|
|
197
|
+
*/
|
|
198
|
+
export function isValidProjectName(name: string): boolean {
|
|
199
|
+
const normalized = kebab(name);
|
|
200
|
+
return normalized.length <= MAX_PROJECT_NAME && NAME_SEGMENT.test(normalized);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Refuse a project name no Cloudflare namespace could carry, as a **`ValidationError`**: a project name
|
|
205
|
+
* is something a human typed, so it is a 400 with an action, never an internal 500 blamed on the toolkit.
|
|
206
|
+
*
|
|
207
|
+
* Length and charset are one rule with two sentences, because they are two different mistakes: a name
|
|
208
|
+
* that is merely long is otherwise perfect, and "use lowercase letters" would be no help at all.
|
|
209
|
+
*/
|
|
210
|
+
export function assertValidProjectName(name: string): void {
|
|
211
|
+
if (isValidProjectName(name)) return;
|
|
212
|
+
const normalized = kebab(name);
|
|
213
|
+
if (normalized.length > MAX_PROJECT_NAME && NAME_SEGMENT.test(normalized)) {
|
|
214
|
+
throw new ValidationError({
|
|
215
|
+
message: `"${name}" is ${normalized.length} characters. A project name stops at ${MAX_PROJECT_NAME}.`,
|
|
216
|
+
action: `Shorten \`name\` in pithy.config.ts to ${MAX_PROJECT_NAME} characters or fewer.`,
|
|
217
|
+
detail: `${MAX_PROJECT_NAME} is the smaller of what a 64-character Workflow name leaves after a ${MAX_ENVIRONMENT_NAME}-character environment and a ${MAX_CAPABILITY_JOB}-character <capability>-<job> (${WORKFLOW_DERIVED_PROJECT_NAME}), and what a 63-character feature resource name leaves after a six-digit issue, a legible slug, and a binding (${FEATURE_DERIVED_PROJECT_NAME}).`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
throw new ValidationError({
|
|
221
|
+
message: `"${name}" can't be a project name.`,
|
|
222
|
+
action: "Use lowercase letters, digits, and single hyphens, starting with a letter.",
|
|
223
|
+
detail: `"${name}" kebabs to "${normalized}", which does not match ${NAME_SEGMENT.source}.`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* **`pithy-int-` is reserved for Pithy's own live integration tests, on any account.**
|
|
229
|
+
*
|
|
230
|
+
* Every Cloudflare name a live test creates begins with it, no real project's name may, and the
|
|
231
|
+
* test-debris reaper deletes nothing without it. The reservation is what makes automatic reaping safe
|
|
232
|
+
* in both directions: without it the reaper is guessing — either it fails to recognize the names a
|
|
233
|
+
* suite invented, and debris accumulates silently on a real account, or it matches loosely enough to
|
|
234
|
+
* one day delete a customer's database. With it, membership is decidable from the name alone.
|
|
235
|
+
*
|
|
236
|
+
* It lives here, next to the composer, because both halves of the reservation are naming facts.
|
|
237
|
+
* `<project>-<env>-<thing>` puts the project first and takes it **verbatim**, so the prefix of every
|
|
238
|
+
* generated name is decided by the project name and nothing else — which is what lets one guard, on
|
|
239
|
+
* `pithy init`, keep the two sets from ever overlapping. See {@link isReservedProjectName}.
|
|
240
|
+
*
|
|
241
|
+
* This is deliberately narrower than the product's own `pithy` prefix. A project may legitimately be
|
|
242
|
+
* named `pithy-app`, and everything it provisions is real; `pithy-int-…` is debris by definition.
|
|
243
|
+
*/
|
|
244
|
+
export const RESERVED_TEST_PREFIX = "pithy-int-";
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Would a project of this name generate resource names inside the reservation? The one rule
|
|
248
|
+
* `pithy init` enforces, stated once so no call site re-derives it.
|
|
249
|
+
*
|
|
250
|
+
* Two traps make the obvious `name.startsWith(RESERVED_TEST_PREFIX)` wrong, and both are reachable
|
|
251
|
+
* with a name a human would type.
|
|
252
|
+
*
|
|
253
|
+
* **Kebabbing.** The composer kebabs the project before using it, so the raw string is not what
|
|
254
|
+
* Cloudflare sees. `"Pithy Int Ruse"` is not prefixed by `pithy-int-`, and it normalizes straight into
|
|
255
|
+
* the reservation.
|
|
256
|
+
*
|
|
257
|
+
* **The trailing hyphen.** The composer appends one after the project. A project named exactly
|
|
258
|
+
* `pithy-int` is not prefixed by `pithy-int-` either, and it generates `pithy-int-dev-db` — inside the
|
|
259
|
+
* namespace the reaper deletes.
|
|
260
|
+
*
|
|
261
|
+
* Testing `` `${kebab(name)}-` `` against the prefix closes both: it compares the form the composer
|
|
262
|
+
* actually uses, including the separator it actually adds.
|
|
263
|
+
*/
|
|
264
|
+
export function isReservedProjectName(name: string): boolean {
|
|
265
|
+
return `${kebab(name)}-`.startsWith(RESERVED_TEST_PREFIX);
|
|
266
|
+
}
|