alchemy 0.92.2 → 0.93.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/bin/alchemy.js +1 -1
- package/lib/cloudflare/ai-search-namespace.d.ts +150 -0
- package/lib/cloudflare/ai-search-namespace.d.ts.map +1 -0
- package/lib/cloudflare/ai-search-namespace.js +281 -0
- package/lib/cloudflare/ai-search-namespace.js.map +1 -0
- package/lib/cloudflare/ai-search-token.d.ts +11 -1
- package/lib/cloudflare/ai-search-token.d.ts.map +1 -1
- package/lib/cloudflare/ai-search-token.js +35 -7
- package/lib/cloudflare/ai-search-token.js.map +1 -1
- package/lib/cloudflare/ai-search.d.ts +123 -18
- package/lib/cloudflare/ai-search.d.ts.map +1 -1
- package/lib/cloudflare/ai-search.js +239 -68
- package/lib/cloudflare/ai-search.js.map +1 -1
- package/lib/cloudflare/bindings.d.ts +29 -2
- package/lib/cloudflare/bindings.d.ts.map +1 -1
- package/lib/cloudflare/bindings.js.map +1 -1
- package/lib/cloudflare/bound.d.ts +4 -2
- package/lib/cloudflare/bound.d.ts.map +1 -1
- package/lib/cloudflare/container.d.ts +79 -6
- package/lib/cloudflare/container.d.ts.map +1 -1
- package/lib/cloudflare/container.js +117 -2
- package/lib/cloudflare/container.js.map +1 -1
- package/lib/cloudflare/index.d.ts +1 -0
- package/lib/cloudflare/index.d.ts.map +1 -1
- package/lib/cloudflare/index.js +1 -0
- package/lib/cloudflare/index.js.map +1 -1
- package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
- package/lib/cloudflare/miniflare/build-worker-options.js +37 -0
- package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
- package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
- package/lib/cloudflare/worker-metadata.js +25 -1
- package/lib/cloudflare/worker-metadata.js.map +1 -1
- package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
- package/lib/cloudflare/wrangler.json.js +32 -0
- package/lib/cloudflare/wrangler.json.js.map +1 -1
- package/lib/docker/api.d.ts +4 -1
- package/lib/docker/api.d.ts.map +1 -1
- package/lib/docker/api.js +8 -2
- package/lib/docker/api.js.map +1 -1
- package/package.json +2 -2
- package/src/cloudflare/ai-search-namespace.ts +439 -0
- package/src/cloudflare/ai-search-token.ts +43 -9
- package/src/cloudflare/ai-search.ts +353 -73
- package/src/cloudflare/bindings.ts +33 -0
- package/src/cloudflare/bound.ts +87 -74
- package/src/cloudflare/container.ts +228 -11
- package/src/cloudflare/index.ts +1 -0
- package/src/cloudflare/miniflare/build-worker-options.ts +39 -0
- package/src/cloudflare/worker-metadata.ts +25 -1
- package/src/cloudflare/wrangler.json.ts +32 -0
- package/src/docker/api.ts +11 -2
- package/workers/cloudflare-state-store.js +57 -57
- package/workers/tunnel-proxy.js +1 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import { Resource, ResourceKind } from "../resource.ts";
|
|
3
|
+
import { logger } from "../util/logger.ts";
|
|
4
|
+
import { poll } from "../util/poll.ts";
|
|
5
|
+
import { CloudflareApiError } from "./api-error.ts";
|
|
6
|
+
import { extractCloudflareResult } from "./api-response.ts";
|
|
7
|
+
import {
|
|
8
|
+
createCloudflareApi,
|
|
9
|
+
type CloudflareApi,
|
|
10
|
+
type CloudflareApiOptions,
|
|
11
|
+
} from "./api.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Properties for creating or updating an AI Search Namespace
|
|
15
|
+
*/
|
|
16
|
+
export interface AiSearchNamespaceProps extends CloudflareApiOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Name of the namespace.
|
|
19
|
+
* Must match pattern: `^[a-z0-9]([a-z0-9-]{0,26}[a-z0-9])?$`
|
|
20
|
+
*
|
|
21
|
+
* @default `${app}-${stage}-${id}`
|
|
22
|
+
*/
|
|
23
|
+
name?: string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Optional description for the namespace (max 256 characters).
|
|
27
|
+
*
|
|
28
|
+
* Pass `null` to explicitly clear a previously-set description on update.
|
|
29
|
+
* Leaving this field undefined (absent from props) will NOT touch the
|
|
30
|
+
* existing description on update.
|
|
31
|
+
*/
|
|
32
|
+
description?: string | null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether to adopt an existing namespace with the same name if it exists
|
|
36
|
+
*
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
adopt?: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether to delete the namespace when removed from Alchemy.
|
|
43
|
+
* Namespace must be empty (no instances) to be deleted.
|
|
44
|
+
*
|
|
45
|
+
* @default true
|
|
46
|
+
*/
|
|
47
|
+
delete?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Type guard for AiSearchNamespace
|
|
52
|
+
*/
|
|
53
|
+
export function isAiSearchNamespace(
|
|
54
|
+
resource: unknown,
|
|
55
|
+
): resource is AiSearchNamespace {
|
|
56
|
+
return (
|
|
57
|
+
typeof resource === "object" &&
|
|
58
|
+
resource !== null &&
|
|
59
|
+
(resource as any)[ResourceKind] === "cloudflare::AiSearchNamespace"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Output returned after AI Search Namespace creation/update
|
|
65
|
+
*/
|
|
66
|
+
export type AiSearchNamespace = Omit<
|
|
67
|
+
AiSearchNamespaceProps,
|
|
68
|
+
"delete" | "adopt" | "description" | "name"
|
|
69
|
+
> & {
|
|
70
|
+
/**
|
|
71
|
+
* Resource type identifier for binding resolution
|
|
72
|
+
*/
|
|
73
|
+
type: "ai_search_namespace";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The stable identifier of the namespace (equal to `namespace`).
|
|
77
|
+
* Present because Alchemy's resource model expects every output to carry an `id`.
|
|
78
|
+
*/
|
|
79
|
+
id: string;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The name of the namespace.
|
|
83
|
+
*/
|
|
84
|
+
namespace: string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Optional description of the namespace
|
|
88
|
+
*/
|
|
89
|
+
description: string | null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Time at which the namespace was created
|
|
93
|
+
*/
|
|
94
|
+
createdAt: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* API response for AI Search namespace
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
interface AiSearchNamespaceApiResponse {
|
|
102
|
+
name: string;
|
|
103
|
+
description: string | null;
|
|
104
|
+
created_at: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* An AI Search Namespace groups AI Search instances together,
|
|
109
|
+
* providing logical isolation and scoped access control.
|
|
110
|
+
*
|
|
111
|
+
* Namespaces are a first-class concept in the AI Search binding model.
|
|
112
|
+
* Instance names are unique within their namespace: `UNIQUE(account_id, namespace, name)`.
|
|
113
|
+
*
|
|
114
|
+
* @see https://developers.cloudflare.com/ai-search/
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ## Create a namespace
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* const ns = await AiSearchNamespace("production", {
|
|
121
|
+
* name: "production",
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ## Use as a Worker binding
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* const ns = await AiSearchNamespace("docs", {
|
|
130
|
+
* name: "docs",
|
|
131
|
+
* });
|
|
132
|
+
*
|
|
133
|
+
* const worker = await Worker("my-worker", {
|
|
134
|
+
* bindings: {
|
|
135
|
+
* DOCS: ns,
|
|
136
|
+
* },
|
|
137
|
+
* // ...
|
|
138
|
+
* });
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ## Adopt an existing namespace
|
|
143
|
+
*
|
|
144
|
+
* ```ts
|
|
145
|
+
* const ns = await AiSearchNamespace("existing", {
|
|
146
|
+
* name: "production",
|
|
147
|
+
* adopt: true,
|
|
148
|
+
* });
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export const AiSearchNamespace = Resource(
|
|
152
|
+
"cloudflare::AiSearchNamespace",
|
|
153
|
+
async function (
|
|
154
|
+
this: Context<AiSearchNamespace>,
|
|
155
|
+
id: string,
|
|
156
|
+
props: AiSearchNamespaceProps = {},
|
|
157
|
+
): Promise<AiSearchNamespace> {
|
|
158
|
+
const adopt = props.adopt ?? this.scope.adopt;
|
|
159
|
+
|
|
160
|
+
const namespace =
|
|
161
|
+
props.name ??
|
|
162
|
+
this.output?.namespace ??
|
|
163
|
+
this.scope.createPhysicalName(id, "-", 28);
|
|
164
|
+
|
|
165
|
+
// Validate namespace name against CF's documented pattern. We do this
|
|
166
|
+
// before any API calls so users get a clear local error rather than a
|
|
167
|
+
// generic API 400. Also guards against `createPhysicalName` truncation
|
|
168
|
+
// producing a name that begins/ends with "-".
|
|
169
|
+
//
|
|
170
|
+
// Skip validation during delete: `this.output?.namespace` is always a
|
|
171
|
+
// previously-accepted name (came from `toOutput(result)`), so there's
|
|
172
|
+
// nothing to re-validate. Critically, this also prevents a failed
|
|
173
|
+
// create (which throws from this guard without persisting state) from
|
|
174
|
+
// tripping the same guard during scope teardown — destroy would have
|
|
175
|
+
// nothing to delete anyway.
|
|
176
|
+
if (
|
|
177
|
+
this.phase !== "delete" &&
|
|
178
|
+
!/^[a-z0-9]([a-z0-9-]{0,26}[a-z0-9])?$/.test(namespace)
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`AI Search namespace name "${namespace}" is invalid. ` +
|
|
182
|
+
"Must match pattern: ^[a-z0-9]([a-z0-9-]{0,26}[a-z0-9])?$ " +
|
|
183
|
+
"(1-28 chars, lowercase alphanumerics and hyphens, cannot start or end with a hyphen).",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The `default` namespace is reserved by Cloudflare — it is created
|
|
188
|
+
// automatically on every account and cannot be created, updated, or
|
|
189
|
+
// deleted via the API. Only allow binding to it via `adopt: true`.
|
|
190
|
+
if (namespace === "default" && !adopt && this.phase !== "delete") {
|
|
191
|
+
throw new Error(
|
|
192
|
+
'The "default" AI Search namespace is reserved by Cloudflare. ' +
|
|
193
|
+
"Use `adopt: true` to bind to it (it cannot be created, renamed, or deleted via the API).",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (this.scope.local) {
|
|
198
|
+
// Local development mode — return mock output
|
|
199
|
+
return {
|
|
200
|
+
type: "ai_search_namespace",
|
|
201
|
+
id: namespace,
|
|
202
|
+
namespace,
|
|
203
|
+
description: props.description ?? null,
|
|
204
|
+
createdAt: new Date().toISOString(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const api = await createCloudflareApi(props);
|
|
209
|
+
|
|
210
|
+
if (this.phase === "delete") {
|
|
211
|
+
const namespaceName = this.output?.namespace;
|
|
212
|
+
if (namespaceName && props.delete !== false) {
|
|
213
|
+
await deleteAiSearchNamespace(api, namespaceName);
|
|
214
|
+
}
|
|
215
|
+
return this.destroy();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Immutable name — replace if changed. We use the default
|
|
219
|
+
// (pending-deletion) replace strategy: the old namespace is queued for
|
|
220
|
+
// deletion and flushed at scope finalize, matching the convention used
|
|
221
|
+
// by other Cloudflare resources in this repo. Forcing immediate
|
|
222
|
+
// deletion (`replace(true)`) interacts poorly with Cloudflare's
|
|
223
|
+
// namespace-delete consistency window and causes the old namespace to
|
|
224
|
+
// still appear in subsequent GETs.
|
|
225
|
+
if (this.phase === "update" && this.output?.namespace !== namespace) {
|
|
226
|
+
return this.replace();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (this.phase === "update" && this.output) {
|
|
230
|
+
// Three-way semantics on `description`:
|
|
231
|
+
// - absent (undefined) → do not touch (preserves out-of-band edits)
|
|
232
|
+
// - string → set to that value
|
|
233
|
+
// - null → clear (explicit opt-in to clearing)
|
|
234
|
+
// We only PUT when the value differs from current state.
|
|
235
|
+
const hasDescriptionProp = "description" in props;
|
|
236
|
+
if (hasDescriptionProp && props.description !== this.output.description) {
|
|
237
|
+
const updated = await updateAiSearchNamespace(api, namespace, {
|
|
238
|
+
description: props.description,
|
|
239
|
+
});
|
|
240
|
+
return toOutput(updated);
|
|
241
|
+
}
|
|
242
|
+
return this.output;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Adopting the reserved `default` namespace is a no-op creation: emit a
|
|
246
|
+
// warn so the behavior is visible (delete is silently skipped later).
|
|
247
|
+
if (namespace === "default" && adopt) {
|
|
248
|
+
logger.warn(
|
|
249
|
+
`Adopting the reserved AI Search "default" namespace. It will not be deleted on teardown.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Create (with adopt via pre-flight check when enabled)
|
|
254
|
+
const hasDescriptionProp = "description" in props;
|
|
255
|
+
let result: AiSearchNamespaceApiResponse;
|
|
256
|
+
if (adopt) {
|
|
257
|
+
// Pre-flight: if namespace exists, adopt it; otherwise create.
|
|
258
|
+
// `getAiSearchNamespace` internally returns `undefined` on 404 and
|
|
259
|
+
// rethrows all other errors (auth, 5xx, network) — no catch needed here.
|
|
260
|
+
const existing = await getAiSearchNamespace(api, namespace);
|
|
261
|
+
if (existing) {
|
|
262
|
+
if (hasDescriptionProp && props.description !== existing.description) {
|
|
263
|
+
result = await updateAiSearchNamespace(api, namespace, {
|
|
264
|
+
description: props.description,
|
|
265
|
+
});
|
|
266
|
+
} else {
|
|
267
|
+
result = existing;
|
|
268
|
+
}
|
|
269
|
+
} else if (namespace === "default") {
|
|
270
|
+
// The `default` namespace is implicit — treat GET 404 as "exists but
|
|
271
|
+
// not queryable" and return a synthetic adoption record. Use the
|
|
272
|
+
// current timestamp rather than epoch 0 to avoid a misleading
|
|
273
|
+
// "1970-01-01" createdAt in the state file.
|
|
274
|
+
result = {
|
|
275
|
+
name: "default",
|
|
276
|
+
description: null,
|
|
277
|
+
created_at: new Date().toISOString(),
|
|
278
|
+
};
|
|
279
|
+
} else {
|
|
280
|
+
result = await createAiSearchNamespace(api, {
|
|
281
|
+
name: namespace,
|
|
282
|
+
description: props.description ?? undefined,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
try {
|
|
287
|
+
result = await createAiSearchNamespace(api, {
|
|
288
|
+
name: namespace,
|
|
289
|
+
description: props.description ?? undefined,
|
|
290
|
+
});
|
|
291
|
+
} catch (error) {
|
|
292
|
+
// Wrap "already exists" errors with AGENTS.md-style messaging that
|
|
293
|
+
// points at `adopt: true`.
|
|
294
|
+
if (
|
|
295
|
+
error instanceof CloudflareApiError &&
|
|
296
|
+
(error.status === 400 || error.status === 409)
|
|
297
|
+
) {
|
|
298
|
+
const existing = await getAiSearchNamespace(api, namespace);
|
|
299
|
+
if (existing) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`AI Search namespace "${namespace}" already exists. Use \`adopt: true\` to adopt it.`,
|
|
302
|
+
{ cause: error },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return toOutput(result);
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
function toOutput(result: AiSearchNamespaceApiResponse): AiSearchNamespace {
|
|
315
|
+
return {
|
|
316
|
+
type: "ai_search_namespace",
|
|
317
|
+
id: result.name,
|
|
318
|
+
namespace: result.name,
|
|
319
|
+
description: result.description ?? null,
|
|
320
|
+
createdAt: result.created_at,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── API Helper Functions ────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Create an AI Search namespace
|
|
328
|
+
*/
|
|
329
|
+
export async function createAiSearchNamespace(
|
|
330
|
+
api: CloudflareApi,
|
|
331
|
+
payload: { name: string; description?: string | null },
|
|
332
|
+
): Promise<AiSearchNamespaceApiResponse> {
|
|
333
|
+
return await extractCloudflareResult<AiSearchNamespaceApiResponse>(
|
|
334
|
+
`create AI Search namespace "${payload.name}"`,
|
|
335
|
+
api.post(`/accounts/${api.accountId}/ai-search/namespaces`, payload),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get an AI Search namespace by name
|
|
341
|
+
*/
|
|
342
|
+
export async function getAiSearchNamespace(
|
|
343
|
+
api: CloudflareApi,
|
|
344
|
+
name: string,
|
|
345
|
+
): Promise<AiSearchNamespaceApiResponse | undefined> {
|
|
346
|
+
try {
|
|
347
|
+
return await extractCloudflareResult<AiSearchNamespaceApiResponse>(
|
|
348
|
+
`get AI Search namespace "${name}"`,
|
|
349
|
+
api.get(`/accounts/${api.accountId}/ai-search/namespaces/${name}`),
|
|
350
|
+
);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof CloudflareApiError && error.status === 404) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Update an AI Search namespace.
|
|
361
|
+
*
|
|
362
|
+
* `description: null` clears a previously-set description. `description`
|
|
363
|
+
* absent from the payload leaves the current value untouched.
|
|
364
|
+
*/
|
|
365
|
+
export async function updateAiSearchNamespace(
|
|
366
|
+
api: CloudflareApi,
|
|
367
|
+
name: string,
|
|
368
|
+
payload: { description?: string | null },
|
|
369
|
+
): Promise<AiSearchNamespaceApiResponse> {
|
|
370
|
+
return await extractCloudflareResult<AiSearchNamespaceApiResponse>(
|
|
371
|
+
`update AI Search namespace "${name}"`,
|
|
372
|
+
api.put(`/accounts/${api.accountId}/ai-search/namespaces/${name}`, payload),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Delete an AI Search namespace. Namespace must be empty (no instances).
|
|
378
|
+
* The reserved `default` namespace cannot be deleted and is skipped.
|
|
379
|
+
*/
|
|
380
|
+
export async function deleteAiSearchNamespace(
|
|
381
|
+
api: CloudflareApi,
|
|
382
|
+
name: string,
|
|
383
|
+
): Promise<void> {
|
|
384
|
+
// The `default` namespace is reserved and cannot be deleted by the API.
|
|
385
|
+
// Skip silently to allow clean teardown of resources that adopted it.
|
|
386
|
+
if (name === "default") {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
await extractCloudflareResult(
|
|
391
|
+
`delete AI Search namespace "${name}"`,
|
|
392
|
+
api.delete(`/accounts/${api.accountId}/ai-search/namespaces/${name}`),
|
|
393
|
+
);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (error instanceof CloudflareApiError) {
|
|
396
|
+
if (error.status === 404) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Namespace is likely non-empty — wrap with a clearer message.
|
|
400
|
+
if (error.status === 400 || error.status === 409) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Cannot delete AI Search namespace "${name}". ` +
|
|
403
|
+
"The namespace may contain instances — delete all instances first, " +
|
|
404
|
+
"or use { delete: false } on the AiSearchNamespace resource to preserve it.",
|
|
405
|
+
{ cause: error },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Cloudflare's namespace DELETE is eventually consistent — same-colo
|
|
413
|
+
// GETs are invalidated immediately via the edge cache, cross-colo GETs
|
|
414
|
+
// can surface the stale row for up to 60s (KV TTL). Wait until the
|
|
415
|
+
// namespace is truly gone so destroy phase presents a strongly-
|
|
416
|
+
// consistent contract to callers (user teardown assertions,
|
|
417
|
+
// downstream resources, rename-triggered replace flows).
|
|
418
|
+
await poll({
|
|
419
|
+
description: `wait for AI Search namespace "${name}" deletion to propagate`,
|
|
420
|
+
fn: () =>
|
|
421
|
+
api.get(`/accounts/${api.accountId}/ai-search/namespaces/${name}`),
|
|
422
|
+
predicate: (res) => res.status === 404,
|
|
423
|
+
initialDelay: 500,
|
|
424
|
+
maxDelay: 3000,
|
|
425
|
+
timeout: 90_000,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* List all AI Search namespaces in an account
|
|
431
|
+
*/
|
|
432
|
+
export async function listAiSearchNamespaces(
|
|
433
|
+
api: CloudflareApi,
|
|
434
|
+
): Promise<AiSearchNamespaceApiResponse[]> {
|
|
435
|
+
return await extractCloudflareResult<AiSearchNamespaceApiResponse[]>(
|
|
436
|
+
"list AI Search namespaces",
|
|
437
|
+
api.get(`/accounts/${api.accountId}/ai-search/namespaces`),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
@@ -291,22 +291,56 @@ export async function listAiSearchTokens(
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
/**
|
|
294
|
-
* Delete an AI Search token
|
|
294
|
+
* Delete an AI Search token.
|
|
295
|
+
*
|
|
296
|
+
* Retries briefly on `7076 token_in_use_by_instances` to cover residual
|
|
297
|
+
* cross-colo lag (CF's server retries the guard internally once after
|
|
298
|
+
* 500ms; the spurious-409 window is typically sub-second). We layer two
|
|
299
|
+
* client-side retries with 1s + 2s delays on top.
|
|
300
|
+
*
|
|
301
|
+
* A persistent 409 after the short retry window means the token is
|
|
302
|
+
* genuinely still referenced by another AI Search instance. We throw an
|
|
303
|
+
* actionable error rather than silently leaking the token — shared tokens
|
|
304
|
+
* should be modeled explicitly via an `AiSearchToken` resource.
|
|
295
305
|
*/
|
|
296
306
|
export async function deleteAiSearchToken(
|
|
297
307
|
api: CloudflareApi,
|
|
298
308
|
tokenId: string,
|
|
299
309
|
): Promise<void> {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
310
|
+
const retryDelaysMs = [1000, 2000]; // 2 retries, ~3s total
|
|
311
|
+
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
|
312
|
+
try {
|
|
313
|
+
await extractCloudflareResult(
|
|
314
|
+
`delete AI Search token "${tokenId}"`,
|
|
315
|
+
api.delete(`/accounts/${api.accountId}/ai-search/tokens/${tokenId}`),
|
|
316
|
+
);
|
|
307
317
|
return;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error instanceof CloudflareApiError && error.status === 404) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const isTokenInUse =
|
|
323
|
+
error instanceof CloudflareApiError &&
|
|
324
|
+
error.status === 409 &&
|
|
325
|
+
Array.isArray(error.errorData) &&
|
|
326
|
+
error.errorData.some((e: { code?: number }) => e?.code === 7076);
|
|
327
|
+
if (isTokenInUse && attempt < retryDelaysMs.length) {
|
|
328
|
+
await new Promise((resolve) =>
|
|
329
|
+
setTimeout(resolve, retryDelaysMs[attempt]),
|
|
330
|
+
);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (isTokenInUse) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`AI Search token "${tokenId}" is still referenced by another instance. ` +
|
|
336
|
+
"If the token is intentionally shared across instances, manage it " +
|
|
337
|
+
"explicitly via an `AiSearchToken` resource with `delete: false`, " +
|
|
338
|
+
"or delete all referencing instances before deleting the token.",
|
|
339
|
+
{ cause: error },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
308
343
|
}
|
|
309
|
-
throw error;
|
|
310
344
|
}
|
|
311
345
|
}
|
|
312
346
|
|