@pithy-sh/cloudflare 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/ai/aiManager.d.ts +101 -0
  2. package/dist/ai/aiManager.d.ts.map +1 -0
  3. package/dist/ai/aiManager.js +150 -0
  4. package/dist/ai/vectorizeManager.d.ts +69 -0
  5. package/dist/ai/vectorizeManager.d.ts.map +1 -0
  6. package/dist/ai/vectorizeManager.js +103 -0
  7. package/dist/ai/vectorizeProvisioner.d.ts +125 -0
  8. package/dist/ai/vectorizeProvisioner.d.ts.map +1 -0
  9. package/dist/ai/vectorizeProvisioner.js +197 -0
  10. package/dist/client/accounts.d.ts +50 -0
  11. package/dist/client/accounts.d.ts.map +1 -0
  12. package/dist/client/accounts.js +57 -0
  13. package/dist/client/clients.d.ts +124 -0
  14. package/dist/client/clients.d.ts.map +1 -0
  15. package/dist/client/clients.js +218 -0
  16. package/dist/client/errors.d.ts +75 -0
  17. package/dist/client/errors.d.ts.map +1 -0
  18. package/dist/client/errors.js +100 -0
  19. package/dist/client/manager.d.ts +47 -0
  20. package/dist/client/manager.d.ts.map +1 -0
  21. package/dist/client/manager.js +53 -0
  22. package/dist/d1/d1Manager.d.ts +76 -0
  23. package/dist/d1/d1Manager.d.ts.map +1 -0
  24. package/dist/d1/d1Manager.js +130 -0
  25. package/dist/d1/d1PreparedStatement.d.ts +26 -0
  26. package/dist/d1/d1PreparedStatement.d.ts.map +1 -0
  27. package/dist/d1/d1PreparedStatement.js +93 -0
  28. package/dist/d1/d1Provisioner.d.ts +30 -0
  29. package/dist/d1/d1Provisioner.d.ts.map +1 -0
  30. package/dist/d1/d1Provisioner.js +69 -0
  31. package/dist/email/emailRoutingManager.d.ts +38 -0
  32. package/dist/email/emailRoutingManager.d.ts.map +1 -0
  33. package/dist/email/emailRoutingManager.js +126 -0
  34. package/dist/email/emailSendManager.d.ts +47 -0
  35. package/dist/email/emailSendManager.d.ts.map +1 -0
  36. package/dist/email/emailSendManager.js +54 -0
  37. package/dist/env/devVars.d.ts +67 -0
  38. package/dist/env/devVars.d.ts.map +1 -0
  39. package/dist/env/devVars.js +86 -0
  40. package/dist/hostnames/customHostnamesManager.d.ts +53 -0
  41. package/dist/hostnames/customHostnamesManager.d.ts.map +1 -0
  42. package/dist/hostnames/customHostnamesManager.js +86 -0
  43. package/dist/kv/kvManager.d.ts +78 -0
  44. package/dist/kv/kvManager.d.ts.map +1 -0
  45. package/dist/kv/kvManager.js +137 -0
  46. package/dist/kv/kvProvisioner.d.ts +30 -0
  47. package/dist/kv/kvProvisioner.d.ts.map +1 -0
  48. package/dist/kv/kvProvisioner.js +73 -0
  49. package/dist/media/assetSeeder.d.ts +33 -0
  50. package/dist/media/assetSeeder.d.ts.map +1 -0
  51. package/dist/media/assetSeeder.js +72 -0
  52. package/dist/media/imageManager.d.ts +36 -0
  53. package/dist/media/imageManager.d.ts.map +1 -0
  54. package/dist/media/imageManager.js +105 -0
  55. package/dist/media/ownership.d.ts +42 -0
  56. package/dist/media/ownership.d.ts.map +1 -0
  57. package/dist/media/ownership.js +48 -0
  58. package/dist/media/streamManager.d.ts +85 -0
  59. package/dist/media/streamManager.d.ts.map +1 -0
  60. package/dist/media/streamManager.js +147 -0
  61. package/dist/queue/queueManager.d.ts +86 -0
  62. package/dist/queue/queueManager.d.ts.map +1 -0
  63. package/dist/queue/queueManager.js +116 -0
  64. package/dist/r2/r2Credentials.d.ts +14 -0
  65. package/dist/r2/r2Credentials.d.ts.map +1 -0
  66. package/dist/r2/r2Credentials.js +15 -0
  67. package/dist/r2/r2Manager.d.ts +228 -0
  68. package/dist/r2/r2Manager.d.ts.map +1 -0
  69. package/dist/r2/r2Manager.js +407 -0
  70. package/dist/r2/r2Provisioner.d.ts +41 -0
  71. package/dist/r2/r2Provisioner.d.ts.map +1 -0
  72. package/dist/r2/r2Provisioner.js +88 -0
  73. package/dist/secrets/secretsStoreManager.d.ts +84 -0
  74. package/dist/secrets/secretsStoreManager.d.ts.map +1 -0
  75. package/dist/secrets/secretsStoreManager.js +149 -0
  76. package/dist/secrets/secretsStores.d.ts +49 -0
  77. package/dist/secrets/secretsStores.d.ts.map +1 -0
  78. package/dist/secrets/secretsStores.js +67 -0
  79. package/dist/test-utils/emailRoutingRules.d.ts +41 -0
  80. package/dist/test-utils/emailRoutingRules.d.ts.map +1 -0
  81. package/dist/test-utils/emailRoutingRules.js +91 -0
  82. package/dist/test-utils/fixtureReportSetup.d.ts +27 -0
  83. package/dist/test-utils/fixtureReportSetup.d.ts.map +1 -0
  84. package/dist/test-utils/fixtureReportSetup.js +32 -0
  85. package/dist/test-utils/fixtures.d.ts +226 -0
  86. package/dist/test-utils/fixtures.d.ts.map +1 -0
  87. package/dist/test-utils/fixtures.js +279 -0
  88. package/dist/test-utils/harness.d.ts +194 -0
  89. package/dist/test-utils/harness.d.ts.map +1 -0
  90. package/dist/test-utils/harness.js +353 -0
  91. package/dist/test-utils/inboundRecorder.d.ts +93 -0
  92. package/dist/test-utils/inboundRecorder.d.ts.map +1 -0
  93. package/dist/test-utils/inboundRecorder.js +143 -0
  94. package/dist/test-utils/integrationSetup.d.ts +24 -0
  95. package/dist/test-utils/integrationSetup.d.ts.map +1 -0
  96. package/dist/test-utils/integrationSetup.js +39 -0
  97. package/dist/test-utils/reap.d.ts +114 -0
  98. package/dist/test-utils/reap.d.ts.map +1 -0
  99. package/dist/test-utils/reap.js +187 -0
  100. package/dist/tokens/accountTokensManager.d.ts +159 -0
  101. package/dist/tokens/accountTokensManager.d.ts.map +1 -0
  102. package/dist/tokens/accountTokensManager.js +282 -0
  103. package/dist/tokens/permissions.d.ts +38 -0
  104. package/dist/tokens/permissions.d.ts.map +1 -0
  105. package/dist/tokens/permissions.js +52 -0
  106. package/dist/tokens/profiles.d.ts +104 -0
  107. package/dist/tokens/profiles.d.ts.map +1 -0
  108. package/dist/tokens/profiles.js +139 -0
  109. package/dist/turnstile/turnstileManager.d.ts +77 -0
  110. package/dist/turnstile/turnstileManager.d.ts.map +1 -0
  111. package/dist/turnstile/turnstileManager.js +140 -0
  112. package/dist/user/userManager.d.ts +38 -0
  113. package/dist/user/userManager.d.ts.map +1 -0
  114. package/dist/user/userManager.js +60 -0
  115. package/dist/workers/buildsManager.d.ts +118 -0
  116. package/dist/workers/buildsManager.d.ts.map +1 -0
  117. package/dist/workers/buildsManager.js +239 -0
  118. package/dist/workers/buildsTypes.d.ts +104 -0
  119. package/dist/workers/buildsTypes.d.ts.map +1 -0
  120. package/dist/workers/buildsTypes.js +89 -0
  121. package/dist/workers/workersBuildEvent.d.ts +66 -0
  122. package/dist/workers/workersBuildEvent.d.ts.map +1 -0
  123. package/dist/workers/workersBuildEvent.js +49 -0
  124. package/dist/workers/workersManager.d.ts +138 -0
  125. package/dist/workers/workersManager.d.ts.map +1 -0
  126. package/dist/workers/workersManager.js +314 -0
  127. package/dist/workers/workersProvisioner.d.ts +82 -0
  128. package/dist/workers/workersProvisioner.d.ts.map +1 -0
  129. package/dist/workers/workersProvisioner.js +108 -0
  130. package/dist/workflows/stepFailure.d.ts +182 -0
  131. package/dist/workflows/stepFailure.d.ts.map +1 -0
  132. package/dist/workflows/stepFailure.js +229 -0
  133. package/dist/workflows/workflowsClient.d.ts +89 -0
  134. package/dist/workflows/workflowsClient.d.ts.map +1 -0
  135. package/dist/workflows/workflowsClient.js +158 -0
  136. package/dist/zones/zonesManager.d.ts +52 -0
  137. package/dist/zones/zonesManager.d.ts.map +1 -0
  138. package/dist/zones/zonesManager.js +76 -0
  139. package/package.json +18 -9
