@pithy-sh/core 0.6.2 → 0.7.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 (56) hide show
  1. package/dist/capability/capability.d.ts.map +1 -1
  2. package/dist/capability/capability.js +2 -0
  3. package/dist/capability/settings.d.ts +15 -2
  4. package/dist/capability/settings.d.ts.map +1 -1
  5. package/dist/data/databases.d.ts +11 -0
  6. package/dist/data/databases.d.ts.map +1 -1
  7. package/dist/migrations/registry.d.ts +6 -0
  8. package/dist/migrations/registry.d.ts.map +1 -1
  9. package/dist/migrations/registry.js +2 -0
  10. package/dist/migrations/retained.d.ts +101 -0
  11. package/dist/migrations/retained.d.ts.map +1 -0
  12. package/dist/migrations/retained.js +243 -0
  13. package/dist/migrations/retainedDeclaration.d.ts +15 -0
  14. package/dist/migrations/retainedDeclaration.d.ts.map +1 -0
  15. package/dist/migrations/retainedDeclaration.js +49 -0
  16. package/dist/migrations/runner.d.ts +44 -7
  17. package/dist/migrations/runner.d.ts.map +1 -1
  18. package/dist/migrations/runner.js +41 -15
  19. package/dist/naming/environment.d.ts +12 -0
  20. package/dist/naming/environment.d.ts.map +1 -1
  21. package/dist/naming/environment.js +38 -1
  22. package/dist/naming/feature.d.ts +7 -1
  23. package/dist/naming/feature.d.ts.map +1 -1
  24. package/dist/naming/feature.js +10 -3
  25. package/dist/naming/provisionScope.d.ts +65 -1
  26. package/dist/naming/provisionScope.d.ts.map +1 -1
  27. package/dist/naming/provisionScope.js +62 -5
  28. package/dist/naming/resourceNames.d.ts +5 -2
  29. package/dist/naming/resourceNames.d.ts.map +1 -1
  30. package/dist/naming/resourceNames.js +1 -1
  31. package/dist/text/comments.d.ts +14 -0
  32. package/dist/text/comments.d.ts.map +1 -1
  33. package/dist/text/comments.js +28 -2
  34. package/dist/version.generated.d.ts +1 -1
  35. package/dist/version.generated.js +1 -1
  36. package/dist/workflow/host.d.ts +8 -0
  37. package/dist/workflow/host.d.ts.map +1 -1
  38. package/dist/workflow/host.js +5 -2
  39. package/dist/workflow/hostEnv.d.ts +1 -1
  40. package/dist/workflow/hostEnv.js +1 -1
  41. package/package.json +1 -1
  42. package/src/capability/capability.ts +6 -0
  43. package/src/capability/settings.ts +15 -2
  44. package/src/data/databases.ts +11 -0
  45. package/src/migrations/registry.ts +10 -0
  46. package/src/migrations/retained.ts +319 -0
  47. package/src/migrations/retainedDeclaration.ts +56 -0
  48. package/src/migrations/runner.ts +89 -15
  49. package/src/naming/environment.ts +43 -0
  50. package/src/naming/feature.ts +10 -3
  51. package/src/naming/provisionScope.ts +98 -5
  52. package/src/naming/resourceNames.ts +6 -3
  53. package/src/text/comments.ts +31 -1
  54. package/src/version.generated.ts +1 -1
  55. package/src/workflow/host.ts +14 -4
  56. package/src/workflow/hostEnv.ts +1 -1
@@ -8,6 +8,7 @@ import { causeMessage } from "../error/cause";
8
8
  import { InternalError } from "../error/pithyError";
9
9
  import { batchedProvider } from "./batch";
10
10
  import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookkeeping";
11
+ import { guardRetained, isDownRefusal, RetainedBudget } from "./retained";
11
12
 
12
13
  /**
13
14
  * The per-database migration runner. The registry yields one `MigrationProvider` per database
@@ -49,12 +50,18 @@ import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookke
49
50
  * capability arrives with its whole set. Refusing the add with an actionable error was the
50
51
  * alternative; it explains the corner instead of removing it.
51
52
  */
