@pithy-sh/cloudflare 0.7.1 → 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.
- package/dist/ai/vectorizeProvisioner.d.ts +16 -0
- package/dist/ai/vectorizeProvisioner.d.ts.map +1 -1
- package/dist/ai/vectorizeProvisioner.js +43 -6
- package/dist/artifacts/artifactsManager.d.ts +7 -0
- package/dist/artifacts/artifactsManager.d.ts.map +1 -1
- package/dist/artifacts/artifactsManager.js +26 -17
- package/dist/r2/r2Provisioner.d.ts +30 -0
- package/dist/r2/r2Provisioner.d.ts.map +1 -1
- package/dist/r2/r2Provisioner.js +52 -2
- package/dist/test-utils/reap.d.ts +9 -0
- package/dist/test-utils/reap.d.ts.map +1 -1
- package/dist/test-utils/reap.js +21 -1
- package/dist/test-utils/settle.d.ts +29 -14
- package/dist/test-utils/settle.d.ts.map +1 -1
- package/dist/test-utils/settle.js +30 -15
- package/dist/test-utils/workerAnswer.d.ts +30 -0
- package/dist/test-utils/workerAnswer.d.ts.map +1 -0
- package/dist/test-utils/workerAnswer.js +53 -0
- package/package.json +2 -2
- package/src/ai/vectorizeProvisioner.ts +47 -7
- package/src/artifacts/artifactsManager.ts +36 -21
- package/src/r2/r2Provisioner.ts +74 -0
- package/src/test-utils/reap.ts +29 -0
- package/src/test-utils/settle.ts +30 -14
- package/src/test-utils/workerAnswer.ts +59 -0
|
@@ -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":"
|
|
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}`, () =>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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,
|
|
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
|
|
27
|
-
const
|
|
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}`,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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"}
|
package/dist/r2/r2Provisioner.js
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/test-utils/reap.js
CHANGED
|
@@ -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,
|
|
6
|
-
*
|
|
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
|
|
9
|
-
* call that made it, measured live
|
|
10
|
-
*
|
|
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
|
|
23
|
-
* step inside budget. Vitest then
|
|
24
|
-
* teardown that deletes the index never
|
|
25
|
-
*
|
|
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
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
7
|
-
*
|
|
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
|
|
10
|
-
* call that made it, measured live
|
|
11
|
-
*
|
|
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
|
|
24
|
-
* step inside budget. Vitest then
|
|
25
|
-
* teardown that deletes the index never
|
|
26
|
-
*
|
|
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
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
46
|
-
const
|
|
47
|
-
.array(z.object({ name: z.string().describe("The repo's
|
|
48
|
-
.describe("One page of an Artifacts
|
|
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}`,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
/**
|
package/src/r2/r2Provisioner.ts
CHANGED
|
@@ -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
|
*
|
package/src/test-utils/reap.ts
CHANGED
|
@@ -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),
|
package/src/test-utils/settle.ts
CHANGED
|
@@ -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,
|
|
7
|
-
*
|
|
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
|
|
10
|
-
* call that made it, measured live
|
|
11
|
-
*
|
|
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
|
|
24
|
-
* step inside budget. Vitest then
|
|
25
|
-
* teardown that deletes the index never
|
|
26
|
-
*
|
|
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
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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
|
+
}
|