@@ -0,0 +1,143 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { CloudflareInvalidResponseError, CloudflareRequestError } from "../client/errors.js";
4
+ import { z } from "zod";
5
+ //#region src/test-utils/inboundRecorder.ts
6
+ /**
7
+ * A throwaway Worker that records what its `email()` handler was handed, and the reader that fetches
8
+ * the record back out.
9
+ *
10
+ * ## Why a Worker has to exist at all
11
+ *
12
+ * An Email Routing rule cannot point at a Worker that is not there. Cloudflare rejects the rule with
13
+ * `2016 Workers Script Info not found` at creation time, so a live test of `ensureWorkerRoute` has to
14
+ * deploy a real script before it can create a real rule. That fact alone forces this file; everything
15
+ * else here is what the script may as well do once it is running.
16
+ *
17
+ * ## Why the record goes through KV
18
+ *
19
+ * There is no inbox. The fixture zone's catch-all drops and it has zero verified destination
20
+ * addresses, so nothing this suite sends can be read from a mailbox — and a test that waits for one
21
+ * waits forever. The Worker is the destination, so the Worker has to be the witness: it writes what it
22
+ * received to a KV namespace the test created, and the test reads it back over the REST API.
23
+ *
24
+ * ## What it deliberately does not do
25
+ *
26
+ * It does not classify, suppress, or parse MIME. Everything `@pithy-sh/email` actually does with an
27
+ * inbound message is unit-tested against real D1 in the Workers pool, where a hostile message can be
28
+ * constructed exactly rather than mailed and hoped for. What only a live delivery can answer is what
29
+ * Cloudflare *hands* the handler — which headers survive, which Cloudflare adds, and whether a
30
+ * sender's own headers arrive intact. So the recorder records, and asserts nothing.
31
+ */
32
+ /** The KV binding the recorder writes through. */
33
+ const INBOUND_RECORDER_BINDING = "OBSERVED";
34
+ /** The header a sender stamps to address its record. One send, one key, so parallel runs cannot collide. */
35
+ const INBOUND_NONCE_HEADER = "X-Pithy-Probe";
36
+ /**
37
+ * The recorder's source, as a module string.
38
+ *
39
+ * Kept verbatim here rather than built from a template because it is uploaded as-is and read by whoever
40
+ * is debugging a delivery that did not arrive. It writes **once**, under the nonce it finds in the raw
41
+ * message, and it must never throw: a throw inside `email()` is a delivery failure Cloudflare retries,
42
+ * which turns one test message into several.
43
+ */
44
+ const INBOUND_RECORDER_MODULE = `
45
+ const NONCE = /^${INBOUND_NONCE_HEADER}:[ \\t]*(\\S+)[ \\t]*$/im;
46
+ const AUTH_RESULTS = /^Authentication-Results:[ \\t]*([\\s\\S]*?)(?=\\r?\\n[^ \\t])/gim;
47
+
48
+ /** Header names in the raw block, in order, lowercased. Duplicates kept — two DKIM signatures are two facts. */
49
+ function rawHeaderNames(raw) {
50
+ const block = raw.split(/\\r?\\n\\r?\\n/)[0] ?? "";
51
+ return block
52
+ .split(/\\r?\\n/)
53
+ .filter((line) => /^[A-Za-z0-9-]+:/.test(line))
54
+ .map((line) => line.slice(0, line.indexOf(":")).toLowerCase());
55
+ }
56
+
57
+ export default {
58
+ async email(message, env) {
59
+ let record;
60
+ let nonce = "unkeyed";
61
+ try {
62
+ const raw = await new Response(message.raw).text();
63
+ nonce = NONCE.exec(raw)?.[1] ?? "unkeyed";
64
+ record = {
65
+ envelopeFrom: message.from,
66
+ envelopeTo: message.to,
67
+ rawSize: message.rawSize,
68
+ seen: [...message.headers.keys()].map((name) => name.toLowerCase()).sort(),
69
+ rawHeaderNames: rawHeaderNames(raw),
70
+ authenticationResults: [...raw.matchAll(AUTH_RESULTS)].map((match) => match[1].trim()),
71
+ raw: raw.slice(0, 16384),
72
+ };
73
+ } catch (error) {
74
+ record = { failure: String(error) };
75
+ }
76
+ await env.${INBOUND_RECORDER_BINDING}.put(nonce, JSON.stringify(record), { expirationTtl: 3600 });
77
+ },
78
+ };
79
+ `;
80
+ /**
81
+ * What the recorder saw — validated on the way back in, because a KV read is a boundary like any other.
82
+ *
83
+ * The Worker that wrote it is ours, which is exactly the argument that has let every unvalidated
84
+ * internal read through. A truncated write or a stale record from an older recorder deserves a Zod
85
+ * failure naming the field, not a `TypeError` three assertions later.
86
+ */
87
+ const ObservedInbound = z.object({
88
+ envelopeFrom: z.string().describe("The SMTP envelope sender, as `message.from` reported it."),
89
+ envelopeTo: z.string().describe("The SMTP envelope recipient, as `message.to` reported it."),
90
+ rawSize: z.number().describe("The message size in bytes, as `message.rawSize` reported it."),
91
+ seen: z.array(z.string()).describe("Header names on `message.headers`, lowercased and sorted — the surface workerd#6740 is about."),
92
+ rawHeaderNames: z.array(z.string()).describe("Header names in the raw MIME block, lowercased, in wire order, duplicates kept."),
93
+ authenticationResults: z.array(z.string()).describe("Every `Authentication-Results` value in the raw block, in wire order: the topmost is the receiver's."),
94
+ raw: z.string().describe("The delivered message, truncated to the first 16 KiB.")
95
+ }).describe("One inbound message as the Worker's `email()` handler received it.");
96
+ /** A trivial module Worker: enough for an Email Routing rule to be allowed to point at it. */
97
+ const PLACEHOLDER_INBOUND_MODULE = "export default { async email() {} };\n";
98
+ /** The module filename every throwaway Worker here is uploaded under. */
99
+ const INBOUND_MODULE_NAME = "worker.mjs";
100
+ /**
101
+ * Deploy the recorder at `scriptName`, bound to the KV namespace records are written to.
102
+ *
103
+ * Uploaded through `CloudflareWorkersManager.createWorker` like anything else. It briefly was not:
104
+ * #373 was a manager that could not upload a module to the live API at all, and this file carried a
105
+ * hand-rolled `fetch` around it. The manager is the path again — a helper that exists because the
106
+ * product is broken is the thing people copy.
107
+ */
108
+ async function deployInboundRecorder(options) {
109
+ const { workers, scriptName, namespaceId, compatibilityDate } = options;
110
+ await workers.createWorker(scriptName, compatibilityDate, { bindings: [{
111
+ type: "kv_namespace",
112
+ name: INBOUND_RECORDER_BINDING,
113
+ namespace_id: namespaceId
114
+ }] }, {
115
+ name: INBOUND_MODULE_NAME,
116
+ body: INBOUND_RECORDER_MODULE
117
+ });
118
+ }
119
+ /**
120
+ * The record for one nonce, or null while nothing has arrived.
121
+ *
122
+ * Null rather than a throw for the missing case, because "not yet" is the normal state of a mail
123
+ * delivery: the caller polls. A 404 from KV is that state; anything else is a real failure and throws.
124
+ */
125
+ async function readObservedInbound(options) {
126
+ const { creds, namespaceId, nonce } = options;
127
+ const url = `https://api.cloudflare.com/client/v4/accounts/${creds.accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(nonce)}`;
128
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${creds.apiToken}` } });
129
+ if (response.status === 404) return null;
130
+ if (!response.ok) throw new CloudflareRequestError({
131
+ message: "Could not read the recorded inbound message.",
132
+ action: "Check the token carries Workers KV Storage: Edit on this account.",
133
+ detail: `KV read for the inbound record returned ${response.status}.`
134
+ });
135
+ const body = JSON.parse(await response.text());
136
+ if (typeof body === "object" && body !== null && "failure" in body) throw new CloudflareInvalidResponseError({
137
+ message: "The inbound recorder could not read the message it was handed.",
138
+ detail: `The recorder Worker recorded a failure: ${String(body.failure)}`
139
+ });
140
+ return ObservedInbound.parse(body);
141
+ }
142
+ //#endregion
143
+ export { INBOUND_MODULE_NAME, INBOUND_NONCE_HEADER, INBOUND_RECORDER_BINDING, INBOUND_RECORDER_MODULE, ObservedInbound, PLACEHOLDER_INBOUND_MODULE, deployInboundRecorder, readObservedInbound };
@@ -0,0 +1,24 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The Vitest `globalSetup` every `vitest.integration.config.ts` points at — one fixture report and one
5
+ * debris sweep per integration run, before a single suite is collected.
6
+ *
7
+ * **A `globalSetup` is the only place either can correctly live.** Reaping used to happen in a suite's
8
+ * `beforeAll`, and Vitest runs no hooks inside a `describe.skipIf(true)` — so each reaper was gated on
9
+ * exactly the credential whose absence lets debris pile up, and a package with no live suite of its own
10
+ * reaped nothing however much it created. `globalSetup` runs before collection, so no suite's gate can
11
+ * switch it off, and it runs once per project rather than once per file.
12
+ *
13
+ * The fixture report is the same argument about a different thing. A suite that skips for want of a
14
+ * Turnstile widget prints "skipped" and nothing else, and the one place that can say *which* fixture and
15
+ * *where to make it* is a place no suite's gate reaches. So it runs first, and it runs **before the
16
+ * credentials check** — a contributor with no account is exactly who needs to be told why the run went
17
+ * quiet, and gating the explanation on the thing being explained is how it goes silent for them.
18
+ *
19
+ * Never throws. Housekeeping that fails the run it was meant to help is worse than the debris: a missing
20
+ * or unprivileged token must skip the sweep and let the suites make their own skip decision, exactly as
21
+ * they did before.
22
+ */
23
+ export default function setup(): Promise<void>;
24
+ //# sourceMappingURL=integrationSetup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integrationSetup.d.ts","sourceRoot":"","sources":["../../src/test-utils/integrationSetup.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAA8B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAkBnD"}
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { loadIntegrationCreds } from "./harness.js";
4
+ import { fixtureValue, reportFixtureEstate, resolveFixture } from "./fixtures.js";
5
+ import { reapAllStaleTestResources } from "./reap.js";
6
+ //#region src/test-utils/integrationSetup.ts
7
+ /**
8
+ * The Vitest `globalSetup` every `vitest.integration.config.ts` points at — one fixture report and one
9
+ * debris sweep per integration run, before a single suite is collected.
10
+ *
11
+ * **A `globalSetup` is the only place either can correctly live.** Reaping used to happen in a suite's
12
+ * `beforeAll`, and Vitest runs no hooks inside a `describe.skipIf(true)` — so each reaper was gated on
13
+ * exactly the credential whose absence lets debris pile up, and a package with no live suite of its own
14
+ * reaped nothing however much it created. `globalSetup` runs before collection, so no suite's gate can
15
+ * switch it off, and it runs once per project rather than once per file.
16
+ *
17
+ * The fixture report is the same argument about a different thing. A suite that skips for want of a
18
+ * Turnstile widget prints "skipped" and nothing else, and the one place that can say *which* fixture and
19
+ * *where to make it* is a place no suite's gate reaches. So it runs first, and it runs **before the
20
+ * credentials check** — a contributor with no account is exactly who needs to be told why the run went
21
+ * quiet, and gating the explanation on the thing being explained is how it goes silent for them.
22
+ *
23
+ * Never throws. Housekeeping that fails the run it was meant to help is worse than the debris: a missing
24
+ * or unprivileged token must skip the sweep and let the suites make their own skip decision, exactly as
25
+ * they did before.
26
+ */
27
+ async function setup() {
28
+ reportFixtureEstate();
29
+ const creds = loadIntegrationCreds();
30
+ if (!creds.hasCreds) return;
31
+ const emailRoutingZoneId = resolveFixture("email-routing").ready ? fixtureValue("email-routing", "EMAIL_ROUTING_ZONE_ID") : void 0;
32
+ try {
33
+ await reapAllStaleTestResources(creds, { emailRoutingZoneId });
34
+ } catch (error) {
35
+ console.warn(`stale test resources could not be swept: ${error instanceof Error ? error.message : String(error)}`);
36
+ }
37
+ }
38
+ //#endregion
39
+ export { setup as default };
@@ -0,0 +1,114 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { type IntegrationCreds, type ReapableKind, testResourceAge } from "./harness";
4
+ /**
5
+ * The reap registry: every throwaway resource kind a live suite can mint, in one place, swept once per
6
+ * integration run.
7
+ *
8
+ * ## Why this exists, and why per-suite reaping was never enough
9
+ *
10
+ * {@link ReapableKind} and `reapStaleTestResources` are generic — they know how to reap *a* kind, and
11
+ * nothing about which kinds exist. So a kind was reaped only where some suite happened to hand the
12
+ * reaper a `list`/`remove` pair, and five such call sites covered the whole repo. Secrets Store
13
+ * entries, Queues, and API tokens had none at all, which is why eight `pithy-int-secret-…` entries sat
14
+ * on a real account with nothing in the repo able to reclaim them, ever.
15
+ *
16
+ * Two failures compound, and the second is the one that makes per-suite reaping structurally wrong.
17
+ *
18
+ * **A reaper registered in a suite's `beforeAll` does not run when that suite skips.** Every live suite
19
+ * is a `describe.skipIf(...)`, and Vitest runs no hooks inside a skipped suite. So the reaper is gated
20
+ * on exactly the credential whose absence lets debris accumulate: without `R2_CREDENTIALS` the R2
21
+ * suite skips, and because D1 reaping lived inside `@pithy-sh/storage`'s bundle, the **D1** reaper went
22
+ * offline with it. A reaper must never be gated on the same condition as the suite that dirties the
23
+ * account.
24
+ *
25
+ * **And reaping was per-suite, not per-run.** `test:integration --filter @pithy-sh/vector` mints
26
+ * Vectorize indexes and reaps nothing, because the only index reaper lived in `@pithy-sh/cloudflare`.
27
+ * Cross-package coverage was accidental.
28
+ *
29
+ * So the sweep moved to a Vitest `globalSetup` (see `integrationSetup.ts`), which runs once per project
30
+ * before any suite is collected and cannot be skipped by a suite's gate. A new live suite inherits
31
+ * cleanup instead of remembering to arrange it.
32
+ *
33
+ * ## What is deliberately still not reaped
34
+ *
35
+ * Resources composed under {@link RESERVED_TEST_PROJECT} — `pithy provision --feature`'s ephemeral D1 and
36
+ * KV, which a live test provisions through the *product's* namer because the names are the thing under
37
+ * test. `testResourceAge` cannot read an age out of `pithy-int-test-dev-73-slug-db`, and returns null
38
+ * rather than guessing. That conservatism is correct and stays: failing to clean up costs pennies, and
39
+ * deleting a resource a running suite still owns turns a green run red for reasons nobody can see. Those
40
+ * suites tear down in an unconditional `afterAll`; an interrupted run leaves debris that must be removed
41
+ * by hand. `CONTRIBUTING.md` says so out loud rather than implying the sweep covers everything.
42
+ */
43
+ /**
44
+ * What this run is allowed to sweep beyond the account itself.
45
+ *
46
+ * Every other kind is account-scoped: the credentials name the account, and the account holds the
47
+ * debris. An Email Routing rule is not — it lives on a **zone**, and a sweep that guessed which zone
48
+ * would be editing mail delivery on a domain nobody pointed it at. So the zone is passed in, from the
49
+ * fixture that declared it, and a run that was told nothing sweeps nothing.
50
+ */
51
+ export interface ReapScope {
52
+ /** The zone whose `pithy-int-` routing rules may be reclaimed. Absent means the kind reports itself skipped. */
53
+ emailRoutingZoneId?: string;
54
+ }
55
+ /** A kind that cannot be reaped this run, and the credential it is waiting on. */
56
+ export interface SkippedReapKind {
57
+ /** The human label, matching the kind it stands in for. */
58
+ label: string;
59
+ /** Why it was skipped — names the missing variable, so the report is actionable. */
60
+ skipped: string;
61
+ }
62
+ /** One entry in a reap plan: a kind that can be swept, or a named reason it cannot. */
63
+ export type ReapPlanEntry = (ReapableKind & {
64
+ label: string;
65
+ }) | SkippedReapKind;
66
+ /** What one kind's sweep did. `skipped` is non-null only when the kind never ran. */
67
+ export interface ReapKindResult {
68
+ /** The kind's label, as it appears in the plan. */
69
+ label: string;
70
+ /** Names successfully removed. */
71
+ reaped: string[];
72
+ /** Names that matched but could not be removed — reported, never swallowed. */
73
+ failed: string[];
74
+ /** The reason this kind did not run, or null when it did. */
75
+ skipped: string | null;
76
+ }
77
+ /** Every label in a plan, in plan order — what a test asserts against so a dropped kind is visible. */
78
+ export declare function reapPlanLabels(plan: readonly ReapPlanEntry[]): string[];
79
+ /**
80
+ * Sweep every kind in a plan and report what went.
81
+ *
82
+ * Never throws, and never lets one kind's failure end the sweep: a reaper exists to help a suite, and a
83
+ * housekeeping error that fails the run is worse than the debris it was cleaning. A listing that throws
84
+ * yields an empty result for that kind; a removal that throws lands in `failed`, where it is visible.
85
+ */
86
+ export declare function reapKinds(plan: readonly ReapPlanEntry[], options?: {
87
+ now?: number;
88
+ staleAfterMs?: number;
89
+ }): Promise<ReapKindResult[]>;
90
+ /**
91
+ * Build the sweep plan for an account.
92
+ *
93
+ * Alphabetical by label, so the set is readable as a list rather than as an accident of construction
94
+ * order, and so a test can pin it. Every kind appears whether or not it can run — a kind that is missing
95
+ * a credential reports *why*, because "nothing to reap" and "unable to reap" look identical in a log and
96
+ * only one of them is fine.
97
+ *
98
+ * R2 is the one kind an API token cannot reclaim: Cloudflare refuses to delete a non-empty bucket, and
99
+ * emptying one is an S3-protocol operation. So it needs the key pair, and says so when it lacks it.
100
+ */
101
+ export declare function testResourceReapPlan(creds: IntegrationCreds, options?: ReapScope): ReapPlanEntry[];
102
+ /**
103
+ * Reclaim every stale throwaway resource on the account, across every kind.
104
+ *
105
+ * The entry point a `globalSetup` calls. Reports one line per kind that did something or could not run;
106
+ * a kind with nothing to do stays quiet, because a clean account should produce a clean log.
107
+ */
108
+ export declare function reapAllStaleTestResources(creds: IntegrationCreds, options?: ReapScope & {
109
+ now?: number;
110
+ staleAfterMs?: number;
111
+ }): Promise<ReapKindResult[]>;
112
+ /** Re-exported so a caller reasoning about one name does not need two imports. */
113
+ export { testResourceAge };
114
+ //# sourceMappingURL=reap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reap.d.ts","sourceRoot":"","sources":["../../src/test-utils/reap.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,YAAY,EAEjB,eAAe,EAChB,MAAM,WAAW,CAAC;AAEnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,gHAAgH;IAChH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uFAAuF;AACvF,MAAM,MAAM,aAAa,GAAG,CAAC,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,eAAe,CAAC;AAEjF,qFAAqF;AACrF,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,kCAAkC;IAClC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,+EAA+E;IAC/E,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAOD,uGAAuG;AACvG,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,EAAE,CAEvE;AAED;;;;;;GAMG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,SAAS,aAAa,EAAE,EAC9B,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GACpD,OAAO,CAAC,cAAc,EAAE,CAAC,CA+B3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,GAAE,SAAc,GAAG,aAAa,EAAE,CAkHtG;AAED;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,gBAAgB,EACvB,OAAO,GAAE,SAAS,GAAG;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GAChE,OAAO,CAAC,cAAc,EAAE,CAAC,CAc3B;AAED,kFAAkF;AAClF,OAAO,EAAE,eAAe,EAAE,CAAC"}
@@ -0,0 +1,187 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { CloudflareClients } from "../client/clients.js";
4
+ import { listEmailRoutingRules, namedRules } from "./emailRoutingRules.js";
5
+ import { emptyTestBucket, staleTestResourceNames, testResourceAge } from "./harness.js";
6
+ import { Cloudflare } from "cloudflare";
7
+ //#region src/test-utils/reap.ts
8
+ /** Whether a plan entry is a stand-in for a kind that cannot run. */
9
+ function isSkipped(entry) {
10
+ return "skipped" in entry;
11
+ }
12
+ /** Every label in a plan, in plan order — what a test asserts against so a dropped kind is visible. */
13
+ function reapPlanLabels(plan) {
14
+ return plan.map((entry) => entry.label);
15
+ }
16
+ /**
17
+ * Sweep every kind in a plan and report what went.
18
+ *
19
+ * Never throws, and never lets one kind's failure end the sweep: a reaper exists to help a suite, and a
20
+ * housekeeping error that fails the run is worse than the debris it was cleaning. A listing that throws
21
+ * yields an empty result for that kind; a removal that throws lands in `failed`, where it is visible.
22
+ */
23
+ async function reapKinds(plan, options = {}) {
24
+ const results = [];
25
+ for (const entry of plan) {
26
+ if (isSkipped(entry)) {
27
+ results.push({
28
+ label: entry.label,
29
+ reaped: [],
30
+ failed: [],
31
+ skipped: entry.skipped
32
+ });
33
+ continue;
34
+ }
35
+ const reaped = [];
36
+ const failed = [];
37
+ let names;
38
+ try {
39
+ names = await entry.list();
40
+ } catch {
41
+ results.push({
42
+ label: entry.label,
43
+ reaped,
44
+ failed,
45
+ skipped: null
46
+ });
47
+ continue;
48
+ }
49
+ for (const name of staleTestResourceNames(names, options)) try {
50
+ await entry.remove(name);
51
+ reaped.push(name);
52
+ } catch {
53
+ failed.push(name);
54
+ }
55
+ results.push({
56
+ label: entry.label,
57
+ reaped,
58
+ failed,
59
+ skipped: null
60
+ });
61
+ }
62
+ return results;
63
+ }
64
+ /**
65
+ * Build the sweep plan for an account.
66
+ *
67
+ * Alphabetical by label, so the set is readable as a list rather than as an accident of construction
68
+ * order, and so a test can pin it. Every kind appears whether or not it can run — a kind that is missing
69
+ * a credential reports *why*, because "nothing to reap" and "unable to reap" look identical in a log and
70
+ * only one of them is fine.
71
+ *
72
+ * R2 is the one kind an API token cannot reclaim: Cloudflare refuses to delete a non-empty bucket, and
73
+ * emptying one is an S3-protocol operation. So it needs the key pair, and says so when it lacks it.
74
+ */
75
+ function testResourceReapPlan(creds, options = {}) {
76
+ const clients = new CloudflareClients({
77
+ accountId: creds.accountId,
78
+ apiToken: creds.apiToken
79
+ });
80
+ const sdk = new Cloudflare({ apiToken: creds.apiToken });
81
+ const workers = clients.workers();
82
+ const tokens = clients.accountTokens();
83
+ const d1 = clients.d1Provisioner();
84
+ const kv = clients.kvProvisioner();
85
+ const vectorize = clients.vectorizeProvisioner();
86
+ const r2 = clients.r2Provisioner();
87
+ return [
88
+ {
89
+ label: "API token",
90
+ list: async () => (await tokens.listTokens()).map((token) => token.name),
91
+ remove: async (name) => {
92
+ await tokens.deleteTokensByName(name);
93
+ }
94
+ },
95
+ {
96
+ label: "D1 database",
97
+ list: async () => (await d1.listDatabases()).map((database) => database.name),
98
+ remove: async (name) => {
99
+ const found = (await d1.listDatabases()).find((database) => database.name === name);
100
+ if (found) await d1.deleteDatabase(found.uuid);
101
+ }
102
+ },
103
+ options.emailRoutingZoneId ? {
104
+ label: "Email Routing rule",
105
+ list: async () => namedRules(await listEmailRoutingRules(creds, options.emailRoutingZoneId ?? "")).map((rule) => rule.name),
106
+ remove: async (name) => {
107
+ await clients.emailRouting().removeWorkerRoute({
108
+ zoneId: options.emailRoutingZoneId ?? "",
109
+ ruleName: name
110
+ });
111
+ }
112
+ } : {
113
+ label: "Email Routing rule",
114
+ skipped: "no EMAIL_ROUTING_ZONE_ID: the zone to sweep is unknown."
115
+ },
116
+ {
117
+ label: "KV namespace",
118
+ list: async () => (await kv.listNamespaces()).map((namespace) => namespace.title),
119
+ remove: async (title) => {
120
+ const found = (await kv.listNamespaces()).find((namespace) => namespace.title === title);
121
+ if (found) await kv.deleteNamespace(found.id);
122
+ }
123
+ },
124
+ {
125
+ label: "Queue",
126
+ list: async () => {
127
+ const names = [];
128
+ for await (const queue of sdk.queues.list({ account_id: creds.accountId })) if (queue.queue_name) names.push(queue.queue_name);
129
+ return names;
130
+ },
131
+ remove: async (name) => {
132
+ for await (const queue of sdk.queues.list({ account_id: creds.accountId })) if (queue.queue_name === name && queue.queue_id) {
133
+ await sdk.queues.delete(queue.queue_id, { account_id: creds.accountId });
134
+ return;
135
+ }
136
+ }
137
+ },
138
+ creds.r2 ? {
139
+ label: "R2 bucket",
140
+ list: async () => (await r2.listBuckets()).map((bucket) => bucket.name),
141
+ remove: async (name) => {
142
+ await emptyTestBucket(creds, name);
143
+ await r2.deleteBucket(name);
144
+ }
145
+ } : {
146
+ label: "R2 bucket",
147
+ skipped: "no R2_CREDENTIALS: a bucket cannot be emptied, and R2 refuses to delete a non-empty one."
148
+ },
149
+ creds.secretsStoreId ? {
150
+ label: "Secrets Store entry",
151
+ list: async () => (await clients.secrets(creds.secretsStoreId).listSecrets()).map((entry) => entry.name),
152
+ remove: async (name) => {
153
+ await clients.secrets(creds.secretsStoreId).deleteSecretIfPresent(name);
154
+ }
155
+ } : {
156
+ label: "Secrets Store entry",
157
+ skipped: "no SECRETS_STORE_ID: the store to sweep is unknown."
158
+ },
159
+ {
160
+ label: "Vectorize index",
161
+ list: async () => (await vectorize.listIndexes()).map((index) => index.name),
162
+ remove: (name) => vectorize.deleteIndex(name)
163
+ },
164
+ {
165
+ label: "Worker script",
166
+ list: async () => (await workers.listWorkers()).map((script) => script.id ?? "").filter(Boolean),
167
+ remove: (name) => workers.deleteWorker(name)
168
+ }
169
+ ];
170
+ }
171
+ /**
172
+ * Reclaim every stale throwaway resource on the account, across every kind.
173
+ *
174
+ * The entry point a `globalSetup` calls. Reports one line per kind that did something or could not run;
175
+ * a kind with nothing to do stays quiet, because a clean account should produce a clean log.
176
+ */
177
+ async function reapAllStaleTestResources(creds, options = {}) {
178
+ const results = await reapKinds(testResourceReapPlan(creds, options), options);
179
+ for (const result of results) {
180
+ if (result.skipped) console.warn(`stale ${result.label}(s) were not swept: ${result.skipped}`);
181
+ if (result.reaped.length > 0) console.warn(`reaped ${result.reaped.length} stale ${result.label}(s): ${result.reaped.join(", ")}`);
182
+ if (result.failed.length > 0) console.warn(`could not reap ${result.failed.length} stale ${result.label}(s): ${result.failed.join(", ")}`);
183
+ }
184
+ return results;
185
+ }
186
+ //#endregion
187
+ export { reapAllStaleTestResources, reapKinds, reapPlanLabels, testResourceAge, testResourceReapPlan };