52
- function migrator(database: D1Database, provider: MigrationProvider): Migrator {
53
+ function migrator(
54
+ database: D1Database,
55
+ provider: MigrationProvider,
56
+ target: MigrationTarget | undefined,
57
+ consent: RetainedConsent | undefined,
58
+ ): Migrator {
53
59
  return new Migrator({
54
60
  db: migrationKysely(database),
55
61
  // Each migration body applies in one `d1.batch()`; the ledger row stays on the ordinary path,
56
- // so nothing batches across a migration boundary. See `./batch`.
57
- provider: batchedProvider(provider, database),
62
+ // so nothing batches across a migration boundary. See `./batch`. Every `down` is guarded before it
63
+ // is batched: no reversal runs while a retained table in this database holds rows (#588).
64
+ provider: batchedProvider(guardRetained(provider, database, guardOptions(target, consent)), database),
58
65
  migrationTableName: MIGRATION_TABLE,
59
66
  migrationLockTableName: MIGRATION_LOCK_TABLE,
60
67
  allowUnorderedMigrations: true,
@@ -76,13 +83,35 @@ export interface MigrationTarget {
76
83
  database: string;
77
84
  }
78
85
 
86
+ /**
87
+ * What a caller that reverses migrations says about retained rows (#588) — see `./retained`.
88
+ *
89
+ * Optional, and absent is the safe answer: a caller that says nothing has counted nothing, so any `down`
90
+ * against a database holding retained rows is refused.
91
+ */
92
+ export interface RetainedConsent {
93
+ /**
94
+ * The retained rows the caller agreed to destroy. Pass one budget to every database in a run, so one
95
+ * agreement is spent once; a caller reversing a single database builds its own. Absent means none.
96
+ */
97
+ budget?: RetainedBudget;
98
+ }
99
+
100
+ /** The guard's options: the binding its refusal names, and the budget the caller agreed to. */
101
+ function guardOptions(
102
+ target: MigrationTarget | undefined,
103
+ consent: RetainedConsent | undefined,
104
+ ): { binding: string; budget: RetainedBudget } {
105
+ return { binding: target?.binding ?? "this database", budget: consent?.budget ?? new RetainedBudget(undefined) };
106
+ }
107
+
79
108
  /** Run every pending migration to latest. An empty provider resolves to `[]`. */
80
109
  export async function runMigrations(
81
110
  database: D1Database,
82
111
  provider: MigrationProvider,
83
112
  target?: MigrationTarget,
84
113
  ): Promise<MigrationResult[]> {
85
- const { error, results } = await migrator(database, provider).migrateToLatest();
114
+ const { error, results } = await migrator(database, provider, target, undefined).migrateToLatest();
86
115
  return settle("run", error, results, target);
87
116
  }
88
117
 
@@ -123,13 +152,18 @@ export async function readMigrationLedger(database: D1Database, provider: Migrat
123
152
  };
124
153
  }
125
154
 
126
- /** Step the latest applied migration back — one step, the `pithy migrate --rollback` seam. */
155
+ /**
156
+ * Step this database's latest applied migration back — one step, per database. `pithy migrate --rollback`
157
+ * calls it once for **every** database in its fan-out. Refused while a retained table here holds rows the
158
+ * caller has not counted in `consent` (#588).
159
+ */
127
160
  export async function rollbackMigration(
128
161
  database: D1Database,
129
162
  provider: MigrationProvider,
130
163
  target?: MigrationTarget,
164
+ consent?: RetainedConsent,
131
165
  ): Promise<MigrationResult[]> {
132
- const { error, results } = await migrator(database, provider).migrateDown();
166
+ const { error, results } = await migrator(database, provider, target, consent).migrateDown();
133
167
  return settle("rollback", error, results, target);
134
168
  }
135
169
 
@@ -139,14 +173,16 @@ export async function rollbackMigration(
139
173
  * `up` reapplies from empty. The seam behind `pithy seed --redo`'s destructive rebuild: because the
140
174
  * schema comes back empty, the ordinary non-destructive seed writes (`INSERT OR IGNORE`, KV
141
175
  * skip-if-exists) simply work afterward — there is no per-row identity problem to solve. An empty
142
- * ledger rolls back nothing; an empty provider reapplies nothing.
176
+ * ledger rolls back nothing; an empty provider reapplies nothing. Refused, before the first `down`, while a
177
+ * retained table here holds rows the caller has not counted in `consent` (#588).
143
178
  */
144
179
  export async function resetMigrations(
145
180
  database: D1Database,
146
181
  provider: MigrationProvider,
147
182
  target?: MigrationTarget,
183
+ consent?: RetainedConsent,
148
184
  ): Promise<MigrationResult[]> {
149
- const runner = migrator(database, provider);
185
+ const runner = migrator(database, provider, target, consent);
150
186
  const down = await runner.migrateTo(NO_MIGRATIONS);
151
187
  const downResults = settle("resetDown", down.error, down.results, target);
152
188
  const up = await runner.migrateToLatest();
@@ -169,26 +205,57 @@ async function appliedMigrationNames(db: Kysely<unknown>): Promise<Set<string>>
169
205
  }
170
206
 
171
207
  /**
172
- * Surgically drop **one capability's** migrations: run each of the provider's `down` functions in
208
+ * What a drop reverses, and the database it reverses it in.
209
+ *
210
+ * Two providers because they answer two questions. `reverse` is the part being removed — one capability's
211
+ * migrations. `database` is every migration composed into that D1, and it is what the retained guard counts:
212
+ * a table is retained in a *database*, and the capability being dropped is the one least likely to declare
213
+ * the vault it shares a database with (#588). Handed only the part, the guard counted what the part declared
214
+ * — nothing — while the vault beside it held rows.
215
+ */
216
+ export interface DropSelection {
217
+ /** Every migration composed into this database — the set whose retained declarations are counted. */
218
+ database: MigrationProvider;
219
+ /** The migrations to reverse: a part of `database`, by composed name. */
220
+ reverse: MigrationProvider;
221
+ }
222
+
223
+ /**
224
+ * Surgically drop **one capability's** migrations: run each of `drop.reverse`'s `down` functions in
173
225
  * reverse order and delete only those ledger rows, leaving every other capability's tables and
174
226
  * bookkeeping untouched. The seam behind `pithy remove --drop`. Kysely's stepwise `Migrator` refuses a
175
227
  * provider that doesn't span the whole ledger (it reads a foreign row as corrupt state), so a
176
228
  * per-capability drop can't go through it — this reverses the capability's own migrations directly.
177
- * Only migrations recorded in the ledger are reversed; an absent ledger drops nothing.
229
+ * Only migrations recorded in the ledger are reversed; an absent ledger drops nothing. Refused, before the
230
+ * first `down`, while a retained table **anywhere in `drop.database`** holds rows the caller has not counted
231
+ * in `consent` (#588). A name `drop.reverse` carries that `drop.database` does not is an internal fault: the
232
+ * guard would not have counted the database the `down` runs in.
178
233
  */
179
234
  export async function dropMigrations(
180
235
  database: D1Database,
181
- provider: MigrationProvider,
236
+ drop: DropSelection,
182
237
  target?: MigrationTarget,
238
+ consent?: RetainedConsent,
183
239
  ): Promise<MigrationResult[]> {
184
240
  const db = migrationKysely(database);
185
- // Batched here too: `down` pays the same per-statement cost `up` does, and a drop is all DDL.
186
- const migrations = await batchedProvider(provider, database).getMigrations();
241
+ // Batched here too: `down` pays the same per-statement cost `up` does, and a drop is all DDL. Guarded
242
+ // first, exactly as the `Migrator` path is (#588) — over the whole database's set, so the count is too.
243
+ const guarded = guardRetained(drop.database, database, guardOptions(target, consent));
244
+ const migrations = await batchedProvider(guarded, database).getMigrations();
245
+ const reversing = Object.keys(await drop.reverse.getMigrations());
246
+ const stray = reversing.filter((name) => !(name in migrations));
247
+ if (stray.length > 0) {
248
+ throw new InternalError({
249
+ message: `Couldn't drop ${stray.map((name) => `"${name}"`).join(", ")}${on(target)}. The database's migrations do not carry it.`,
250
+ detail: `${where(target)} A drop counts retained rows over the database's whole set, so every migration it reverses must be in it.`,
251
+ action: "Build the drop from the database's composed migrations. Nothing was dropped.",
252
+ });
253
+ }
187
254
  const applied = await appliedMigrationNames(db);
188
255
 
189
256
  const results: MigrationResult[] = [];
190
257
  // Reverse application order: drop the newest of the capability's migrations first.
191
- for (const name of Object.keys(migrations).sort().reverse()) {
258
+ for (const name of reversing.sort().reverse()) {
192
259
  if (!applied.has(name)) continue;
193
260
  // No `down` — the migration can't be reversed, so leave both its table and its ledger row in place
194
261
  // (deleting the row would desync the ledger from the schema). Every Pithy migration ships a `down`.
@@ -199,6 +266,8 @@ export async function dropMigrations(
199
266
  await sql`delete from ${sql.table(MIGRATION_TABLE)} where name = ${name}`.execute(db);
200
267
  results.push({ migrationName: name, direction: "Down", status: "Success" });
201
268
  } catch (error) {
269
+ // A refusal is not a failed drop: nothing is broken, and "fix the migration's down" would be a lie.
270
+ if (isDownRefusal(error)) throw error;
202
271
  const dropped = results.map((result) => `"${result.migrationName}"`);
203
272
  throw new InternalError(
204
273
  {
@@ -223,7 +292,9 @@ const VOICE = {
223
292
  rollback: {
224
293
  failed: (key: string) => `Couldn't roll back "${key}"`,
225
294
  fallback: "The rollback failed",
226
- action: "Fix the migration. Run pithy migrate --rollback again.",
295
+ // Never "run --rollback again": a rollback steps back every database, so a second one after a partial
296
+ // failure reverses a second migration in each database that already moved (#588).
297
+ action: "Fix the migration's down. Run pithy doctor to see where each database stands.",
227
298
  },
228
299
  resetDown: {
229
300
  failed: (key: string) => `Couldn't roll back "${key}" during reset`,
@@ -273,6 +344,9 @@ function settle(
273
344
  target?: MigrationTarget,
274
345
  ): MigrationResult[] {
275
346
  if (error !== undefined) {
347
+ // A guard refused before a `down` ran. It already names what it protected and the way past it, and
348
+ // nothing about it is a migration to fix — so it reaches the operator as itself (#588).
349
+ if (isDownRefusal(error)) throw error;
276
350
  const voice = VOICE[verb];
277
351
  const failed = results?.find((result) => result.status === "Error");
278
352
  const applied = results?.filter((result) => result.status === "Success").map((result) => result.migrationName);
@@ -52,6 +52,41 @@ export const GLOBAL_SCOPE = "global";
52
52
  */
53
53
  export const FEATURE_ENVIRONMENT = "feature";
54
54
 
55
+ /**
56
+ * **The segment a feature takes where an environment sits** — `f<issue>`, as in `<project>-f69-demo-db-d1`.
57
+ *
58
+ * Defined here, beside the environment rule, because the two share a slot in every composed name and the
59
+ * environment rule has to refuse this shape: `feature.ts` composes a feature's head with it, and
60
+ * {@link isValidEnvironment} refuses an environment that starts with one. A marker composed in one module and
61
+ * recognized in another would be two statements of one shape, and the next person to change one would not
62
+ * find the other.
63
+ */
64
+ export function featureMarker(issue: string): string {
65
+ return `f${issue}`;
66
+ }
67
+
68
+ /** Every string {@link featureMarker} can compose: an `f` and one or more digits, as a whole segment. */
69
+ const FEATURE_MARKER = /^f[0-9]+$/;
70
+
71
+ /** Is this whole hyphen-delimited segment a feature's marker? `f1` and `f01` are; `f1a` and `feature` are not. */
72
+ export function isFeatureMarker(segment: string): boolean {
73
+ return FEATURE_MARKER.test(segment);
74
+ }
75
+
76
+ /**
77
+ * Does this environment start where a feature's name does?
78
+ *
79
+ * **Exactly the environments that can collide with a feature (#587).** A declared environment composes
80
+ * `<project>-<env>-<thing>`, a feature `<project>-f<issue>-<slug>-<thing>`, and a feature Worker's name has no
81
+ * kind suffix to tell the two apart. After the shared `<project>-`, the two strings can only be equal when the
82
+ * environment's first segment is the feature's marker — `f1-demo` meets feature 1-demo's Worker `api`, and `f1`
83
+ * meets it too, through a Worker called `demo-api`. Any other first segment differs from every marker, so no
84
+ * name composed under it can be a feature's. So this is the whole rule, not one case of it.
85
+ */
86
+ function startsWithFeatureMarker(name: string): boolean {
87
+ return isFeatureMarker(name.split("-")[0] ?? "");
88
+ }
89
+
55
90
  /**
56
91
  * The longest an environment name may be — **the longest canonical one, `staging`**.
57
92
  *
@@ -77,6 +112,7 @@ export const MAX_ENVIRONMENT_NAME = Math.max(...ENVIRONMENTS.map((environment) =
77
112
  */
78
113
  export function isValidEnvironment(name: string): boolean {
79
114
  if (name === GLOBAL_SCOPE) return false;
115
+ if (startsWithFeatureMarker(name)) return false;
80
116
  return name.length <= MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name);
81
117
  }
82
118
 
@@ -104,6 +140,13 @@ export function assertValidEnvironment(name: string): void {
104
140
  detail: `${GLOBAL_SCOPE} occupies the environment slot of a composed name for values shared across every environment.`,
105
141
  });
106
142
  }
143
+ if (startsWithFeatureMarker(name)) {
144
+ throw new ValidationError({
145
+ message: `"${name}" starts the way a feature's name does.`,
146
+ action: "Start the environment with something other than f and a number.",
147
+ detail: `A feature takes the environment's slot with f<issue>, so <project>-${name}-<worker> can be a feature's Worker name exactly, and \`pithy feature destroy\` deletes that Worker.`,
148
+ });
149
+ }
107
150
  if (name.length > MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name)) {
108
151
  throw new ValidationError({
109
152
  message: `"${name}" is ${name.length} characters. An environment stops at ${MAX_ENVIRONMENT_NAME}.`,
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { ValidationError } from "../error/pithyError";
5
+ import { featureMarker } from "./environment";
5
6
  import { MAX_ISSUE_DIGITS, NAMESPACE_LIMITS } from "./limits";
6
7
  import { assertValidProjectName, fitSegment, kebab } from "./resource";
7
8
 
@@ -63,7 +64,7 @@ function assertIssue(issue: string): void {
63
64
  function head(identity: FeatureIdentity): string {
64
65
  assertValidProjectName(identity.project);
65
66
  assertIssue(identity.issue);
66
- return `${kebab(identity.project)}-f${identity.issue}`;
67
+ return `${kebab(identity.project)}-${featureMarker(identity.issue)}`;
67
68
  }
68
69
 
69
70
  /**
@@ -156,7 +157,13 @@ export function featureSecretEntryName(identity: FeatureIdentity, secret: string
156
157
  * floored the slug at one character and never touched the worker segment, so it ran unbounded — 69
157
158
  * characters for a worker called `collaboration-realtime-gateway`, 109 for a 70-character one. `apps/<name>`
158
159
  * has a charset rule and no length rule, so nothing upstream was going to stop it either.
160
+ *
161
+ * **`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
163
+ * project twice, spent out of the budget above (#587). This function takes a string and cannot tell the
164
+ * two apart, so the provisioning path reaches it only through `featureScope`, which is handed both names
165
+ * and picks.
159
166
  */
160
- export function featureWorkerName(identity: FeatureIdentity, worker: string): string {
161
- return composeFeatureName(head(identity), kebab(identity.slug), kebab(worker), "", NAMESPACE_LIMITS.worker.maxLength);
167
+ export function featureWorkerName(identity: FeatureIdentity, app: string): string {
168
+ return composeFeatureName(head(identity), kebab(identity.slug), kebab(app), "", NAMESPACE_LIMITS.worker.maxLength);
162
169
  }
@@ -1,8 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { InternalError } from "../error/pithyError";
5
- import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE } from "./environment";
4
+ import { InternalError, ValidationError } from "../error/pithyError";
5
+ import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE, isFeatureMarker } from "./environment";
6
6
  import {
7
7
  type FeatureIdentity,
8
8
  type FeatureResourceKind,
@@ -10,6 +10,7 @@ import {
10
10
  featureSecretEntryName,
11
11
  featureWorkerName,
12
12
  } from "./feature";
13
+ import { kebab } from "./resource";
13
14
  import { resourceNames } from "./resourceNames";
14
15
 
15
16
  /**
@@ -89,6 +90,28 @@ export interface ProjectGlobalNaming extends BindingNaming {
89
90
  readonly scope: "global";
90
91
  }
91
92
 
93
+ /**
94
+ * The two names one Worker answers to before a scope has named it — **and why a scope is handed both.**
95
+ *
96
+ * `pithy init replay --worker board` writes `apps/board/wrangler.jsonc` with `"name": "replay-board"`.
97
+ * So the Worker's directory says `board` and its deploy name says `replay-board`, and the two scopes need
98
+ * different ones. A declared environment's fallback is wrangler's own `<script>-<env>`, which is built on
99
+ * the deploy name. A feature's name is `<project>-f<issue>-<slug>-<app>`, which already leads with the
100
+ * project, so it is built on the directory.
101
+ *
102
+ * This was one string until #587, and the string was the deploy name, so every feature Worker carried the
103
+ * project twice: `replay-f69-demo-replay-board`. That was not only untidy. The name is held to the Worker
104
+ * cap of 63, and the doubled segment spent that budget where the truncation lands on the part that says
105
+ * *which* Worker. Carrying both names is what lets each scope take the one its shape is built from, and
106
+ * lets neither caller choose on the scope's behalf.
107
+ */
108
+ export interface ProvisionWorkerNames {
109
+ /** The Worker's `apps/<app>` directory basename — the name a sibling's `service` binding targets it by. */
110
+ readonly app: string;
111
+ /** The Worker's deploy name — the top-level `name` of its `wrangler.jsonc`, e.g. `replay-board`. */
112
+ readonly script: string;
113
+ }
114
+
92
115
  /** Where a provisioning run's resources are named, and where their ids are written. */
93
116
  export interface ProvisionScope {
94
117
  /** The `env.<stanza>` key in each Worker's config that this scope's ids are written into. */
@@ -136,11 +159,15 @@ export interface ProvisionScope {
136
159
  /**
137
160
  * The script name a Worker deploys under in this scope.
138
161
  *
162
+ * `worker` carries **both** of the Worker's names, because the two scopes compose from different ones:
163
+ * a declared environment falls back to wrangler's `<script>-<env>`, and a feature composes
164
+ * `<project>-f<issue>-<slug>-<app>`. See {@link ProvisionWorkerNames}.
165
+ *
139
166
  * `declared` is the name that scope's stanza already carries, when it carries one. A scope may honor it
140
167
  * or compose its own; see {@link environmentScope} and {@link featureScope}, which answer differently
141
168
  * and say why. Passing it is how a caller asks the scope rather than deciding for itself.
142
169
  */
143
- worker(worker: string, declared?: string): string;
170
+ worker(worker: ProvisionWorkerNames, declared?: string): string;
144
171
  /**
145
172
  * This scope's CF Secrets Store entry name for a declared secret.
146
173
  *
@@ -227,6 +254,28 @@ export function bindingResourceName(
227
254
  return scoped(thing);
228
255
  }
229
256
 
257
+ /**
258
+ * **Is this name inside a feature's namespace** — `<project>-f<issue>-…`, the project and then a feature's
259
+ * marker, with something after it?
260
+ *
261
+ * The namespace, not one feature's names, because that is what has to stay disjoint: `pithy feature destroy`
262
+ * deletes by exact name, and a feature Worker's name has no kind suffix, so a declared environment's name
263
+ * that lands anywhere in here is one some branch's teardown can delete (#587). Every name {@link featureScope}
264
+ * composes is inside; {@link environmentScope} refuses to name a Worker inside, and `isValidEnvironment`
265
+ * refuses every environment whose composed names could be.
266
+ *
267
+ * **What it does not see.** It is stated for one project. A second project on the same account whose name is
268
+ * this one's plus `-f<issue>` — `acme-f1` beside `acme` — composes names inside `acme`'s namespace from an
269
+ * ordinary environment, and nothing here can tell, because a project name carries no boundary a later hyphen
270
+ * cannot fake. That is the hyphen ambiguity every composed name carries, one segment further left.
271
+ */
272
+ export function isFeatureName(project: string, name: string): boolean {
273
+ const head = `${kebab(project)}-`;
274
+ if (!name.startsWith(head)) return false;
275
+ const [marker, ...rest] = name.slice(head.length).split("-");
276
+ return isFeatureMarker(marker ?? "") && rest.length > 0;
277
+ }
278
+
230
279
  /**
231
280
  * A declared environment's scope — `staging`, `prod`, or whatever the root `pithy.config.ts` lists.
232
281
  *
@@ -265,7 +314,21 @@ export function environmentScope(project: string, environment: string): Provisio
265
314
  honorsGlobal: true,
266
315
  resource: (binding, kind, naming) =>
267
316
  bindingResourceName(project, binding, kind, naming, (thing) => names[KIND_NAMER[kind]](thing)),
268
- worker: (worker, declared) => declared ?? `${worker}-${environment}`,
317
+ worker: ({ script }, declared) => {
318
+ const name = declared ?? `${script}-${environment}`;
319
+ // The environment rule already keeps every name composed from the environment out of a feature's
320
+ // namespace. A declared name and wrangler's fallback are not composed from it — the stanza says one,
321
+ // the deploy name leads the other — so either can still sit there, and a branch's teardown would
322
+ // delete the Worker (#587). Refused here, where the name is chosen, rather than spared at teardown.
323
+ if (isFeatureName(project, name)) {
324
+ throw new ValidationError({
325
+ message: `The ${environment} Worker "${name}" is named the way a feature's Worker is.`,
326
+ action: `Rename it in env.${environment}.name, or the Worker's top-level name, so it does not follow ${kebab(project)}- with f and a number.`,
327
+ detail: `${declared === undefined ? "Composed by wrangler from the deploy name" : `Declared in env.${environment}.name`}. A feature's Worker is <project>-f<issue>-<slug>-<app>, and \`pithy feature destroy\` deletes it by that exact name.`,
328
+ });
329
+ }
330
+ return name;
331
+ },
269
332
  secretEntry: (secret, secretScope) =>
270
333
  secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
271
334
  };
@@ -306,8 +369,38 @@ export function featureScope(identity: FeatureIdentity): ProvisionScope {
306
369
  // part company here.
307
370
  honorsGlobal: false,
308
371
  resource: (binding, kind) => featureResourceName(identity, binding, kind),
309
- worker: (worker) => featureWorkerName(identity, worker),
372
+ // The directory, not the deploy name: the head already carries the project, and the deploy name
373
+ // usually leads with it too, which composed `<project>-f<issue>-<slug>-<project>-<app>` (#587).
374
+ worker: ({ app }) => featureWorkerName(identity, app),
310
375
  secretEntry: (secret, secretScope) =>
311
376
  secretEntryName(identity.project, secret, secretScope, () => featureSecretEntryName(identity, secret)),
312
377
  };
313
378
  }
379
+
380
+ /**
381
+ * **Every script name one Worker can be deployed under in a feature — what teardown looks for (#592).**
382
+ *
383
+ * The first is the name {@link featureScope} composes today, taken from the scope rather than composed a
384
+ * second time, so provisioning and teardown cannot disagree about it. The second is the one it composed
385
+ * until #587, from the deploy name: `<project>-f<issue>-<slug>-<project>-<app>`. A feature provisioned and
386
+ * deployed before that upgrade is still running under it, and after its next deploy it runs under both —
387
+ * so teardown looks for both. One entry when the two coincide, as they do for a Worker whose deploy name
388
+ * is its directory.
389
+ *
390
+ * **Exact names, never a prefix.** A prefix scan of `<project>-f<issue>-<slug>-` reaches a sibling whose
391
+ * slug extends this one's. The exact names can still meet a sibling in one shape: the doubled name for
392
+ * slug `demo` and deploy name `acme-api` is the current name for slug `demo-acme` and directory `api`, on
393
+ * the same issue number. That is the same hyphen ambiguity every feature name here carries.
394
+ *
395
+ * **They cannot meet a declared environment's Worker**, which has no suffix either and is `<project>-<env>-<app>`
396
+ * since #580: every name either shape yields is inside {@link isFeatureName}'s namespace, no environment may
397
+ * start with a feature's marker, and {@link environmentScope} refuses a declared or fallback Worker name inside
398
+ * it. What that does not reach is a name nothing in the kit chose — a stanza written by hand and deployed
399
+ * without `pithy provision --env` ever reading it, or another project on the account, as {@link isFeatureName}
400
+ * says.
401
+ */
402
+ export function featureWorkerScriptNames(identity: FeatureIdentity, worker: ProvisionWorkerNames): string[] {
403
+ const current = featureScope(identity).worker(worker);
404
+ const beforeSingleProject = featureWorkerName(identity, worker.script);
405
+ return current === beforeSingleProject ? [current] : [current, beforeSingleProject];
406
+ }
@@ -69,8 +69,11 @@ export interface ScopedNames {
69
69
  export interface FeatureNames {
70
70
  /** A feature's D1, KV, or R2 resource — `<project>-f<issue>-<slug>-<binding>-<kind>`. */
71
71
  resource(binding: string, kind: FeatureResourceKind): string;
72
- /** A feature's Worker script — `<project>-f<issue>-<slug>-<worker>`. */
73
- worker(worker: string): string;
72
+ /**
73
+ * A feature's Worker script — `<project>-f<issue>-<slug>-<app>`, from the `apps/<app>` directory and
74
+ * never the deploy name, which already leads with the project (#587).
75
+ */
76
+ worker(app: string): string;
74
77
  }
75
78
 
76
79
  /** Everything a project can be asked to name. Created once, from the project, by {@link resourceNames}. */
@@ -167,7 +170,7 @@ export function resourceNames(project: string): ProjectNames {
167
170
  const full: FeatureIdentity = { ...identity, project: normalized };
168
171
  return {
169
172
  resource: (binding, kind) => featureResourceName(full, binding, kind),
170
- worker: (worker) => featureWorkerName(full, worker),
173
+ worker: (app) => featureWorkerName(full, app),
171
174
  };
172
175
  },
173
176
  };
@@ -62,6 +62,28 @@
62
62
  * `worker-safety.test.ts` exists to enforce, so the gate now polices the stripper it reads with.
63
63
  */
64
64
  export function blankComments(source: string): string {
65
+ return blank(source, false);
66
+ }
67
+
68
+ /**
69
+ * `source` with every comment **and every string's contents** blanked — for a gate asking whether a name is
70
+ * *called*, where {@link blankComments}' own rule points the wrong way.
71
+ *
72
+ * `blankComments` keeps strings because its callers look for a forbidden read, and a string that merely contains
73
+ * one is a false positive, the safe direction. A gate looking for a *required* call inverts that: a validator named
74
+ * only inside an error message — `"requireEnvironment(args.env)"` — is a call that never runs, and counting it
75
+ * passes the file. So this blanks the contents too, delimiters kept, character for character.
76
+ *
77
+ * **A template literal goes whole, `${}` included.** A call written only inside an interpolation is not seen. For
78
+ * a presence check that is the safe direction again — the file is flagged, never passed — and a scan for a read
79
+ * belongs on {@link blankComments}, where the interpolation stays.
80
+ */
81
+ export function blankCommentsAndStrings(source: string): string {
82
+ return blank(source, true);
83
+ }
84
+
85
+ /** The one walk behind both: comments always blanked, a quoted run kept or blanked inside its delimiters. */
86
+ function blank(source: string, strings: boolean): string {
65
87
  let out = "";
66
88
  let previous = "";
67
89
  let index = 0;
@@ -76,7 +98,8 @@ export function blankComments(source: string): string {
76
98
  }
77
99
  if (char === '"' || char === "'" || char === "`") {
78
100
  const stop = endOfQuoted(source, index, char, char !== "`");
79
- out += source.slice(index, stop);
101
+ const run = source.slice(index, stop);
102
+ out += strings ? blankInside(run, char) : run;
80
103
  index = stop;
81
104
  previous = char;
82
105
  continue;
@@ -95,6 +118,13 @@ export function blankComments(source: string): string {
95
118
  return out;
96
119
  }
97
120
 
121
+ /** A quoted run with everything between its delimiters blanked, newlines kept. An unterminated run has no closer. */
122
+ function blankInside(run: string, quote: string): string {
123
+ const closed = run.length > 1 && run.endsWith(quote);
124
+ const body = run.slice(1, closed ? -1 : undefined).replace(/[^\n]/g, " ");
125
+ return `${quote}${body}${closed ? quote : ""}`;
126
+ }
127
+
98
128
  /**
99
129
  * After one of these, a `/` opens a regex literal; after anything else it divides. The empty string is
100
130
  * the start of the file. A wrong guess is bounded — see {@link endOfRegex}.
@@ -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.6.2";
16
+ export const PACKAGE_VERSION = "0.7.0";
@@ -139,6 +139,14 @@ export interface WorkflowHostParams {
139
139
  * not depend on `@pithy-sh/secrets`, which owns that naming.
140
140
  */
141
141
  masterKeySecretName?: string;
142
+ /**
143
+ * Secrets Store binding name → the entry it resolves to in this environment, for every store secret the
144
+ * host reads besides the master key. Filled **only** for the bindings listed, like
145
+ * {@link databaseNames}; the names come from the capability, which composes them with the same namer
146
+ * provisioning created the entries with. Core composes none of them itself, for the reason
147
+ * {@link masterKeySecretName} gives.
148
+ */
149
+ secretNames?: Record<string, string>;
142
150
  /** Vars merged over the template's, e.g. the capability's config serialized as one JSON blob. */
143
151
  vars?: Record<string, string>;
144
152
  /**
@@ -241,13 +249,15 @@ export function resolveWorkflowHost(template: WorkflowHostTemplate, params: Work
241
249
 
242
250
  if (resolved.secrets_store_secrets && params.secretsStoreId !== undefined) {
243
251
  const storeId = params.secretsStoreId;
244
- const masterKey = params.masterKeySecretName;
252
+ const names: Record<string, string> = {
253
+ ...(params.masterKeySecretName !== undefined ? { SECRETS_ENCRYPTION_KEYS: params.masterKeySecretName } : {}),
254
+ ...params.secretNames,
255
+ };
245
256
  resolved.secrets_store_secrets = resolved.secrets_store_secrets.map((entry) => ({
246
257
  ...entry,
247
258
  store_id: storeId,
248
- // Only the master key is environment-scoped; every other entry keeps its declared name.
249
- secret_name:
250
- masterKey !== undefined && entry.binding === "SECRETS_ENCRYPTION_KEYS" ? masterKey : entry.secret_name,
259
+ // A named binding takes the entry its capability composed; an unnamed one keeps its declared name.
260
+ secret_name: Object.hasOwn(names, entry.binding) ? (names[entry.binding] as string) : entry.secret_name,
251
261
  }));
252
262
  }
253
263
 
@@ -45,7 +45,7 @@ export type HostEnvProviderKind = z.infer<typeof HostEnvProviderKind>;
45
45
  /**
46
46
  * What provides one field. The whole point of the declaration: a report that says `BASE_URL is
47
47
  * missing` sends a developer looking, and a report that says `Var BASE_URL in the host's
48
- * wrangler.jsonc. Run pithy email provision --env dev.` ends the search.
48
+ * wrangler.jsonc. Run pithy email provision.` ends the search.
49
49
  */
50
50
  export const HostEnvProvider = z
51
51
  .object({