@pithy-sh/cloudflare 0.2.3 → 0.3.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.
@@ -19,6 +19,8 @@ export declare const CfSecretEntry: z.ZodObject<{
19
19
  modified: z.ZodCodec<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodDate]>, z.ZodDate>;
20
20
  }, z.core.$strip>;
21
21
  export type CfSecretEntry = z.output<typeof CfSecretEntry>;
22
+ /** What {@link CloudflareSecretsStoreManager.createSecretIfAbsent} did — see it for each value. */
23
+ export type CreateSecretOutcome = "created" | "present" | "unconfirmed";
22
24
  /** Config for the Secrets Store manager: the shared client config plus the store it targets. */
23
25
  export interface SecretsStoreManagerConfig extends CloudflareManagerConfig {
24
26
  /** The CF Secrets Store id (the REST API addresses stores by id). */
@@ -48,6 +50,23 @@ export declare class CloudflareSecretsStoreManager extends CloudflareManager {
48
50
  * Scopes are re-sent so an entry converges on the same shape whichever branch wrote it.
49
51
  */
50
52
  putSecret(name: string, value: string): Promise<void>;
53
+ /**
54
+ * Create a secret only if no entry of that name is there, and say what happened — never "somebody else did"
55
+ * when this call cannot know that.
56
+ *
57
+ * - `created` — this call's create landed, and no older entry of the name exists beside it.
58
+ * - `present` — an entry of the name was there before this call, or appeared beside this one's and is older;
59
+ * in the second case this call's own entry is deleted, so exactly one entry of the name remains.
60
+ * - `unconfirmed` — the create threw, and an entry of the name is there now. It may be this call's own, landed
61
+ * server-side before the response was lost, or another run's. Nothing can tell which (F1 of #643's review), so
62
+ * the answer says so rather than guess, and a caller must not make correctness depend on who created it.
63
+ *
64
+ * **Never an overwrite, even in a race (#643)**, and **never reliant on the store refusing a duplicate name**:
65
+ * Cloudflare does not document that it does. So after a create lands, the entries of that name are listed, and
66
+ * if another is older this one is the loser and removes itself. The oldest wins, deterministically, whichever
67
+ * run looks. A failure with no entry there afterwards — an outage, a refused token — is thrown as it came.
68
+ */
69
+ createSecretIfAbsent(name: string, value: string): Promise<CreateSecretOutcome>;
51
70
  /** Delete a secret by name. Resolves the id via `listSecrets`, then issues DELETE by id. */
52
71
  deleteSecret(name: string): Promise<void>;
53
72
  /**
@@ -77,8 +96,14 @@ export declare class CloudflareSecretsStoreManager extends CloudflareManager {
77
96
  storeId: string;
78
97
  accountId: string;
79
98
  };
80
- /** Create a single secret with the default scopes. */
99
+ /** Create a single secret with the default scopes, and return the id Cloudflare gave it when it says one. */
81
100
  private createSecret;
101
+ /**
102
+ * The entry of this name, or `undefined`. **A `deleted` entry is not one** (#643): Cloudflare lists a secret's
103
+ * lifecycle status as `pending`, `active` or `deleted` without saying what each means, and a deleted entry is
104
+ * the one reading that cannot be "there". `pending` is taken as there, since creating over it would duplicate
105
+ * it. Of several entries of one name, the oldest, which is the one {@link createSecretIfAbsent} leaves standing.
106
+ */
82
107
  private findByName;
83
108
  }
84
109
  //# sourceMappingURL=secretsStoreManager.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAKpF;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;iBAQqE,CAAC;AAChG,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D,gGAAgG;AAChG,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,qBAAa,6BAA8B,SAAQ,iBAAiB;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,YAAY,MAAM,EAAE,yBAAyB,EAM5C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;;OAQG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1D;IAED,4FAA4F;IACtF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc9C;IAED;;;;;;;;;;;;;OAaG;IACG,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1D;IAED;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAU5C;IAED,0EAA0E;IACpE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED,kEAAkE;IAClE,mBAAmB,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAE5D;IAED,sDAAsD;YACxC,YAAY;YASZ,UAAU;CAIzB"}
1
+ {"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAKpF;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;iBAQqE,CAAC;AAChG,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D,mGAAmG;AACnG,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAOxE,gGAAgG;AAChG,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,qBAAa,6BAA8B,SAAQ,iBAAiB;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,YAAY,MAAM,EAAE,yBAAyB,EAM5C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;;OAQG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1D;IAED;;;;;;;;;;;;;;;OAeG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAoBpF;IAED,4FAA4F;IACtF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc9C;IAED;;;;;;;;;;;;;OAaG;IACG,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1D;IAED;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAU5C;IAED,0EAA0E;IACpE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED,kEAAkE;IAClE,mBAAmB,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAE5D;IAED,6GAA6G;YAC/F,YAAY;IAiB1B;;;;;OAKG;YACW,UAAU;CAIzB"}
@@ -24,6 +24,10 @@ const CfSecretEntry = z.object({
24
24
  created: JsonDate.describe("When the secret was created (ISO string on the wire, Date in app)."),
25
25
  modified: JsonDate.describe("When the secret was last modified (ISO string on the wire, Date in app).")
26
26
  }).describe("A single Cloudflare Secrets Store secret's metadata (never its plaintext value).");
27
+ /** The oldest of some entries by creation time, the smaller id breaking a tie — one answer whoever asks. */
28
+ function oldestEntry(entries) {
29
+ return [...entries].sort((a, b) => a.created.getTime() - b.created.getTime() || a.id.localeCompare(b.id))[0];
30
+ }
27
31
  /**
28
32
  * Out-of-Worker access to the account-level Cloudflare Secrets Store over the REST API: provisioning
29
33
  * and audit from a CLI/CI context. Inside a Worker, secret values resolve via bindings — this manager
@@ -73,6 +77,39 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
73
77
  scopes: [...DEFAULT_SCOPES]
74
78
  }));
75
79
  }
80
+ /**
81
+ * Create a secret only if no entry of that name is there, and say what happened — never "somebody else did"
82
+ * when this call cannot know that.
83
+ *
84
+ * - `created` — this call's create landed, and no older entry of the name exists beside it.
85
+ * - `present` — an entry of the name was there before this call, or appeared beside this one's and is older;
86
+ * in the second case this call's own entry is deleted, so exactly one entry of the name remains.
87
+ * - `unconfirmed` — the create threw, and an entry of the name is there now. It may be this call's own, landed
88
+ * server-side before the response was lost, or another run's. Nothing can tell which (F1 of #643's review), so
89
+ * the answer says so rather than guess, and a caller must not make correctness depend on who created it.
90
+ *
91
+ * **Never an overwrite, even in a race (#643)**, and **never reliant on the store refusing a duplicate name**:
92
+ * Cloudflare does not document that it does. So after a create lands, the entries of that name are listed, and
93
+ * if another is older this one is the loser and removes itself. The oldest wins, deterministically, whichever
94
+ * run looks. A failure with no entry there afterwards — an outage, a refused token — is thrown as it came.
95
+ */
96
+ async createSecretIfAbsent(name, value) {
97
+ if (await this.findByName(name)) return "present";
98
+ let ownId;
99
+ try {
100
+ ownId = await this.createSecret(name, value);
101
+ } catch (error) {
102
+ if (await this.findByName(name)) return "unconfirmed";
103
+ throw error;
104
+ }
105
+ const oldest = oldestEntry((await this.listSecrets()).filter((entry) => entry.name === name && entry.status !== "deleted"));
106
+ if (oldest === void 0 || ownId === void 0 || oldest.id === ownId) return "created";
107
+ await cloudflareRequest(`delete duplicate secret ${name}`, () => this.getClient().secretsStore.stores.secrets.delete(ownId, {
108
+ account_id: this.accountId,
109
+ store_id: this.storeId
110
+ }));
111
+ return "present";
112
+ }
76
113
  /** Delete a secret by name. Resolves the id via `listSecrets`, then issues DELETE by id. */
77
114
  async deleteSecret(name) {
78
115
  const existing = await this.findByName(name);
@@ -130,19 +167,30 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
130
167
  accountId: this.accountId
131
168
  };
132
169
  }
