@pithy-sh/cloudflare 0.7.0 → 0.8.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.
@@ -74,6 +74,16 @@ export type VectorizeIndexSpec = {
74
74
  } | {
75
75
  preset: VectorizePreset;
76
76
  };
77
+ /**
78
+ * Whether an error means "that metadata index is already there": `400` with error code 40004, the answer to
79
+ * creating a property that is already indexed. Narrow for the same reason {@link isAbsentMetadataIndexError} is:
80
+ * every other `400` stays an error.
81
+ *
82
+ * It is how a create retried by the SDK comes back. The SDK retries a request that timed out, lost its
83
+ * connection, or answered 408, 409, 429 or a 5xx, twice by default and a POST included, so a create Cloudflare
84
+ * applied can be sent again and refused as a duplicate of itself.
85
+ */
86
+ export declare function isExistingMetadataIndexError(error: unknown): boolean;
77
87
  /** Options a new index accepts beyond its name and shape. */
78
88
  export interface CreateVectorizeIndexOptions {
79
89
  /** A human description stored on the index, surfaced in the Cloudflare dashboard. */
@@ -111,6 +121,12 @@ export declare class CloudflareVectorizeProvisioner extends CloudflareManager {
111
121
  * Make a metadata property filterable. Cloudflare caps an index at 10 indexed properties, and only vectors
112
122
  * written **after** the metadata index exists are covered — so declare these before the first insert.
113
123
  * The change is enqueued asynchronously; the returned promise resolving means accepted, not live.
124
+ *
125
+ * Idempotent, like {@link deleteMetadataIndex}. A property already indexed answers `400`/40004
126
+ * ({@link isExistingMetadataIndexError}); the list then decides. Listed with the same type, the create has
127
+ * already happened and this returns. Listed with another type, it throws `core/conflict` naming both, since a
128
+ * metadata index's type is fixed. Not listed yet, it returns too: Cloudflare has just said the index exists,
129
+ * the list lags, and the caller's own settle wait reads its type.
114
130
  */
115
131
  createMetadataIndex(indexName: string, propertyName: string, indexType: VectorizeMetadataIndexType): Promise<void>;
116
132
  /** List an index's indexed metadata properties — the read that proves a `createMetadataIndex` has landed. */
@@ -1 +1 @@
1
- {"version":3,"file":"vectorizeProvisioner.d.ts","sourceRoot":"","sources":["../../src/ai/vectorizeProvisioner.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtD,kHAAkH;AAClH,eAAO,MAAM,eAAe;;;;EAEgF,CAAC;AAC7G,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,eAAe;;;;;;EAQiF,CAAC;AAC9G,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,4GAA4G;AAC5G,eAAO,MAAM,oBAAoB;;;;;;;iBAS2E,CAAC;AAC7G,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAEzE,0FAA0F;AAC1F,eAAO,MAAM,kBAAkB;;;;;;;;;;;iBAMqF,CAAC;AACrH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE,sHAAsH;AACtH,eAAO,MAAM,0BAA0B;;;;EAEqE,CAAC;AAC7G,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAiBpF,2GAA2G;AAC3G,eAAO,MAAM,sBAAsB;;;;;;;iBAK+E,CAAC;AACnH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE7E;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC;AAuC/G,6DAA6D;AAC7D,MAAM,WAAW,2BAA2B;IAC1C,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,8BAA+B,SAAQ,iBAAiB;IACnE,cAAc,IAAI,MAAM,CAEvB;IAED,iGAAiG;IAC3F,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED,uGAAuG;IACjG,WAAW,CACf,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAAC,kBAAkB,CAAC,CAU7B;IAED,qHAAqH;IAC/G,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS7C;IAED,uGAAuG;IACjG,WAAW,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAQjD;IAED;;;;;OAKG;IACG,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAUtE;IAED;;;;OAIG;IACG,mBAAmB,CACvB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,0BAA0B,GACpC,OAAO,CAAC,IAAI,CAAC,CAQf;IAED,6GAA6G;IACvG,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAS9E;IAED;;;;OAIG;IACG,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAYhF;CACF"}
1
+ {"version":3,"file":"vectorizeProvisioner.d.ts","sourceRoot":"","sources":["../../src/ai/vectorizeProvisioner.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtD,kHAAkH;AAClH,eAAO,MAAM,eAAe;;;;EAEgF,CAAC;AAC7G,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,eAAe;;;;;;EAQiF,CAAC;AAC9G,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,4GAA4G;AAC5G,eAAO,MAAM,oBAAoB;;;;;;;iBAS2E,CAAC;AAC7G,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAEzE,0FAA0F;AAC1F,eAAO,MAAM,kBAAkB;;;;;;;;;;;iBAMqF,CAAC;AACrH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE,sHAAsH;AACtH,eAAO,MAAM,0BAA0B;;;;EAEqE,CAAC;AAC7G,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAiBpF,2GAA2G;AAC3G,eAAO,MAAM,sBAAsB;;;;;;;iBAK+E,CAAC;AACnH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE7E;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC;AA0C/G;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAKpE;AAED,6DAA6D;AAC7D,MAAM,WAAW,2BAA2B;IAC1C,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,8BAA+B,SAAQ,iBAAiB;IACnE,cAAc,IAAI,MAAM,CAEvB;IAED,iGAAiG;IAC3F,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED,uGAAuG;IACjG,WAAW,CACf,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAAC,kBAAkB,CAAC,CAU7B;IAED,qHAAqH;IAC/G,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS7C;IAED,uGAAuG;IACjG,WAAW,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAQjD;IAED;;;;;OAKG;IACG,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAUtE;IAED;;;;;;;;;;OAUG;IACG,mBAAmB,CACvB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,0BAA0B,GACpC,OAAO,CAAC,IAAI,CAAC,CAsBf;IAED,6GAA6G;IACvG,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAS9E;IAED;;;;OAIG;IACG,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAYhF;CACF"}
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { cloudflareRequest, decodeResponse, isNotFoundError } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
+ import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
5
6
  import { z } from "zod";
6
7
  //#region src/ai/vectorizeProvisioner.ts
7
8
  /** The distance metric an index scores nearest neighbors with. Fixed at creation — it cannot be changed later. */
@@ -87,6 +88,23 @@ function isAbsentMetadataIndexError(error) {
87
88
  if (status !== 400 || !Array.isArray(errors)) return false;
88
89
  return errors.some((entry) => entry?.code === METADATA_INDEX_ABSENT_CODE);
89
90
  }
91
+ /** Vectorize's error code for "a metadata index with that name already exists". */
92
+ const METADATA_INDEX_EXISTS_CODE = 40004;
93
+ /**
94
+ * Whether an error means "that metadata index is already there": `400` with error code 40004, the answer to
95
+ * creating a property that is already indexed. Narrow for the same reason {@link isAbsentMetadataIndexError} is:
96
+ * every other `400` stays an error.
97
+ *
98
+ * It is how a create retried by the SDK comes back. The SDK retries a request that timed out, lost its
99
+ * connection, or answered 408, 409, 429 or a 5xx, twice by default and a POST included, so a create Cloudflare
100
+ * applied can be sent again and refused as a duplicate of itself.
101
+ */
102
+ function isExistingMetadataIndexError(error) {
103
+ if (typeof error !== "object" || error === null) return false;
104
+ const { status, errors } = error;
105
+ if (status !== 400 || !Array.isArray(errors)) return false;
106
+ return errors.some((entry) => entry?.code === METADATA_INDEX_EXISTS_CODE);
107
+ }
90
108
  /**
91
109
  * Account-level Vectorize control plane: **create and delete indexes, and manage their metadata indexes**.
92
110
  * `pithy vector provision` uses this to stand up a per-environment index and declare which metadata
@@ -161,13 +179,32 @@ var CloudflareVectorizeProvisioner = class extends CloudflareManager {
161
179
  * Make a metadata property filterable. Cloudflare caps an index at 10 indexed properties, and only vectors
162
180
  * written **after** the metadata index exists are covered — so declare these before the first insert.
163
181
  * The change is enqueued asynchronously; the returned promise resolving means accepted, not live.
182
+ *
183
+ * Idempotent, like {@link deleteMetadataIndex}. A property already indexed answers `400`/40004
184
+ * ({@link isExistingMetadataIndexError}); the list then decides. Listed with the same type, the create has
185
+ * already happened and this returns. Listed with another type, it throws `core/conflict` naming both, since a
186
+ * metadata index's type is fixed. Not listed yet, it returns too: Cloudflare has just said the index exists,
187
+ * the list lags, and the caller's own settle wait reads its type.
164
188
  */
165
189
  async createMetadataIndex(indexName, propertyName, indexType) {
166
- await cloudflareRequest(`create Vectorize metadata index ${indexName}.${propertyName}`, () => this.getClient().vectorize.indexes.metadataIndex.create(indexName, {
167
- account_id: this.accountId,
168
- indexType,
169
- propertyName
170
- }));
190
+ await cloudflareRequest(`create Vectorize metadata index ${indexName}.${propertyName}`, async () => {
191
+ try {
192
+ await this.getClient().vectorize.indexes.metadataIndex.create(indexName, {
193
+ account_id: this.accountId,
194
+ indexType,
195
+ propertyName
196
+ });
197
+ } catch (error) {
198
+ if (!isExistingMetadataIndexError(error)) throw error;
199
+ const live = (await this.listMetadataIndexes(indexName)).find((entry) => entry.propertyName === propertyName);
200
+ if (live === void 0 || live.indexType === indexType) return;
201
+ throw new ConflictError({
202
+ message: `Metadata index ${indexName}.${propertyName} already exists as ${live.indexType}, not ${indexType}.`,
203
+ action: `A metadata index's type is fixed. Declare ${propertyName} as ${live.indexType}, or delete the metadata index and create it again.`,
204
+ detail: `Cloudflare answered 40004 to a create of ${propertyName} as ${indexType}; the list holds it as ${live.indexType}.`
205
+ }, { cause: error });
206
+ }
207
+ });
171
208
  }
172
209
  /** List an index's indexed metadata properties — the read that proves a `createMetadataIndex` has landed. */
173
210
  async listMetadataIndexes(indexName) {
@@ -194,4 +231,4 @@ var CloudflareVectorizeProvisioner = class extends CloudflareManager {
194
231
  }
195
232
  };
196
233
  //#endregion
197
- export { CloudflareVectorizeProvisioner, VectorizeIndexConfig, VectorizeIndexInfo, VectorizeMetadataIndex, VectorizeMetadataIndexType, VectorizeMetric, VectorizePreset };
234
+ export { CloudflareVectorizeProvisioner, VectorizeIndexConfig, VectorizeIndexInfo, VectorizeMetadataIndex, VectorizeMetadataIndexType, VectorizeMetric, VectorizePreset, isExistingMetadataIndexError };
@@ -23,6 +23,13 @@ export declare class CloudflareArtifactsManager extends CloudflareManager {
23
23
  * clean no-op rather than a failure.
24
24
  */
25
25
  listRepos(namespace: string): Promise<string[]>;
26
+ /**
27
+ * Every namespace name on the account, across every page. The live suites' debris sweep reads it to find the
28
+ * `pithy-int-` namespaces an aborted run left behind, since nothing else lists them.
29
+ */
30
+ listNamespaces(): Promise<string[]>;
31
+ /** Every `name` a paged listing answers, following its cursor. A 404 is an empty listing. */
32
+ private names;
26
33
  /**
27
34
  * Delete one repo and every token on it. **Resolves whether one was there**, so teardown re-runs safely: a repo
28
35
  * already gone is `false`, not an error. Cloudflare answers `202 Accepted`, so the storage may take a moment to
@@ -1 +1 @@
1
- {"version":3,"file":"artifactsManager.d.ts","sourceRoot":"","sources":["../../src/artifacts/artifactsManager.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAoCtD;;;;;;;;;;;GAWG;AACH,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,gFAAgF;IAC1E,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;OAIG;IACG,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAmBpD;IAED;;;;OAIG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOlE;IAED;;;;;;;;;OASG;IACG,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,QAAQ,GAAG,UAAU,CAAC,CAOnF;IAED,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,IAAI;IAIZ;;;;OAIG;YACW,IAAI;CAwCnB"}
1
+ {"version":3,"file":"artifactsManager.d.ts","sourceRoot":"","sources":["../../src/artifacts/artifactsManager.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAoCtD;;;;;;;;;;;GAWG;AACH,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,gFAAgF;IAC1E,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;OAIG;IACG,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAKpD;IAED;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAIxC;IAED,6FAA6F;YAC/E,KAAK;IAkBnB;;;;OAIG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOlE;IAED;;;;;;;;;OASG;IACG,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,QAAQ,GAAG,UAAU,CAAC,CAOnF;IAED,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,IAAI;IAIZ;;;;OAIG;YACW,IAAI;CAwCnB"}
@@ -23,8 +23,8 @@ const Envelope = z.object({
23
23
  errors: z.array(z.unknown()).optional().describe("Cloudflare's own error entries."),
24
24
  result_info: z.object({ cursor: z.string().nullable().optional().describe("The next page's cursor, when there is one.") }).loose().optional().describe("Pagination, for a listing.")
25
25
  }).describe("A Cloudflare REST envelope.");
26
- /** One page of `GET …/repos`: only the name of each repo is read. */
27
- const RepoPage = z.array(z.object({ name: z.string().describe("The repo's name in the namespace.") }).loose()).describe("One page of an Artifacts namespace's repos.");
26
+ /** One page of `GET …/repos` or `GET …/namespaces`: only the name of each entry is read. */
27
+ const NamedPage = z.array(z.object({ name: z.string().describe("The repo's or the namespace's name.") }).loose()).describe("One page of an Artifacts listing of repos or namespaces.");
28
28
  /**
29
29
  * Account-level Artifacts control plane: **list and delete the repos in a namespace, then the namespace** (#673).
30
30
  *
@@ -56,21 +56,30 @@ var CloudflareArtifactsManager = class extends CloudflareManager {
56
56
  */
57
57
  async listRepos(namespace) {
58
58
  assertName(namespace, "namespace");
59
- return cloudflareRequest(`list Artifacts repos in ${namespace}`, async () => {
60
- const names = /* @__PURE__ */ new Set();
61
- const followed = /* @__PURE__ */ new Set();
62
- let cursor;
63
- for (;;) {
64
- const query = `limit=${PAGE_LIMIT}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
65
- const page = await this.call("GET", `${this.namespacePath(namespace)}/repos?${query}`);
66
- if (page === null) return [...names];
67
- for (const repo of decodeResponse(RepoPage, page.result, "Artifacts repo list")) names.add(repo.name);
68
- const next = page.result_info?.cursor ?? void 0;
69
- if (!next || followed.has(next)) return [...names];
70
- followed.add(next);
71
- cursor = next;
72
- }
73
- });
59
+ return cloudflareRequest(`list Artifacts repos in ${namespace}`, () => this.names(`${this.namespacePath(namespace)}/repos`, "Artifacts repo list"));
60
+ }
61
+ /**
62
+ * Every namespace name on the account, across every page. The live suites' debris sweep reads it to find the
63
+ * `pithy-int-` namespaces an aborted run left behind, since nothing else lists them.
64
+ */
65
+ async listNamespaces() {
66
+ return cloudflareRequest("list Artifacts namespaces", () => this.names(`/accounts/${this.getAccountId()}/artifacts/namespaces`, "Artifacts namespace list"));
67
+ }
68
+ /** Every `name` a paged listing answers, following its cursor. A 404 is an empty listing. */
69
+ async names(path, what) {
70
+ const names = /* @__PURE__ */ new Set();
71
+ const followed = /* @__PURE__ */ new Set();
72
+ let cursor;
73
+ for (;;) {
74
+ const query = `limit=${PAGE_LIMIT}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
75
+ const page = await this.call("GET", `${path}?${query}`);
76
+ if (page === null) return [...names];
77
+ for (const entry of decodeResponse(NamedPage, page.result, what)) names.add(entry.name);
78
+ const next = page.result_info?.cursor ?? void 0;
79
+ if (!next || followed.has(next)) return [...names];
80
+ followed.add(next);
81
+ cursor = next;
82
+ }
74
83
  }
75
84
  /**
76
85
  * Delete one repo and every token on it. **Resolves whether one was there**, so teardown re-runs safely: a repo
@@ -7,6 +7,24 @@ export declare const R2BucketInfo: z.ZodObject<{
7
7
  name: z.ZodString;
8
8
  }, z.core.$strip>;
9
9
  export type R2BucketInfo = z.output<typeof R2BucketInfo>;
10
+ /** One R2 bucket lock rule: objects under `prefix` cannot be overwritten or deleted while its condition holds. */
11
+ export declare const R2LockRule: z.ZodObject<{
12
+ id: z.ZodString;
13
+ enabled: z.ZodBoolean;
14
+ prefix: z.ZodOptional<z.ZodString>;
15
+ condition: z.ZodDiscriminatedUnion<[z.ZodObject<{
16
+ type: z.ZodLiteral<"Age">;
17
+ maxAgeSeconds: z.ZodNumber;
18
+ }, z.core.$strip>, z.ZodObject<{
19
+ type: z.ZodLiteral<"Date">;
20
+ date: z.ZodString;
21
+ }, z.core.$strip>, z.ZodObject<{
22
+ type: z.ZodLiteral<"Indefinite">;
23
+ }, z.core.$strip>], "type">;
24
+ }, z.core.$strip>;
25
+ export type R2LockRule = z.output<typeof R2LockRule>;
26
+ /** What asking for an indefinite lock did. */
27
+ export type R2LockOutcome = "locked" | "already";
10
28
  /**
11
29
  * Account-level R2 control plane: **create and delete buckets**. Customers use this to stand up and
12
30
  * tear down per-environment buckets (e.g. ephemeral staging), and worktree teardown uses it to
@@ -29,6 +47,18 @@ export declare class CloudflareR2Provisioner extends CloudflareManager {
29
47
  listBuckets(): Promise<R2BucketInfo[]>;
30
48
  /** Create an R2 bucket by name; returns its name. */
31
49
  createBucket(name: string): Promise<R2BucketInfo>;
50
+ /** The bucket's lock rules. A bucket with none answers an empty list. */
51
+ bucketLockRules(name: string): Promise<R2LockRule[]>;
52
+ /** Whether some enabled rule locks every object under `prefix` indefinitely. */
53
+ static locksPrefixIndefinitely(rules: readonly R2LockRule[], prefix: string): boolean;
54
+ /** Whether the bucket's rules lock every object under `prefix` indefinitely, read from the account. */
55
+ prefixLockedIndefinitely(name: string, prefix: string): Promise<boolean>;
56
+ /**
57
+ * Lock every object under `prefix` indefinitely, beside whatever rules the bucket already has. Idempotent: a
58
+ * rule already in force is left alone and answered `already`. The update replaces the bucket's whole rule set,
59
+ * so the existing rules are read and written back with the new one — never dropped.
60
+ */
61
+ lockPrefixIndefinitely(name: string, prefix: string, id: string): Promise<R2LockOutcome>;
32
62
  /**
33
63
  * Delete an R2 bucket by name. Idempotent — a missing bucket is not an error, so teardown can re-run safely.
34
64
  *
@@ -1 +1 @@
1
- {"version":3,"file":"r2Provisioner.d.ts","sourceRoot":"","sources":["../../src/r2/r2Provisioner.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtD,6GAA6G;AAC7G,eAAO,MAAM,YAAY;;iBAIkE,CAAC;AAC5F,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,YAAY,CAAC,CAAC;AAEzD;;;;;;GAMG;AACH,qBAAa,uBAAwB,SAAQ,iBAAiB;IAC5D,cAAc,IAAI,MAAM,CAEvB;IAED,iGAAiG;IAC3F,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED,2FAA2F;IACrF,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAGjE;IAED;;;;;OAKG;IACG,WAAW,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAsB3C;IAED,qDAAqD;IAC/C,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAKtD;IAED;;;;;;OAMG;IACG,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS9C;CACF"}
1
+ {"version":3,"file":"r2Provisioner.d.ts","sourceRoot":"","sources":["../../src/r2/r2Provisioner.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtD,6GAA6G;AAC7G,eAAO,MAAM,YAAY;;iBAIkE,CAAC;AAC5F,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,YAAY,CAAC,CAAC;AAEzD,kHAAkH;AAClH,eAAO,MAAM,UAAU;;;;;;;;;;;;;iBAuBe,CAAC;AACvC,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAErD,8CAA8C;AAC9C,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEjD;;;;;;GAMG;AACH,qBAAa,uBAAwB,SAAQ,iBAAiB;IAC5D,cAAc,IAAI,MAAM,CAEvB;IAED,iGAAiG;IAC3F,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED,2FAA2F;IACrF,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAGjE;IAED;;;;;OAKG;IACG,WAAW,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAsB3C;IAED,qDAAqD;IAC/C,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAKtD;IAED,yEAAyE;IACnE,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAWzD;IAED,gFAAgF;IAChF,MAAM,CAAC,uBAAuB,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAIpF;IAED,uGAAuG;IACjG,wBAAwB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE7E;IAED;;;;OAIG;IACG,sBAAsB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAW7F;IAED;;;;;;OAMG;IACG,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS9C;CACF"}
@@ -6,6 +6,23 @@ import { z } from "zod";
6
6
  //#region src/r2/r2Provisioner.ts
7
7
  /** An R2 bucket's identity, decoded from the create/list response. Buckets are addressed by name, not id. */
8
8
  const R2BucketInfo = z.object({ name: z.string().describe("The bucket name — R2's sole address for a bucket (no separate uuid id).") }).describe("A Cloudflare R2 bucket's identity, as returned by the create/list endpoints.");
9
+ /** One R2 bucket lock rule: objects under `prefix` cannot be overwritten or deleted while its condition holds. */
10
+ const R2LockRule = z.object({
11
+ id: z.string().describe("The rule's id, unique within the bucket."),
12
+ enabled: z.boolean().describe("Whether the rule is in force."),
13
+ prefix: z.string().optional().describe("The key prefix it covers. Absent or empty covers every object."),
14
+ condition: z.discriminatedUnion("type", [
15
+ z.object({
16
+ type: z.literal("Age").describe("Locked for a time after each object is written."),
17
+ maxAgeSeconds: z.number().describe("How long, in seconds.")
18
+ }).describe("Locked for an age."),
19
+ z.object({
20
+ type: z.literal("Date").describe("Locked until a date."),
21
+ date: z.string().describe("The date, ISO-8601.")
22
+ }).describe("Locked until a date."),
23
+ z.object({ type: z.literal("Indefinite").describe("Locked for ever.") }).describe("Locked for ever.")
24
+ ]).describe("How long the objects stay locked.")
25
+ }).describe("An R2 bucket lock rule.");
9
26
  /**
10
27
  * Account-level R2 control plane: **create and delete buckets**. Customers use this to stand up and
11
28
  * tear down per-environment buckets (e.g. ephemeral staging), and worktree teardown uses it to
@@ -13,7 +30,7 @@ const R2BucketInfo = z.object({ name: z.string().describe("The bucket name — R
13
30
  * one already-provisioned bucket (presigned object URLs), this manager is account-scoped and
14
31
  * addresses buckets **by name** — R2 buckets carry no separate uuid id the way D1 databases do.
15
32
  */
16
- var CloudflareR2Provisioner = class extends CloudflareManager {
33
+ var CloudflareR2Provisioner = class CloudflareR2Provisioner extends CloudflareManager {
17
34
  getServiceType() {
18
35
  return "Cloudflare R2 (control plane)";
19
36
  }
@@ -66,6 +83,39 @@ var CloudflareR2Provisioner = class extends CloudflareManager {
66
83
  }));
67
84
  return decodeResponse(R2BucketInfo, response, "R2 bucket create");
68
85
  }
86
+ /** The bucket's lock rules. A bucket with none answers an empty list. */
87
+ async bucketLockRules(name) {
88
+ const response = await cloudflareRequest(`read lock rules of R2 bucket ${name}`, () => this.getClient().r2.buckets.locks.get(name, { account_id: this.accountId }));
89
+ return decodeResponse(z.object({ rules: z.array(R2LockRule).optional() }).describe("A bucket's lock rules."), response, "R2 bucket lock rules").rules ?? [];
90
+ }
91
+ /** Whether some enabled rule locks every object under `prefix` indefinitely. */
92
+ static locksPrefixIndefinitely(rules, prefix) {
93
+ return rules.some((rule) => rule.enabled && rule.condition.type === "Indefinite" && prefix.startsWith(rule.prefix ?? ""));
94
+ }
95
+ /** Whether the bucket's rules lock every object under `prefix` indefinitely, read from the account. */
96
+ async prefixLockedIndefinitely(name, prefix) {
97
+ return CloudflareR2Provisioner.locksPrefixIndefinitely(await this.bucketLockRules(name), prefix);
98
+ }
99
+ /**
100
+ * Lock every object under `prefix` indefinitely, beside whatever rules the bucket already has. Idempotent: a
101
+ * rule already in force is left alone and answered `already`. The update replaces the bucket's whole rule set,
102
+ * so the existing rules are read and written back with the new one — never dropped.
103
+ */
104
+ async lockPrefixIndefinitely(name, prefix, id) {
105
+ const rules = await this.bucketLockRules(name);
106
+ if (CloudflareR2Provisioner.locksPrefixIndefinitely(rules, prefix)) return "already";
107
+ const next = [...rules.filter((rule) => rule.id !== id), {
108
+ id,
109
+ enabled: true,
110
+ prefix,
111
+ condition: { type: "Indefinite" }
112
+ }];
113
+ await cloudflareRequest(`lock R2 bucket ${name}`, () => this.getClient().r2.buckets.locks.update(name, {
114
+ account_id: this.accountId,
115
+ rules: next
116
+ }));
117
+ return "locked";
118
+ }
69
119
  /**
70
120
  * Delete an R2 bucket by name. Idempotent — a missing bucket is not an error, so teardown can re-run safely.
71
121
  *
@@ -85,4 +135,4 @@ var CloudflareR2Provisioner = class extends CloudflareManager {
85
135
  }
86
136
  };
87
137
  //#endregion
88
- export { CloudflareR2Provisioner, R2BucketInfo };
138
+ export { CloudflareR2Provisioner, R2BucketInfo, R2LockRule };
@@ -1,5 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
+ import type { CloudflareArtifactsManager } from "../artifacts/artifactsManager";
3
4
  import { type IntegrationCreds, type ReapableKind, testResourceAge } from "./harness";
4
5
  /**
5
6
  * The reap registry: every throwaway resource kind a live suite can mint, in one place, swept once per
@@ -87,6 +88,14 @@ export declare function reapKinds(plan: readonly ReapPlanEntry[], options?: {
87
88
  now?: number;
88
89
  staleAfterMs?: number;
89
90
  }): Promise<ReapKindResult[]>;
91
+ /**
92
+ * Remove one throwaway Artifacts namespace: every repository in it, then the namespace.
93
+ *
94
+ * Cloudflare refuses to delete a namespace that still holds a repository, and a repository delete is `202 Accepted`,
95
+ * so one just reaped can hold the namespace a moment longer. That case throws, so the sweep reports it as not reaped
96
+ * and the next run tries again, rather than waiting here on Cloudflare's clock. A namespace already gone is done.
97
+ */
98
+ export declare function reapArtifactsNamespace(artifacts: Pick<CloudflareArtifactsManager, "listRepos" | "deleteRepo" | "deleteNamespace">, namespace: string): Promise<void>;
90
99
  /**
91
100
  * Build the sweep plan for an account.
92
101
  *
@@ -1 +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"}
1
+ {"version":3,"file":"reap.d.ts","sourceRoot":"","sources":["../../src/test-utils/reap.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+BAA+B,CAAC;AAGhF,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;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,SAAS,EAAE,IAAI,CAAC,0BAA0B,EAAE,WAAW,GAAG,YAAY,GAAG,iBAAiB,CAAC,EAC3F,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAQf;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,GAAE,SAAc,GAAG,aAAa,EAAE,CAyHtG;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"}
@@ -3,6 +3,7 @@
3
3
  import { CloudflareClients } from "../client/clients.js";
4
4
  import { listEmailRoutingRules, namedRules } from "./emailRoutingRules.js";
5
5
  import { emptyTestBucket, staleTestResourceNames, testResourceAge } from "./harness.js";
6
+ import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
6
7
  import { Cloudflare } from "cloudflare";
7
8
  //#region src/test-utils/reap.ts
8
9
  /** Whether a plan entry is a stand-in for a kind that cannot run. */
@@ -62,6 +63,20 @@ async function reapKinds(plan, options = {}) {
62
63
  return results;
63
64
  }
64
65
  /**
66
+ * Remove one throwaway Artifacts namespace: every repository in it, then the namespace.
67
+ *
68
+ * Cloudflare refuses to delete a namespace that still holds a repository, and a repository delete is `202 Accepted`,
69
+ * so one just reaped can hold the namespace a moment longer. That case throws, so the sweep reports it as not reaped
70
+ * and the next run tries again, rather than waiting here on Cloudflare's clock. A namespace already gone is done.
71
+ */
72
+ async function reapArtifactsNamespace(artifacts, namespace) {
73
+ for (const repo of await artifacts.listRepos(namespace)) await artifacts.deleteRepo(namespace, repo);
74
+ if (await artifacts.deleteNamespace(namespace) === "occupied") throw new ConflictError({
75
+ message: `Artifacts namespace ${namespace} still holds a repository.`,
76
+ detail: "a repository delete is 202 Accepted; the next sweep deletes the namespace once it is released"
77
+ });
78
+ }
79
+ /**
65
80
  * Build the sweep plan for an account.
66
81
  *
67
82
  * Alphabetical by label, so the set is readable as a list rather than as an accident of construction
@@ -92,6 +107,11 @@ function testResourceReapPlan(creds, options = {}) {
92
107
  await tokens.deleteTokensByName(name);
93
108
  }
94
109
  },
110
+ {
111
+ label: "Artifacts namespace",
112
+ list: () => clients.artifacts().listNamespaces(),
113
+ remove: (name) => reapArtifactsNamespace(clients.artifacts(), name)
114
+ },
95
115
  {
96
116
  label: "D1 database",
97
117
  list: async () => (await d1.listDatabases()).map((database) => database.name),
@@ -184,4 +204,4 @@ async function reapAllStaleTestResources(creds, options = {}) {
184
204
  return results;
185
205
  }
186
206
  //#endregion
187
- export { reapAllStaleTestResources, reapKinds, reapPlanLabels, testResourceAge, testResourceReapPlan };
207
+ export { reapAllStaleTestResources, reapArtifactsNamespace, reapKinds, reapPlanLabels, testResourceAge, testResourceReapPlan };
@@ -2,12 +2,21 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  /**
4
4
  * **Waiting on an eventually consistent control plane, for the live suites.** One budget and one rule, shared
5
- * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, because the cloudflare suite kept a
6
- * 60s budget and a single confirming read of its own and failed on exactly that (#709).
5
+ * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, so neither keeps a shorter budget or a
6
+ * single confirming read of its own.
7
7
  *
8
- * **The budget is 120s, and 300s for a metadata index.** A Vectorize index became visible 25-50s after the
9
- * call that made it, measured live, and an upsert became queryable in about 30s. A metadata index is slower:
10
- * about 97s on one run of #709 and over 120s on the next, so its waits take {@link METADATA_INDEX_SETTLE_MS}.
8
+ * **The budget is 120s, 300s for an upsert, and 600s for a metadata index.** A Vectorize index became visible
9
+ * 25-50s after the call that made it, measured live. An upsert usually becomes queryable in about 30s, but one
10
+ * written right after a metadata index was still unqueryable at 121s, with the vectors before it visible, so the
11
+ * waits for an upsert take {@link UPSERT_SETTLE_MS}. A metadata index is the slowest: about 97s on one run, over
12
+ * 120s on the next, and one of two still missing at 300s on a third. Cloudflare documents no readiness time and no
13
+ * readiness signal for one, so its waits take {@link METADATA_INDEX_SETTLE_MS}, twice the longest measured.
14
+ *
15
+ * **An upsert is waited on by its outcome, never by its mutation id.** `describe()` reports a
16
+ * `processedUpToMutation` watermark, and the manager reads it. The watermark moves through mutation ids the
17
+ * caller never issued, and a probe watched it step past ours one second after matching it. A mutation id is a
18
+ * UUID with no order, so "processed up to ours" cannot be computed from a later id, and a poll for equality
19
+ * misses the one moment it holds. So a wait polls the query until it returns what was written.
11
20
  *
12
21
  * **A metadata-index wait compares names and types, never a count.** {@link metadataIndexesSettled} is the
13
22
  * predicate; a list of the right length can still be the wrong list.
@@ -19,19 +28,25 @@
19
28
  * than a fresh read, which can disagree with the one before it.
20
29
  *
21
30
  * **A test that waits several times shares one deadline across them.** Each wait's own budget bounds that
22
- * wait and nothing else, so a test making eight of them in a row could outrun its vitest timeout with every
23
- * step inside budget. Vitest then abandoned the body mid-poll: the failure said only "Test timed out", and the
24
- * teardown that deletes the index never ran (#709). Pass the same {@link SettleOptions.deadline} to every
25
- * wait and the step in progress when it passes fails by name, inside the body, where the teardown still runs.
31
+ * wait and nothing else, so a test making eight of them in a row can outrun its vitest timeout with every
32
+ * step inside budget. Vitest then abandons the body mid-poll: the failure says only "Test timed out", and the
33
+ * teardown that deletes the index never runs. Pass the same {@link SettleOptions.deadline} to every wait, set
34
+ * below the test's vitest timeout, and the step in progress when it passes fails by name, inside the body,
35
+ * where the teardown still runs.
26
36
  */
27
- /** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
37
+ /** Ceiling for any one wait. Measured at 25-50s for a control-plane change. */
28
38
  export declare const SETTLE_TIMEOUT_MS = 120000;
29
39
  /**
30
- * Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
31
- * applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
32
- * undercut exactly this wait. `pithy vector provision` waits on the same measurement.
40
+ * Ceiling for a metadata-index wait. A metadata index is the slowest change Vectorize applies: one settled in
41
+ * about 97s, the next was unsettled at 120s, and a third was still missing at 300s. Twice that last figure, and
42
+ * `pithy vector provision` waits on the same number.
43
+ */
44
+ export declare const METADATA_INDEX_SETTLE_MS = 600000;
45
+ /**
46
+ * Ceiling for an upsert to become queryable. About 30s is usual, and one written right after a metadata index
47
+ * was still missing from the query at 121s, so the waits for an upsert take more than the shared budget.
33
48
  */
34
- export declare const METADATA_INDEX_SETTLE_MS = 300000;
49
+ export declare const UPSERT_SETTLE_MS = 300000;
35
50
  /**
36
51
  * Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
37
52
  * and two in a row six seconds apart is a far weaker coincidence.
@@ -1 +1 @@
1
- {"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../../src/test-utils/settle.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,SAAU,CAAC;AAEzC;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,SAAU,CAAC;AAEhD;;;GAGG;AACH,eAAO,MAAM,YAAY,IAAI,CAAC;AAE9B,oHAAoH;AACpH,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAEtC,+FAA+F;AAC/F,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kHAAkH;IAClH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAmBD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAYrH;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,EAC9B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,CAAC,CAAC,CAiBZ;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,GAC/D,CAAC,IAAI,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,OAAO,CAK3E"}
1
+ {"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../../src/test-utils/settle.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB,SAAU,CAAC;AAEzC;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,SAAU,CAAC;AAEhD;;;GAGG;AACH,eAAO,MAAM,gBAAgB,SAAU,CAAC;AAExC;;;GAGG;AACH,eAAO,MAAM,YAAY,IAAI,CAAC;AAE9B,oHAAoH;AACpH,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAEtC,+FAA+F;AAC/F,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kHAAkH;IAClH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAmBD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAYrH;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,EAC9B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,CAAC,CAAC,CAiBZ;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,GAC/D,CAAC,IAAI,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,OAAO,CAK3E"}
@@ -3,12 +3,21 @@
3
3
  //#region src/test-utils/settle.ts
4
4
  /**
5
5
  * **Waiting on an eventually consistent control plane, for the live suites.** One budget and one rule, shared
6
- * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, because the cloudflare suite kept a
7
- * 60s budget and a single confirming read of its own and failed on exactly that (#709).
6
+ * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, so neither keeps a shorter budget or a
7
+ * single confirming read of its own.
8
8
  *
9
- * **The budget is 120s, and 300s for a metadata index.** A Vectorize index became visible 25-50s after the
10
- * call that made it, measured live, and an upsert became queryable in about 30s. A metadata index is slower:
11
- * about 97s on one run of #709 and over 120s on the next, so its waits take {@link METADATA_INDEX_SETTLE_MS}.
9
+ * **The budget is 120s, 300s for an upsert, and 600s for a metadata index.** A Vectorize index became visible
10
+ * 25-50s after the call that made it, measured live. An upsert usually becomes queryable in about 30s, but one
11
+ * written right after a metadata index was still unqueryable at 121s, with the vectors before it visible, so the
12
+ * waits for an upsert take {@link UPSERT_SETTLE_MS}. A metadata index is the slowest: about 97s on one run, over
13
+ * 120s on the next, and one of two still missing at 300s on a third. Cloudflare documents no readiness time and no
14
+ * readiness signal for one, so its waits take {@link METADATA_INDEX_SETTLE_MS}, twice the longest measured.
15
+ *
16
+ * **An upsert is waited on by its outcome, never by its mutation id.** `describe()` reports a
17
+ * `processedUpToMutation` watermark, and the manager reads it. The watermark moves through mutation ids the
18
+ * caller never issued, and a probe watched it step past ours one second after matching it. A mutation id is a
19
+ * UUID with no order, so "processed up to ours" cannot be computed from a later id, and a poll for equality
20
+ * misses the one moment it holds. So a wait polls the query until it returns what was written.
12
21
  *
13
22
  * **A metadata-index wait compares names and types, never a count.** {@link metadataIndexesSettled} is the
14
23
  * predicate; a list of the right length can still be the wrong list.
@@ -20,19 +29,25 @@
20
29
  * than a fresh read, which can disagree with the one before it.
21
30
  *
22
31
  * **A test that waits several times shares one deadline across them.** Each wait's own budget bounds that
23
- * wait and nothing else, so a test making eight of them in a row could outrun its vitest timeout with every
24
- * step inside budget. Vitest then abandoned the body mid-poll: the failure said only "Test timed out", and the
25
- * teardown that deletes the index never ran (#709). Pass the same {@link SettleOptions.deadline} to every
26
- * wait and the step in progress when it passes fails by name, inside the body, where the teardown still runs.
32
+ * wait and nothing else, so a test making eight of them in a row can outrun its vitest timeout with every
33
+ * step inside budget. Vitest then abandons the body mid-poll: the failure says only "Test timed out", and the
34
+ * teardown that deletes the index never runs. Pass the same {@link SettleOptions.deadline} to every wait, set
35
+ * below the test's vitest timeout, and the step in progress when it passes fails by name, inside the body,
36
+ * where the teardown still runs.
27
37
  */
28
- /** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
38
+ /** Ceiling for any one wait. Measured at 25-50s for a control-plane change. */
29
39
  const SETTLE_TIMEOUT_MS = 12e4;
30
40
  /**
31
- * Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
32
- * applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
33
- * undercut exactly this wait. `pithy vector provision` waits on the same measurement.
41
+ * Ceiling for a metadata-index wait. A metadata index is the slowest change Vectorize applies: one settled in
42
+ * about 97s, the next was unsettled at 120s, and a third was still missing at 300s. Twice that last figure, and
43
+ * `pithy vector provision` waits on the same number.
44
+ */
45
+ const METADATA_INDEX_SETTLE_MS = 6e5;
46
+ /**
47
+ * Ceiling for an upsert to become queryable. About 30s is usual, and one written right after a metadata index
48
+ * was still missing from the query at 121s, so the waits for an upsert take more than the shared budget.
34
49
  */
35
- const METADATA_INDEX_SETTLE_MS = 3e5;
50
+ const UPSERT_SETTLE_MS = 3e5;
36
51
  /**
37
52
  * Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
38
53
  * and two in a row six seconds apart is a far weaker coincidence.
@@ -102,4 +117,4 @@ function metadataIndexesSettled(declared) {
102
117
  };
103
118
  }
104
119
  //#endregion
105
- export { METADATA_INDEX_SETTLE_MS, POLL_INTERVAL_MS, SETTLE_TIMEOUT_MS, STABLE_READS, metadataIndexesSettled, waitFor, waitForStable };
120
+ export { METADATA_INDEX_SETTLE_MS, POLL_INTERVAL_MS, SETTLE_TIMEOUT_MS, STABLE_READS, UPSERT_SETTLE_MS, metadataIndexesSettled, waitFor, waitForStable };
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * **Reading a live probe Worker's answer, for the suites that deploy one.** A probe Worker answers JSON, its
5
+ * own failures included. An HTML page in its place is Cloudflare's, not the Worker's: a workers.dev address
6
+ * that has not reached every machine yet, or a platform error page such as 1101 (an exception the Worker did
7
+ * not catch) or 1102 (a resource limit). `response.json()` reports any of those as `Unexpected token '<'`,
8
+ * which names none of them, so {@link readWorkerAnswer} reads the body as text and, when it is not JSON, fails
9
+ * with the status, the `cf-ray`, the Cloudflare error code and the page's title.
10
+ */
11
+ /** How much of a body that is not JSON a failure quotes. */
12
+ export declare const BODY_EXCERPT_CHARS = 300;
13
+ /** The Cloudflare error code and title an HTML error page carries, when it carries them. */
14
+ export declare function cloudflareErrorPage(body: string): {
15
+ code: string | null;
16
+ title: string | null;
17
+ };
18
+ /** One line describing an answer that was not the JSON the probe sends: status, ray, error code, title, excerpt. */
19
+ export declare function describeNonJsonAnswer(what: string, answer: {
20
+ status: number;
21
+ ray: string | null;
22
+ body: string;
23
+ }): string;
24
+ /**
25
+ * The probe's JSON answer, or a failure that says what came back instead. A non-2xx JSON answer is the Worker's
26
+ * own failure and is reported with its body; a body that is not JSON is Cloudflare's page, described by
27
+ * {@link describeNonJsonAnswer}.
28
+ */
29
+ export declare function readWorkerAnswer(what: string, response: Response): Promise<unknown>;
30
+ //# sourceMappingURL=workerAnswer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workerAnswer.d.ts","sourceRoot":"","sources":["../../src/test-utils/workerAnswer.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AAEH,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC,4FAA4F;AAC5F,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAO/F;AAED,oHAAoH;AACpH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC3D,MAAM,CAUR;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAYzF"}
@@ -0,0 +1,53 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/test-utils/workerAnswer.ts
4
+ /**
5
+ * **Reading a live probe Worker's answer, for the suites that deploy one.** A probe Worker answers JSON, its
6
+ * own failures included. An HTML page in its place is Cloudflare's, not the Worker's: a workers.dev address
7
+ * that has not reached every machine yet, or a platform error page such as 1101 (an exception the Worker did
8
+ * not catch) or 1102 (a resource limit). `response.json()` reports any of those as `Unexpected token '<'`,
9
+ * which names none of them, so {@link readWorkerAnswer} reads the body as text and, when it is not JSON, fails
10
+ * with the status, the `cf-ray`, the Cloudflare error code and the page's title.
11
+ */
12
+ /** How much of a body that is not JSON a failure quotes. */
13
+ const BODY_EXCERPT_CHARS = 300;
14
+ /** The Cloudflare error code and title an HTML error page carries, when it carries them. */
15
+ function cloudflareErrorPage(body) {
16
+ return {
17
+ code: /class="cf-error-code"[^>]*>\s*(\d{3,4})\s*</i.exec(body)?.[1] ?? /\bError(?:\s+code)?\s*:?\s*(1\d{3})\b/i.exec(body)?.[1] ?? null,
18
+ title: /<title>([^<]*)<\/title>/i.exec(body)?.[1]?.replace(/\s+/g, " ").trim() || null
19
+ };
20
+ }
21
+ /** One line describing an answer that was not the JSON the probe sends: status, ray, error code, title, excerpt. */
22
+ function describeNonJsonAnswer(what, answer) {
23
+ const { code, title } = cloudflareErrorPage(answer.body);
24
+ const excerpt = answer.body.replace(/\s+/g, " ").trim().slice(0, 300);
25
+ return `${[
26
+ `${what} answered ${answer.status} with a body that is not JSON`,
27
+ `cf-ray ${answer.ray ?? "absent"}`,
28
+ `Cloudflare error ${code ?? "none named"}`,
29
+ ...title ? [`title "${title}"`] : []
30
+ ].join(", ")}. Body: ${excerpt}`;
31
+ }
32
+ /**
33
+ * The probe's JSON answer, or a failure that says what came back instead. A non-2xx JSON answer is the Worker's
34
+ * own failure and is reported with its body; a body that is not JSON is Cloudflare's page, described by
35
+ * {@link describeNonJsonAnswer}.
36
+ */
37
+ async function readWorkerAnswer(what, response) {
38
+ const body = await response.text();
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(body);
42
+ } catch {
43
+ throw new Error(describeNonJsonAnswer(what, {
44
+ status: response.status,
45
+ ray: response.headers.get("cf-ray"),
46
+ body
47
+ }));
48
+ }
49
+ if (!response.ok) throw new Error(`${what} answered ${response.status}: ${JSON.stringify(parsed)}`);
50
+ return parsed;
51
+ }
52
+ //#endregion
53
+ export { BODY_EXCERPT_CHARS, cloudflareErrorPage, describeNonJsonAnswer, readWorkerAnswer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/cloudflare",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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.12.0",
43
+ "@pithy-sh/core": "^0.14.0",
44
44
  "zod": "^4.4.0"
45
45
  },
46
46
  "devDependencies": {
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
4
5
  import { z } from "zod";
5
6
  import { cloudflareRequest, decodeResponse, isNotFoundError } from "../client/errors";
6
7
  import { CloudflareManager } from "../client/manager";
@@ -124,6 +125,25 @@ function isAbsentMetadataIndexError(error: unknown): boolean {
124
125
  return errors.some((entry) => (entry as { code?: unknown } | null)?.code === METADATA_INDEX_ABSENT_CODE);
125
126
  }
126
127
 
128
+ /** Vectorize's error code for "a metadata index with that name already exists". */
129
+ const METADATA_INDEX_EXISTS_CODE = 40004;
130
+
131
+ /**
132
+ * Whether an error means "that metadata index is already there": `400` with error code 40004, the answer to
133
+ * creating a property that is already indexed. Narrow for the same reason {@link isAbsentMetadataIndexError} is:
134
+ * every other `400` stays an error.
135
+ *
136
+ * It is how a create retried by the SDK comes back. The SDK retries a request that timed out, lost its
137
+ * connection, or answered 408, 409, 429 or a 5xx, twice by default and a POST included, so a create Cloudflare
138
+ * applied can be sent again and refused as a duplicate of itself.
139
+ */
140
+ export function isExistingMetadataIndexError(error: unknown): boolean {
141
+ if (typeof error !== "object" || error === null) return false;
142
+ const { status, errors } = error as { status?: unknown; errors?: unknown };
143
+ if (status !== 400 || !Array.isArray(errors)) return false;
144
+ return errors.some((entry) => (entry as { code?: unknown } | null)?.code === METADATA_INDEX_EXISTS_CODE);
145
+ }
146
+
127
147
  /** Options a new index accepts beyond its name and shape. */
128
148
  export interface CreateVectorizeIndexOptions {
129
149
  /** A human description stored on the index, surfaced in the Cloudflare dashboard. */
@@ -218,19 +238,39 @@ export class CloudflareVectorizeProvisioner extends CloudflareManager {
218
238
  * Make a metadata property filterable. Cloudflare caps an index at 10 indexed properties, and only vectors
219
239
  * written **after** the metadata index exists are covered — so declare these before the first insert.
220
240
  * The change is enqueued asynchronously; the returned promise resolving means accepted, not live.
241
+ *
242
+ * Idempotent, like {@link deleteMetadataIndex}. A property already indexed answers `400`/40004
243
+ * ({@link isExistingMetadataIndexError}); the list then decides. Listed with the same type, the create has
244
+ * already happened and this returns. Listed with another type, it throws `core/conflict` naming both, since a
245
+ * metadata index's type is fixed. Not listed yet, it returns too: Cloudflare has just said the index exists,
246
+ * the list lags, and the caller's own settle wait reads its type.
221
247
  */
222
248
  async createMetadataIndex(
223
249
  indexName: string,
224
250
  propertyName: string,
225
251
  indexType: VectorizeMetadataIndexType,
226
252
  ): Promise<void> {
227
- await cloudflareRequest(`create Vectorize metadata index ${indexName}.${propertyName}`, () =>
228
- this.getClient().vectorize.indexes.metadataIndex.create(indexName, {
229
- account_id: this.accountId,
230
- indexType,
231
- propertyName,
232
- }),
233
- );
253
+ await cloudflareRequest(`create Vectorize metadata index ${indexName}.${propertyName}`, async () => {
254
+ try {
255
+ await this.getClient().vectorize.indexes.metadataIndex.create(indexName, {
256
+ account_id: this.accountId,
257
+ indexType,
258
+ propertyName,
259
+ });
260
+ } catch (error) {
261
+ if (!isExistingMetadataIndexError(error)) throw error;
262
+ const live = (await this.listMetadataIndexes(indexName)).find((entry) => entry.propertyName === propertyName);
263
+ if (live === undefined || live.indexType === indexType) return;
264
+ throw new ConflictError(
265
+ {
266
+ message: `Metadata index ${indexName}.${propertyName} already exists as ${live.indexType}, not ${indexType}.`,
267
+ action: `A metadata index's type is fixed. Declare ${propertyName} as ${live.indexType}, or delete the metadata index and create it again.`,
268
+ detail: `Cloudflare answered 40004 to a create of ${propertyName} as ${indexType}; the list holds it as ${live.indexType}.`,
269
+ },
270
+ { cause: error },
271
+ );
272
+ }
273
+ });
234
274
  }
235
275
 
236
276
  /** List an index's indexed metadata properties — the read that proves a `createMetadataIndex` has landed. */
@@ -42,10 +42,10 @@ const Envelope = z
42
42
  })
43
43
  .describe("A Cloudflare REST envelope.");
44
44
 
45
- /** One page of `GET …/repos`: only the name of each repo is read. */
46
- const RepoPage = z
47
- .array(z.object({ name: z.string().describe("The repo's name in the namespace.") }).loose())
48
- .describe("One page of an Artifacts namespace's repos.");
45
+ /** One page of `GET …/repos` or `GET …/namespaces`: only the name of each entry is read. */
46
+ const NamedPage = z
47
+ .array(z.object({ name: z.string().describe("The repo's or the namespace's name.") }).loose())
48
+ .describe("One page of an Artifacts listing of repos or namespaces.");
49
49
 
50
50
  /**
51
51
  * Account-level Artifacts control plane: **list and delete the repos in a namespace, then the namespace** (#673).
@@ -81,23 +81,38 @@ export class CloudflareArtifactsManager extends CloudflareManager {
81
81
  */
82
82
  async listRepos(namespace: string): Promise<string[]> {
83
83
  assertName(namespace, "namespace");
84
- return cloudflareRequest(`list Artifacts repos in ${namespace}`, async () => {
85
- const names = new Set<string>();
86
- const followed = new Set<string>();
87
- let cursor: string | undefined;
88
- for (;;) {
89
- const query = `limit=${PAGE_LIMIT}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
90
- const page = await this.call("GET", `${this.namespacePath(namespace)}/repos?${query}`);
91
- if (page === null) return [...names];
92
- for (const repo of decodeResponse(RepoPage, page.result, "Artifacts repo list")) names.add(repo.name);
93
- const next = page.result_info?.cursor ?? undefined;
94
- // A cursor already followed is the last page answering again; stopping there is what keeps a listing
95
- // that never empties its cursor from looping forever.
96
- if (!next || followed.has(next)) return [...names];
97
- followed.add(next);
98
- cursor = next;
99
- }
100
- });
84
+ return cloudflareRequest(`list Artifacts repos in ${namespace}`, () =>
85
+ this.names(`${this.namespacePath(namespace)}/repos`, "Artifacts repo list"),
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Every namespace name on the account, across every page. The live suites' debris sweep reads it to find the
91
+ * `pithy-int-` namespaces an aborted run left behind, since nothing else lists them.
92
+ */
93
+ async listNamespaces(): Promise<string[]> {
94
+ return cloudflareRequest("list Artifacts namespaces", () =>
95
+ this.names(`/accounts/${this.getAccountId()}/artifacts/namespaces`, "Artifacts namespace list"),
96
+ );
97
+ }
98
+
99
+ /** Every `name` a paged listing answers, following its cursor. A 404 is an empty listing. */
100
+ private async names(path: string, what: string): Promise<string[]> {
101
+ const names = new Set<string>();
102
+ const followed = new Set<string>();
103
+ let cursor: string | undefined;
104
+ for (;;) {
105
+ const query = `limit=${PAGE_LIMIT}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
106
+ const page = await this.call("GET", `${path}?${query}`);
107
+ if (page === null) return [...names];
108
+ for (const entry of decodeResponse(NamedPage, page.result, what)) names.add(entry.name);
109
+ const next = page.result_info?.cursor ?? undefined;
110
+ // A cursor already followed is the last page answering again; stopping there is what keeps a listing
111
+ // that never empties its cursor from looping forever.
112
+ if (!next || followed.has(next)) return [...names];
113
+ followed.add(next);
114
+ cursor = next;
115
+ }
101
116
  }
102
117
 
103
118
  /**
@@ -13,6 +13,36 @@ export const R2BucketInfo = z
13
13
  .describe("A Cloudflare R2 bucket's identity, as returned by the create/list endpoints.");
14
14
  export type R2BucketInfo = z.output<typeof R2BucketInfo>;
15
15
 
16
+ /** One R2 bucket lock rule: objects under `prefix` cannot be overwritten or deleted while its condition holds. */
17
+ export const R2LockRule = z
18
+ .object({
19
+ id: z.string().describe("The rule's id, unique within the bucket."),
20
+ enabled: z.boolean().describe("Whether the rule is in force."),
21
+ prefix: z.string().optional().describe("The key prefix it covers. Absent or empty covers every object."),
22
+ condition: z
23
+ .discriminatedUnion("type", [
24
+ z
25
+ .object({
26
+ type: z.literal("Age").describe("Locked for a time after each object is written."),
27
+ maxAgeSeconds: z.number().describe("How long, in seconds."),
28
+ })
29
+ .describe("Locked for an age."),
30
+ z
31
+ .object({
32
+ type: z.literal("Date").describe("Locked until a date."),
33
+ date: z.string().describe("The date, ISO-8601."),
34
+ })
35
+ .describe("Locked until a date."),
36
+ z.object({ type: z.literal("Indefinite").describe("Locked for ever.") }).describe("Locked for ever."),
37
+ ])
38
+ .describe("How long the objects stay locked."),
39
+ })
40
+ .describe("An R2 bucket lock rule.");
41
+ export type R2LockRule = z.output<typeof R2LockRule>;
42
+
43
+ /** What asking for an indefinite lock did. */
44
+ export type R2LockOutcome = "locked" | "already";
45
+
16
46
  /**
17
47
  * Account-level R2 control plane: **create and delete buckets**. Customers use this to stand up and
18
48
  * tear down per-environment buckets (e.g. ephemeral staging), and worktree teardown uses it to
@@ -79,6 +109,50 @@ export class CloudflareR2Provisioner extends CloudflareManager {
79
109
  return decodeResponse(R2BucketInfo, response, "R2 bucket create");
80
110
  }
81
111
 
112
+ /** The bucket's lock rules. A bucket with none answers an empty list. */
113
+ async bucketLockRules(name: string): Promise<R2LockRule[]> {
114
+ const response = await cloudflareRequest(`read lock rules of R2 bucket ${name}`, () =>
115
+ this.getClient().r2.buckets.locks.get(name, { account_id: this.accountId }),
116
+ );
117
+ return (
118
+ decodeResponse(
119
+ z.object({ rules: z.array(R2LockRule).optional() }).describe("A bucket's lock rules."),
120
+ response,
121
+ "R2 bucket lock rules",
122
+ ).rules ?? []
123
+ );
124
+ }
125
+
126
+ /** Whether some enabled rule locks every object under `prefix` indefinitely. */
127
+ static locksPrefixIndefinitely(rules: readonly R2LockRule[], prefix: string): boolean {
128
+ return rules.some(
129
+ (rule) => rule.enabled && rule.condition.type === "Indefinite" && prefix.startsWith(rule.prefix ?? ""),
130
+ );
131
+ }
132
+
133
+ /** Whether the bucket's rules lock every object under `prefix` indefinitely, read from the account. */
134
+ async prefixLockedIndefinitely(name: string, prefix: string): Promise<boolean> {
135
+ return CloudflareR2Provisioner.locksPrefixIndefinitely(await this.bucketLockRules(name), prefix);
136
+ }
137
+
138
+ /**
139
+ * Lock every object under `prefix` indefinitely, beside whatever rules the bucket already has. Idempotent: a
140
+ * rule already in force is left alone and answered `already`. The update replaces the bucket's whole rule set,
141
+ * so the existing rules are read and written back with the new one — never dropped.
142
+ */
143
+ async lockPrefixIndefinitely(name: string, prefix: string, id: string): Promise<R2LockOutcome> {
144
+ const rules = await this.bucketLockRules(name);
145
+ if (CloudflareR2Provisioner.locksPrefixIndefinitely(rules, prefix)) return "already";
146
+ const next = [
147
+ ...rules.filter((rule) => rule.id !== id),
148
+ { id, enabled: true, prefix, condition: { type: "Indefinite" as const } },
149
+ ];
150
+ await cloudflareRequest(`lock R2 bucket ${name}`, () =>
151
+ this.getClient().r2.buckets.locks.update(name, { account_id: this.accountId, rules: next }),
152
+ );
153
+ return "locked";
154
+ }
155
+
82
156
  /**
83
157
  * Delete an R2 bucket by name. Idempotent — a missing bucket is not an error, so teardown can re-run safely.
84
158
  *
@@ -1,7 +1,9 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
4
5
  import { Cloudflare } from "cloudflare";
6
+ import type { CloudflareArtifactsManager } from "../artifacts/artifactsManager";
5
7
  import { CloudflareClients } from "../client/clients";
6
8
  import { listEmailRoutingRules, namedRules } from "./emailRoutingRules";
7
9
  import {
@@ -141,6 +143,26 @@ export async function reapKinds(
141
143
  return results;
142
144
  }
143
145
 
146
+ /**
147
+ * Remove one throwaway Artifacts namespace: every repository in it, then the namespace.
148
+ *
149
+ * Cloudflare refuses to delete a namespace that still holds a repository, and a repository delete is `202 Accepted`,
150
+ * so one just reaped can hold the namespace a moment longer. That case throws, so the sweep reports it as not reaped
151
+ * and the next run tries again, rather than waiting here on Cloudflare's clock. A namespace already gone is done.
152
+ */
153
+ export async function reapArtifactsNamespace(
154
+ artifacts: Pick<CloudflareArtifactsManager, "listRepos" | "deleteRepo" | "deleteNamespace">,
155
+ namespace: string,
156
+ ): Promise<void> {
157
+ for (const repo of await artifacts.listRepos(namespace)) await artifacts.deleteRepo(namespace, repo);
158
+ if ((await artifacts.deleteNamespace(namespace)) === "occupied") {
159
+ throw new ConflictError({
160
+ message: `Artifacts namespace ${namespace} still holds a repository.`,
161
+ detail: "a repository delete is 202 Accepted; the next sweep deletes the namespace once it is released",
162
+ });
163
+ }
164
+ }
165
+
144
166
  /**
145
167
  * Build the sweep plan for an account.
146
168
  *
@@ -172,6 +194,13 @@ export function testResourceReapPlan(creds: IntegrationCreds, options: ReapScope
172
194
  await tokens.deleteTokensByName(name);
173
195
  },
174
196
  },
197
+ {
198
+ // Minted by `@pithy-sh/artifacts`' `revokeAll.integration.test.ts`: a Worker bound to a throwaway namespace,
199
+ // which Cloudflare creates on the first repository and nothing but this deletes.
200
+ label: "Artifacts namespace",
201
+ list: () => clients.artifacts().listNamespaces(),
202
+ remove: (name) => reapArtifactsNamespace(clients.artifacts(), name),
203
+ },
175
204
  {
176
205
  label: "D1 database",
177
206
  list: async () => (await d1.listDatabases()).map((database) => database.name),
@@ -3,12 +3,21 @@
3
3
 
4
4
  /**
5
5
  * **Waiting on an eventually consistent control plane, for the live suites.** One budget and one rule, shared
6
- * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, because the cloudflare suite kept a
7
- * 60s budget and a single confirming read of its own and failed on exactly that (#709).
6
+ * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, so neither keeps a shorter budget or a
7
+ * single confirming read of its own.
8
8
  *
9
- * **The budget is 120s, and 300s for a metadata index.** A Vectorize index became visible 25-50s after the
10
- * call that made it, measured live, and an upsert became queryable in about 30s. A metadata index is slower:
11
- * about 97s on one run of #709 and over 120s on the next, so its waits take {@link METADATA_INDEX_SETTLE_MS}.
9
+ * **The budget is 120s, 300s for an upsert, and 600s for a metadata index.** A Vectorize index became visible
10
+ * 25-50s after the call that made it, measured live. An upsert usually becomes queryable in about 30s, but one
11
+ * written right after a metadata index was still unqueryable at 121s, with the vectors before it visible, so the
12
+ * waits for an upsert take {@link UPSERT_SETTLE_MS}. A metadata index is the slowest: about 97s on one run, over
13
+ * 120s on the next, and one of two still missing at 300s on a third. Cloudflare documents no readiness time and no
14
+ * readiness signal for one, so its waits take {@link METADATA_INDEX_SETTLE_MS}, twice the longest measured.
15
+ *
16
+ * **An upsert is waited on by its outcome, never by its mutation id.** `describe()` reports a
17
+ * `processedUpToMutation` watermark, and the manager reads it. The watermark moves through mutation ids the
18
+ * caller never issued, and a probe watched it step past ours one second after matching it. A mutation id is a
19
+ * UUID with no order, so "processed up to ours" cannot be computed from a later id, and a poll for equality
20
+ * misses the one moment it holds. So a wait polls the query until it returns what was written.
12
21
  *
13
22
  * **A metadata-index wait compares names and types, never a count.** {@link metadataIndexesSettled} is the
14
23
  * predicate; a list of the right length can still be the wrong list.
@@ -20,21 +29,28 @@
20
29
  * than a fresh read, which can disagree with the one before it.
21
30
  *
22
31
  * **A test that waits several times shares one deadline across them.** Each wait's own budget bounds that
23
- * wait and nothing else, so a test making eight of them in a row could outrun its vitest timeout with every
24
- * step inside budget. Vitest then abandoned the body mid-poll: the failure said only "Test timed out", and the
25
- * teardown that deletes the index never ran (#709). Pass the same {@link SettleOptions.deadline} to every
26
- * wait and the step in progress when it passes fails by name, inside the body, where the teardown still runs.
32
+ * wait and nothing else, so a test making eight of them in a row can outrun its vitest timeout with every
33
+ * step inside budget. Vitest then abandons the body mid-poll: the failure says only "Test timed out", and the
34
+ * teardown that deletes the index never runs. Pass the same {@link SettleOptions.deadline} to every wait, set
35
+ * below the test's vitest timeout, and the step in progress when it passes fails by name, inside the body,
36
+ * where the teardown still runs.
27
37
  */
28
38
 
29
- /** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
39
+ /** Ceiling for any one wait. Measured at 25-50s for a control-plane change. */
30
40
  export const SETTLE_TIMEOUT_MS = 120_000;
31
41
 
32
42
  /**
33
- * Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
34
- * applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
35
- * undercut exactly this wait. `pithy vector provision` waits on the same measurement.
43
+ * Ceiling for a metadata-index wait. A metadata index is the slowest change Vectorize applies: one settled in
44
+ * about 97s, the next was unsettled at 120s, and a third was still missing at 300s. Twice that last figure, and
45
+ * `pithy vector provision` waits on the same number.
46
+ */
47
+ export const METADATA_INDEX_SETTLE_MS = 600_000;
48
+
49
+ /**
50
+ * Ceiling for an upsert to become queryable. About 30s is usual, and one written right after a metadata index
51
+ * was still missing from the query at 121s, so the waits for an upsert take more than the shared budget.
36
52
  */
37
- export const METADATA_INDEX_SETTLE_MS = 300_000;
53
+ export const UPSERT_SETTLE_MS = 300_000;
38
54
 
39
55
  /**
40
56
  * Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * **Reading a live probe Worker's answer, for the suites that deploy one.** A probe Worker answers JSON, its
6
+ * own failures included. An HTML page in its place is Cloudflare's, not the Worker's: a workers.dev address
7
+ * that has not reached every machine yet, or a platform error page such as 1101 (an exception the Worker did
8
+ * not catch) or 1102 (a resource limit). `response.json()` reports any of those as `Unexpected token '<'`,
9
+ * which names none of them, so {@link readWorkerAnswer} reads the body as text and, when it is not JSON, fails
10
+ * with the status, the `cf-ray`, the Cloudflare error code and the page's title.
11
+ */
12
+
13
+ /** How much of a body that is not JSON a failure quotes. */
14
+ export const BODY_EXCERPT_CHARS = 300;
15
+
16
+ /** The Cloudflare error code and title an HTML error page carries, when it carries them. */
17
+ export function cloudflareErrorPage(body: string): { code: string | null; title: string | null } {
18
+ const code =
19
+ /class="cf-error-code"[^>]*>\s*(\d{3,4})\s*</i.exec(body)?.[1] ??
20
+ /\bError(?:\s+code)?\s*:?\s*(1\d{3})\b/i.exec(body)?.[1] ??
21
+ null;
22
+ const title = /<title>([^<]*)<\/title>/i.exec(body)?.[1]?.replace(/\s+/g, " ").trim() || null;
23
+ return { code, title };
24
+ }
25
+
26
+ /** One line describing an answer that was not the JSON the probe sends: status, ray, error code, title, excerpt. */
27
+ export function describeNonJsonAnswer(
28
+ what: string,
29
+ answer: { status: number; ray: string | null; body: string },
30
+ ): string {
31
+ const { code, title } = cloudflareErrorPage(answer.body);
32
+ const excerpt = answer.body.replace(/\s+/g, " ").trim().slice(0, BODY_EXCERPT_CHARS);
33
+ const parts = [
34
+ `${what} answered ${answer.status} with a body that is not JSON`,
35
+ `cf-ray ${answer.ray ?? "absent"}`,
36
+ `Cloudflare error ${code ?? "none named"}`,
37
+ ...(title ? [`title "${title}"`] : []),
38
+ ];
39
+ return `${parts.join(", ")}. Body: ${excerpt}`;
40
+ }
41
+
42
+ /**
43
+ * The probe's JSON answer, or a failure that says what came back instead. A non-2xx JSON answer is the Worker's
44
+ * own failure and is reported with its body; a body that is not JSON is Cloudflare's page, described by
45
+ * {@link describeNonJsonAnswer}.
46
+ */
47
+ export async function readWorkerAnswer(what: string, response: Response): Promise<unknown> {
48
+ const body = await response.text();
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(body);
52
+ } catch {
53
+ throw new Error(
54
+ describeNonJsonAnswer(what, { status: response.status, ray: response.headers.get("cf-ray"), body }),
55
+ );
56
+ }
57
+ if (!response.ok) throw new Error(`${what} answered ${response.status}: ${JSON.stringify(parsed)}`);
58
+ return parsed;
59
+ }