@pithy-sh/core 0.6.2 → 0.7.1

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 (67) 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/controlPlane/capability.d.ts.map +1 -1
  6. package/dist/controlPlane/capability.js +5 -1
  7. package/dist/controlPlane/data/connection.d.ts +1 -0
  8. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  9. package/dist/controlPlane/data/connection.js +1 -0
  10. package/dist/controlPlane/migrations/0002_management_origin.d.ts +23 -0
  11. package/dist/controlPlane/migrations/0002_management_origin.d.ts.map +1 -0
  12. package/dist/controlPlane/migrations/0002_management_origin.js +31 -0
  13. package/dist/data/databases.d.ts +11 -0
  14. package/dist/data/databases.d.ts.map +1 -1
  15. package/dist/migrations/registry.d.ts +6 -0
  16. package/dist/migrations/registry.d.ts.map +1 -1
  17. package/dist/migrations/registry.js +2 -0
  18. package/dist/migrations/retained.d.ts +101 -0
  19. package/dist/migrations/retained.d.ts.map +1 -0
  20. package/dist/migrations/retained.js +243 -0
  21. package/dist/migrations/retainedDeclaration.d.ts +15 -0
  22. package/dist/migrations/retainedDeclaration.d.ts.map +1 -0
  23. package/dist/migrations/retainedDeclaration.js +49 -0
  24. package/dist/migrations/runner.d.ts +44 -7
  25. package/dist/migrations/runner.d.ts.map +1 -1
  26. package/dist/migrations/runner.js +41 -15
  27. package/dist/naming/environment.d.ts +12 -0
  28. package/dist/naming/environment.d.ts.map +1 -1
  29. package/dist/naming/environment.js +38 -1
  30. package/dist/naming/feature.d.ts +7 -1
  31. package/dist/naming/feature.d.ts.map +1 -1
  32. package/dist/naming/feature.js +10 -3
  33. package/dist/naming/provisionScope.d.ts +65 -1
  34. package/dist/naming/provisionScope.d.ts.map +1 -1
  35. package/dist/naming/provisionScope.js +62 -5
  36. package/dist/naming/resourceNames.d.ts +5 -2
  37. package/dist/naming/resourceNames.d.ts.map +1 -1
  38. package/dist/naming/resourceNames.js +1 -1
  39. package/dist/text/comments.d.ts +14 -0
  40. package/dist/text/comments.d.ts.map +1 -1
  41. package/dist/text/comments.js +28 -2
  42. package/dist/version.generated.d.ts +1 -1
  43. package/dist/version.generated.js +1 -1
  44. package/dist/workflow/host.d.ts +8 -0
  45. package/dist/workflow/host.d.ts.map +1 -1
  46. package/dist/workflow/host.js +5 -2
  47. package/dist/workflow/hostEnv.d.ts +1 -1
  48. package/dist/workflow/hostEnv.js +1 -1
  49. package/package.json +1 -1
  50. package/src/capability/capability.ts +6 -0
  51. package/src/capability/settings.ts +15 -2
  52. package/src/controlPlane/capability.ts +2 -0
  53. package/src/controlPlane/data/connection.ts +7 -0
  54. package/src/controlPlane/migrations/0002_management_origin.ts +36 -0
  55. package/src/data/databases.ts +11 -0
  56. package/src/migrations/registry.ts +10 -0
  57. package/src/migrations/retained.ts +319 -0
  58. package/src/migrations/retainedDeclaration.ts +56 -0
  59. package/src/migrations/runner.ts +89 -15
  60. package/src/naming/environment.ts +43 -0
  61. package/src/naming/feature.ts +10 -3
  62. package/src/naming/provisionScope.ts +98 -5
  63. package/src/naming/resourceNames.ts +6 -3
  64. package/src/text/comments.ts +31 -1
  65. package/src/version.generated.ts +1 -1
  66. package/src/workflow/host.ts +14 -4
  67. package/src/workflow/hostEnv.ts +1 -1
@@ -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.1";
@@ -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({