133
- /** Create a single secret with the default scopes. */
170
+ /** Create a single secret with the default scopes, and return the id Cloudflare gave it when it says one. */
134
171
  async createSecret(name, value) {
135
- await cloudflareRequest(`create secret ${name}`, () => this.getClient().secretsStore.stores.secrets.create(this.storeId, {
136
- account_id: this.accountId,
137
- body: [{
138
- name,
139
- value,
140
- scopes: [...DEFAULT_SCOPES]
141
- }]
142
- }));
172
+ return cloudflareRequest(`create secret ${name}`, async () => {
173
+ const response = await this.getClient().secretsStore.stores.secrets.create(this.storeId, {
174
+ account_id: this.accountId,
175
+ body: [{
176
+ name,
177
+ value,
178
+ scopes: [...DEFAULT_SCOPES]
179
+ }]
180
+ });
181
+ const items = response?.result ?? response;
182
+ if (!Array.isArray(items)) return void 0;
183
+ for (const created of items) if (typeof created?.id === "string" && created.name === name) return created.id;
184
+ });
143
185
  }
186
+ /**
187
+ * The entry of this name, or `undefined`. **A `deleted` entry is not one** (#643): Cloudflare lists a secret's
188
+ * lifecycle status as `pending`, `active` or `deleted` without saying what each means, and a deleted entry is
189
+ * the one reading that cannot be "there". `pending` is taken as there, since creating over it would duplicate
190
+ * it. Of several entries of one name, the oldest, which is the one {@link createSecretIfAbsent} leaves standing.
191
+ */
144
192
  async findByName(name) {
145
- return (await this.listSecrets()).find((entry) => entry.name === name);
193
+ return oldestEntry((await this.listSecrets()).filter((entry) => entry.name === name && entry.status !== "deleted"));
146
194
  }
147
195
  };
148
196
  //#endregion
@@ -109,8 +109,19 @@ export declare class CloudflareWorkersManager extends CloudflareManager {
109
109
  getWorkerVars(scriptName: string): Promise<Record<string, string> | null>;
110
110
  /** Edit a Worker's settings (observability, logpush, tags, …). */
111
111
  updateSettings(scriptName: string, settings: WorkerSettings): Promise<void>;
112
- /** Delete a Worker script. */
113
- deleteWorker(scriptName: string): Promise<void>;
112
+ /**
113
+ * Delete a Worker script.
114
+ *
115
+ * **Cloudflare refuses the delete while another Worker still binds the script** — as a service, a Durable
116
+ * Object namespace, or a dispatch outbound — unless it is forced. `force` sends that flag. It is the
117
+ * caller's to decide, because only the caller knows whether what binds the script is going too: a set of
118
+ * Workers that call each other can only be deleted forced, and one Worker another project still calls
119
+ * should not be. Forcing also removes what Cloudflare associates with the script, its Durable Objects
120
+ * included. wrangler's own `delete` forces only after a person has read the list of what will break.
121
+ */
122
+ deleteWorker(scriptName: string, options?: {
123
+ force?: boolean;
124
+ }): Promise<void>;
114
125
  /** List every version of a Worker script. */
115
126
  listVersions(scriptName: string): Promise<VersionListResponse[]>;
116
127
  /** Get one version of a Worker script. */
@@ -1 +1 @@
1
- {"version":3,"file":"workersManager.d.ts","sourceRoot":"","sources":["../../src/workers/workersManager.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"workersManager.d.ts","sourceRoot":"","sources":["../../src/workers/workersManager.ts"],"names":[],"mappings":"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"}
@@ -186,9 +186,21 @@ var CloudflareWorkersManager = class extends CloudflareManager {
186
186
  ...settings
187
187
  }));
188
188
  }
189
- /** Delete a Worker script. */
190
- async deleteWorker(scriptName) {
191
- await cloudflareRequest(`delete worker '${scriptName}'`, () => this.getClient().workers.scripts.delete(scriptName, { account_id: this.accountId }, requestOptions));
189
+ /**
190
+ * Delete a Worker script.
191
+ *
192
+ * **Cloudflare refuses the delete while another Worker still binds the script** — as a service, a Durable
193
+ * Object namespace, or a dispatch outbound — unless it is forced. `force` sends that flag. It is the
194
+ * caller's to decide, because only the caller knows whether what binds the script is going too: a set of
195
+ * Workers that call each other can only be deleted forced, and one Worker another project still calls
196
+ * should not be. Forcing also removes what Cloudflare associates with the script, its Durable Objects
197
+ * included. wrangler's own `delete` forces only after a person has read the list of what will break.
198
+ */
199
+ async deleteWorker(scriptName, options = {}) {
200
+ await cloudflareRequest(`delete worker '${scriptName}'`, () => this.getClient().workers.scripts.delete(scriptName, {
201
+ account_id: this.accountId,
202
+ ...options.force === true ? { force: true } : {}
203
+ }, requestOptions));
192
204
  }
193
205
  /** List every version of a Worker script. */
194
206
  async listVersions(scriptName) {
@@ -41,6 +41,12 @@ export declare const WorkflowInstance: z.ZodObject<{
41
41
  steps: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
42
42
  }, z.core.$strip>;
43
43
  export type WorkflowInstance = z.output<typeof WorkflowInstance>;
44
+ /** One deployed Workflow definition — its name and the script that hosts it — from the account's list. */
45
+ export declare const WorkflowDefinition: z.ZodObject<{
46
+ name: z.ZodString;
47
+ script_name: z.ZodString;
48
+ }, z.core.$strip>;
49
+ export type WorkflowDefinition = z.output<typeof WorkflowDefinition>;
44
50
  /**
45
51
  * A client over the Cloudflare Workflows API — the only sanctioned way to reach CF Workflows from
46
52
  * outside a Worker (CLAUDE.md: no hand-rolled CF `fetch` outside `@pithy-sh/cloudflare`). The
@@ -58,6 +64,17 @@ export declare class CloudflareWorkflowsClient extends CloudflareManager {
58
64
  getServiceType(): string;
59
65
  /** Prove reach by listing the account's Workflows; never throws. */
60
66
  validateServiceAccess(): Promise<boolean>;
67
+ /**
68
+ * Every Workflow definition on the account, validated at the wire boundary. What `pithy feature destroy` reads
69
+ * to find the Workflows a feature's scripts host, by exact script name (#643).
70
+ */
71
+ listWorkflows(): Promise<WorkflowDefinition[]>;
72
+ /**
73
+ * Delete a Workflow definition by name, resolving `false` when there was none (#643). Cloudflare's own
74
+ * reference says this deletes the Workflow and leaves the Worker script alone; whether deleting the script
75
+ * deletes its Workflows it does not say, so teardown deletes them explicitly rather than rely on either.
76
+ */
77
+ deleteWorkflow(workflowName: string): Promise<boolean>;
61
78
  /** Create (trigger) a Workflow instance and return its id. */
62
79
  createInstance(workflowName: string, params: unknown, instanceId?: string): Promise<string>;
63
80
  /** Fetch an instance's status. Returns `null` on a 404 (a just-created instance can briefly lag). */
@@ -1 +1 @@
1
- {"version":3,"file":"workflowsClient.d.ts","sourceRoot":"","sources":["../../src/workflows/workflowsClient.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAGpF,gGAAgG;AAChG,MAAM,MAAM,OAAO,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEpD,MAAM,WAAW,+BAAgC,SAAQ,uBAAuB;IAC9E,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,eAAO,MAAM,sBAAsB;;;;;;;;;;;EAawD,CAAC;AAC5F,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE7E,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;iBAqBuE,CAAC;AACrG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAOjE;;;;;;;;;;GAUG;AACH,qBAAa,yBAA0B,SAAQ,iBAAiB;;IAG9D,YAAY,MAAM,EAAE,+BAA+B,EAGlD;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oEAAoE;IAC9D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED,8DAA8D;IACxD,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYhG;IAED,qGAAqG;IAC/F,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAoBlG;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,eAAe,CACnB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,OAAO,EACf,OAAO,GAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAO,GAChF,OAAO,CAAC,OAAO,CAAC,CAmClB;CACF"}
1
+ {"version":3,"file":"workflowsClient.d.ts","sourceRoot":"","sources":["../../src/workflows/workflowsClient.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAGpF,gGAAgG;AAChG,MAAM,MAAM,OAAO,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEpD,MAAM,WAAW,+BAAgC,SAAQ,uBAAuB;IAC9E,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,eAAO,MAAM,sBAAsB;;;;;;;;;;;EAawD,CAAC;AAC5F,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE7E,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;iBAqBuE,CAAC;AACrG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEjE,0GAA0G;AAC1G,eAAO,MAAM,kBAAkB;;;iBAKuF,CAAC;AACvH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAOrE;;;;;;;;;;GAUG;AACH,qBAAa,yBAA0B,SAAQ,iBAAiB;;IAG9D,YAAY,MAAM,EAAE,+BAA+B,EAGlD;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oEAAoE;IAC9D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;OAGG;IACG,aAAa,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAUnD;IAED;;;;OAIG;IACG,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU3D;IAED,8DAA8D;IACxD,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYhG;IAED,qGAAqG;IAC/F,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAoBlG;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,eAAe,CACnB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,OAAO,EACf,OAAO,GAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAO,GAChF,OAAO,CAAC,OAAO,CAAC,CAmClB;CACF"}
@@ -28,6 +28,11 @@ const WorkflowInstance = z.object({
28
28
  ]).optional().describe("The instance-level failure, present when `status` is `errored` or `terminated`."),
29
29
  steps: z.array(z.unknown()).optional().describe("The instance's steps, each validated where it is read (`stepFailure`). Held as `unknown` here on purpose: the API has four step shapes, this client reads one field of one of them, and a shape nobody anticipated must not turn a failed Workflow into a parse error about the failure report.")
30
30
  }).describe("A Cloudflare Workflow instance's status, as returned by the instance-detail endpoint.");
31
+ /** One deployed Workflow definition — its name and the script that hosts it — from the account's list. */
32
+ const WorkflowDefinition = z.object({
33
+ name: z.string().describe("The Workflow's account-wide name, e.g. `acme-prod-email-send`."),
34
+ script_name: z.string().describe("The Worker script whose exported class runs the Workflow.")
35
+ }).describe("A Workflow definition on the account, as far as teardown needs one: what it is called and who hosts it.");
31
36
  /** Terminal states a poll stops on. */
32
37
  const TERMINAL = /* @__PURE__ */ new Set([
33
38
  "complete",
@@ -64,6 +69,37 @@ var CloudflareWorkflowsClient = class extends CloudflareManager {
64
69
  return false;
65
70
  }
66
71
  }
72
+ /**
73
+ * Every Workflow definition on the account, validated at the wire boundary. What `pithy feature destroy` reads
74
+ * to find the Workflows a feature's scripts host, by exact script name (#643).
75
+ */
76
+ async listWorkflows() {
77
+ return cloudflareRequest("list Workflows", async () => {
78
+ const out = [];
79
+ for await (const workflow of this.getClient().workflows.list({ account_id: this.accountId })) {
80
+ const parsed = WorkflowDefinition.safeParse(workflow);
81
+ if (!parsed.success) throw new CloudflareInvalidResponseError({ detail: "list Workflows: unexpected shape" });
82
+ out.push(parsed.data);
83
+ }
84
+ return out;
85
+ });
86
+ }
87
+ /**
88
+ * Delete a Workflow definition by name, resolving `false` when there was none (#643). Cloudflare's own
89
+ * reference says this deletes the Workflow and leaves the Worker script alone; whether deleting the script
90
+ * deletes its Workflows it does not say, so teardown deletes them explicitly rather than rely on either.
91
+ */
92
+ async deleteWorkflow(workflowName) {
93
+ return cloudflareRequest(`delete Workflow ${workflowName}`, async () => {
94
+ try {
95
+ await this.getClient().workflows.delete(workflowName, { account_id: this.accountId });
96
+ return true;
97
+ } catch (error) {
98
+ if (isNotFoundError(error)) return false;
99
+ throw error;
100
+ }
101
+ });
102
+ }
67
103
  /** Create (trigger) a Workflow instance and return its id. */
68
104
  async createInstance(workflowName, params, instanceId) {
69
105
  return cloudflareRequest(`create instance of ${workflowName}`, async () => {
@@ -155,4 +191,4 @@ function describeFailure(failure, error) {
155
191
  return `${failure.step === void 0 ? "step" : `step '${failure.step}'`} raised${failure.code === void 0 ? "" : ` [${failure.code}]`} ${failure.raw} — instance error: ${instance}`;
156
192
  }
157
193
  //#endregion
158
- export { CloudflareWorkflowsClient, WorkflowInstance, WorkflowInstanceStatus };
194
+ export { CloudflareWorkflowsClient, WorkflowDefinition, WorkflowInstance, WorkflowInstanceStatus };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/cloudflare",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "js-base64": "^3.9.2"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/core": "^0.6.1",
43
+ "@pithy-sh/core": "^0.7.4",
44
44
  "zod": "^4.4.0"
45
45
  },
46
46
  "devDependencies": {
@@ -26,6 +26,14 @@ export const CfSecretEntry = z
26
26
  .describe("A single Cloudflare Secrets Store secret's metadata (never its plaintext value).");
27
27
  export type CfSecretEntry = z.output<typeof CfSecretEntry>;
28
28
 
29
+ /** What {@link CloudflareSecretsStoreManager.createSecretIfAbsent} did — see it for each value. */
30
+ export type CreateSecretOutcome = "created" | "present" | "unconfirmed";
31
+
32
+ /** The oldest of some entries by creation time, the smaller id breaking a tie — one answer whoever asks. */
33
+ function oldestEntry(entries: readonly CfSecretEntry[]): CfSecretEntry | undefined {
34
+ return [...entries].sort((a, b) => a.created.getTime() - b.created.getTime() || a.id.localeCompare(b.id))[0];
35
+ }
36
+
29
37
  /** Config for the Secrets Store manager: the shared client config plus the store it targets. */
30
38
  export interface SecretsStoreManagerConfig extends CloudflareManagerConfig {
31
39
  /** The CF Secrets Store id (the REST API addresses stores by id). */
@@ -91,6 +99,44 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
91
99
  );
92
100
  }
93
101
 
102
+ /**
103
+ * Create a secret only if no entry of that name is there, and say what happened — never "somebody else did"
104
+ * when this call cannot know that.
105
+ *
106
+ * - `created` — this call's create landed, and no older entry of the name exists beside it.
107
+ * - `present` — an entry of the name was there before this call, or appeared beside this one's and is older;
108
+ * in the second case this call's own entry is deleted, so exactly one entry of the name remains.
109
+ * - `unconfirmed` — the create threw, and an entry of the name is there now. It may be this call's own, landed
110
+ * server-side before the response was lost, or another run's. Nothing can tell which (F1 of #643's review), so
111
+ * the answer says so rather than guess, and a caller must not make correctness depend on who created it.
112
+ *
113
+ * **Never an overwrite, even in a race (#643)**, and **never reliant on the store refusing a duplicate name**:
114
+ * Cloudflare does not document that it does. So after a create lands, the entries of that name are listed, and
115
+ * if another is older this one is the loser and removes itself. The oldest wins, deterministically, whichever
116
+ * run looks. A failure with no entry there afterwards — an outage, a refused token — is thrown as it came.
117
+ */
118
+ async createSecretIfAbsent(name: string, value: string): Promise<CreateSecretOutcome> {
119
+ if (await this.findByName(name)) return "present";
120
+ let ownId: string | undefined;
121
+ try {
122
+ ownId = await this.createSecret(name, value);
123
+ } catch (error) {
124
+ if (await this.findByName(name)) return "unconfirmed";
125
+ throw error;
126
+ }
127
+ const same = (await this.listSecrets()).filter((entry) => entry.name === name && entry.status !== "deleted");
128
+ const oldest = oldestEntry(same);
129
+ if (oldest === undefined || ownId === undefined || oldest.id === ownId) return "created";
130
+ // Another run's entry of the same name is older: it is the one that stands.
131
+ await cloudflareRequest(`delete duplicate secret ${name}`, () =>
132
+ this.getClient().secretsStore.stores.secrets.delete(ownId as string, {
133
+ account_id: this.accountId,
134
+ store_id: this.storeId,
135
+ }),
136
+ );
137
+ return "present";
138
+ }
139
+
94
140
  /** Delete a secret by name. Resolves the id via `listSecrets`, then issues DELETE by id. */
95
141
  async deleteSecret(name: string): Promise<void> {
96
142
  const existing = await this.findByName(name);
@@ -160,18 +206,32 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
160
206
  return { storeId: this.storeId, accountId: this.accountId };
161
207
  }
162
208
 
163
- /** Create a single secret with the default scopes. */
164
- private async createSecret(name: string, value: string): Promise<void> {
165
- await cloudflareRequest(`create secret ${name}`, () =>
166
- this.getClient().secretsStore.stores.secrets.create(this.storeId, {
209
+ /** Create a single secret with the default scopes, and return the id Cloudflare gave it when it says one. */
210
+ private async createSecret(name: string, value: string): Promise<string | undefined> {
211
+ return cloudflareRequest(`create secret ${name}`, async () => {
212
+ const response: unknown = await this.getClient().secretsStore.stores.secrets.create(this.storeId, {
167
213
  account_id: this.accountId,
168
214
  body: [{ name, value, scopes: [...DEFAULT_SCOPES] }],
169
- }),
170
- );
215
+ });
216
+ // The SDK hands back a page of what was created. Read defensively: an id is a convenience here, and a
217
+ // response without one leaves `createSecretIfAbsent` answering `created` as it would have before.
218
+ const items = (response as { result?: unknown })?.result ?? response;
219
+ if (!Array.isArray(items)) return undefined;
220
+ for (const created of items as { id?: unknown; name?: unknown }[]) {
221
+ if (typeof created?.id === "string" && created.name === name) return created.id;
222
+ }
223
+ return undefined;
224
+ });
171
225
  }
172
226
 
227
+ /**
228
+ * The entry of this name, or `undefined`. **A `deleted` entry is not one** (#643): Cloudflare lists a secret's
229
+ * lifecycle status as `pending`, `active` or `deleted` without saying what each means, and a deleted entry is
230
+ * the one reading that cannot be "there". `pending` is taken as there, since creating over it would duplicate
231
+ * it. Of several entries of one name, the oldest, which is the one {@link createSecretIfAbsent} leaves standing.
232
+ */
173
233
  private async findByName(name: string): Promise<CfSecretEntry | undefined> {
174
234
  const all = await this.listSecrets();
175
- return all.find((entry) => entry.name === name);
235
+ return oldestEntry(all.filter((entry) => entry.name === name && entry.status !== "deleted"));
176
236
  }
177
237
  }
@@ -259,10 +259,23 @@ export class CloudflareWorkersManager extends CloudflareManager {
259
259
  );
260
260
  }
261
261
 
262
- /** Delete a Worker script. */
263
- async deleteWorker(scriptName: string): Promise<void> {
262
+ /**
263
+ * Delete a Worker script.
264
+ *
265
+ * **Cloudflare refuses the delete while another Worker still binds the script** — as a service, a Durable
266
+ * Object namespace, or a dispatch outbound — unless it is forced. `force` sends that flag. It is the
267
+ * caller's to decide, because only the caller knows whether what binds the script is going too: a set of
268
+ * Workers that call each other can only be deleted forced, and one Worker another project still calls
269
+ * should not be. Forcing also removes what Cloudflare associates with the script, its Durable Objects
270
+ * included. wrangler's own `delete` forces only after a person has read the list of what will break.
271
+ */
272
+ async deleteWorker(scriptName: string, options: { force?: boolean } = {}): Promise<void> {
264
273
  await cloudflareRequest(`delete worker '${scriptName}'`, () =>
265
- this.getClient().workers.scripts.delete(scriptName, { account_id: this.accountId }, requestOptions),
274
+ this.getClient().workers.scripts.delete(
275
+ scriptName,
276
+ { account_id: this.accountId, ...(options.force === true ? { force: true } : {}) },
277
+ requestOptions,
278
+ ),
266
279
  );
267
280
  }
268
281
 
@@ -55,6 +55,15 @@ export const WorkflowInstance = z
55
55
  .describe("A Cloudflare Workflow instance's status, as returned by the instance-detail endpoint.");
56
56
  export type WorkflowInstance = z.output<typeof WorkflowInstance>;
57
57
 
58
+ /** One deployed Workflow definition — its name and the script that hosts it — from the account's list. */
59
+ export const WorkflowDefinition = z
60
+ .object({
61
+ name: z.string().describe("The Workflow's account-wide name, e.g. `acme-prod-email-send`."),
62
+ script_name: z.string().describe("The Worker script whose exported class runs the Workflow."),
63
+ })
64
+ .describe("A Workflow definition on the account, as far as teardown needs one: what it is called and who hosts it.");
65
+ export type WorkflowDefinition = z.output<typeof WorkflowDefinition>;
66
+
58
67
  /** Terminal states a poll stops on. */
59
68
  const TERMINAL: ReadonlySet<WorkflowInstanceStatus> = new Set(["complete", "errored", "terminated"]);
60
69
 
@@ -93,6 +102,39 @@ export class CloudflareWorkflowsClient extends CloudflareManager {
93
102
  }
94
103
  }
95
104
 
105
+ /**
106
+ * Every Workflow definition on the account, validated at the wire boundary. What `pithy feature destroy` reads
107
+ * to find the Workflows a feature's scripts host, by exact script name (#643).
108
+ */
109
+ async listWorkflows(): Promise<WorkflowDefinition[]> {
110
+ return cloudflareRequest("list Workflows", async () => {
111
+ const out: WorkflowDefinition[] = [];
112
+ for await (const workflow of this.getClient().workflows.list({ account_id: this.accountId })) {
113
+ const parsed = WorkflowDefinition.safeParse(workflow);
114
+ if (!parsed.success) throw new CloudflareInvalidResponseError({ detail: "list Workflows: unexpected shape" });
115
+ out.push(parsed.data);
116
+ }
117
+ return out;
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Delete a Workflow definition by name, resolving `false` when there was none (#643). Cloudflare's own
123
+ * reference says this deletes the Workflow and leaves the Worker script alone; whether deleting the script
124
+ * deletes its Workflows it does not say, so teardown deletes them explicitly rather than rely on either.
125
+ */
126
+ async deleteWorkflow(workflowName: string): Promise<boolean> {
127
+ return cloudflareRequest(`delete Workflow ${workflowName}`, async () => {
128
+ try {
129
+ await this.getClient().workflows.delete(workflowName, { account_id: this.accountId });
130
+ return true;
131
+ } catch (error) {
132
+ if (isNotFoundError(error)) return false;
133
+ throw error;
134
+ }
135
+ });
136
+ }
137
+
96
138
  /** Create (trigger) a Workflow instance and return its id. */
97
139
  async createInstance(workflowName: string, params: unknown, instanceId?: string): Promise<string> {
98
140
  return cloudflareRequest(`create instance of ${workflowName}`, async () => {