1import * as aisearch from "@distilled.cloud/cloudflare/aisearch";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { isResourceOfType, Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.AI.SearchNamespace" as const;
13type TypeId = typeof TypeId;
14
15export type SearchNamespaceProps = {
16 /**
17 * Namespace name. Lowercase letters, digits, and hyphens; must start and
18 * end with an alphanumeric character; 1-28 characters. The name is the
19 * namespace's identity (it appears in the API path) and cannot be
20 * renamed — changing it triggers a replacement. If omitted, a unique
21 * name is generated from the app, stage, and logical ID.
22 * @default ${app}-${id}-${stage}-${suffix}
23 */
24 name?: string;
25 /**
26 * Optional human-readable description for the namespace.
27 * Max 256 characters.
28 */
29 description?: string;
30};
31
32export type SearchNamespaceAttributes = {
33 /**
34 * Namespace name (its identity within the account).
35 */
36 name: string;
37 /**
38 * The Cloudflare account the namespace belongs to.
39 */
40 accountId: string;
41 /**
42 * Human-readable description, when set.
43 */
44 description: string | undefined;
45 /**
46 * When the namespace was created.
47 */
48 createdAt: string | undefined;
49};
50
51export type SearchNamespace = Resource<
52 TypeId,
53 SearchNamespaceProps,
54 SearchNamespaceAttributes,
55 never,
56 Providers
57>;
58
59/**
60 * A Cloudflare.AI. Search namespace — a logical grouping for AI Search
61 * instances within an account.
62 *
63 * Namespaces partition AI Search (formerly AutoRAG) instances: each
64 * namespace owns its own set of namespace-scoped instances and can be
65 * searched or queried as a unit. The namespace `name` is its identity —
66 * changing it triggers a replacement; only the `description` is mutable
67 * in place.
68 *
69 * The account-provided `default` namespace is reserved: it always exists
70 * and Cloudflare disallows modifying or deleting it. Alchemy adopts it so
71 * it can be referenced and bound, but never updates or tears it down.
72 *
73 * ### Creating a Namespace
74 * **Example:** Generated name
75 * ```typescript
76 * const ns = yield* Cloudflare.AI.SearchNamespace("docs", {});
77 * ```
78 *
79 * **Example:** Explicit name and description
80 * ```typescript
81 * const ns = yield* Cloudflare.AI.SearchNamespace("docs", {
82 * name: "docs-search",
83 * description: "Search over the product documentation",
84 * });
85 * ```
86 *
87 * ### Updating a Namespace
88 * **Example:** Change the description in place
89 * Only the `description` is mutable; changing `name` replaces the namespace.
90 * ```typescript
91 * const ns = yield* Cloudflare.AI.SearchNamespace("docs", {
92 * name: "docs-search",
93 * description: "Search over docs and changelogs",
94 * });
95 * ```
96 *
97 * ### Grouping pipelines
98 * Group {@link Search} pipelines under the namespace by passing the
99 * namespace resource itself to each pipeline's `namespace` prop. The engine
100 * orders each pipeline after the namespace on deploy and tears them down
101 * before it on destroy.
102 * **Example:** Two pipelines in one namespace
103 * ```typescript
104 * const ns = yield* Cloudflare.AI.SearchNamespace("docs", {});
105 * const guides = yield* Cloudflare.AI.Search("guides", {
106 * source: guidesBucket,
107 * namespace: ns,
108 * });
109 * const api = yield* Cloudflare.AI.Search("api", {
110 * source: apiBucket,
111 * namespace: ns,
112 * });
113 * ```
114 *
115 * ### Binding to an Effect Worker
116 * Bind the namespace with `Cloudflare.AI.QuerySearchNamespace(namespace)`,
117 * which attaches the `ai_search_namespace` binding and returns a client
118 * whose `.get(name)` selects an instance within the namespace at runtime.
119 * Provide {@link QuerySearchNamespaceBinding} in the Worker's runtime
120 * layer.
121 * **Example:** Select an instance per request
122 * ```typescript
123 * import * as Cloudflare from "alchemy/Cloudflare";
124 * import * as Effect from "effect/Effect";
125 * import { HttpServerRequest } from "effect/http/HttpServerRequest";
126 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
127 *
128 * export default class Api extends Cloudflare.Worker<Api>()(
129 * "api",
130 * { main: import.meta.url },
131 * Effect.gen(function* () {
132 * const ns = yield* Cloudflare.AI.QuerySearchNamespace(Docs);
133 *
134 * return {
135 * fetch: Effect.gen(function* () {
136 * const url = new URL((yield* HttpServerRequest).url);
137 * const instance = url.searchParams.get("instance") ?? "guides";
138 * const query = url.searchParams.get("q") ?? "";
139 * const answer = yield* ns.get(instance).chatCompletions({
140 * messages: [{ role: "user", content: query }],
141 * });
142 * return yield* HttpServerResponse.json(answer);
143 * }),
144 * };
145 * }).pipe(Effect.provide(Cloudflare.AI.QuerySearchNamespaceBinding)),
146 * ) {}
147 * ```
148 *
149 * ### Binding to an Async Worker
150 * For a vanilla `async fetch` Worker, pass the namespace under `Worker.env`.
151 * `InferEnv` types `env.SEARCH` as the runtime `SearchNamespace` handle.
152 * **Example:** Async Worker via `env`
153 * ```typescript
154 * export const Api = Cloudflare.Worker("api", {
155 * main: "./worker.ts",
156 * env: { SEARCH: namespace },
157 * });
158 * export type ApiEnv = Cloudflare.InferEnv<typeof Api>;
159 *
160 * // worker.ts
161 * export default {
162 * async fetch(request: Request, env: ApiEnv): Promise<Response> {
163 * const query = new URL(request.url).searchParams.get("q") ?? "";
164 * return Response.json(
165 * await env.SEARCH.get("guides").chatCompletions({
166 * messages: [{ role: "user", content: query }],
167 * }),
168 * );
169 * },
170 * };
171 * ```
172 *
173 * @see https://developers.cloudflare.com/ai-search/
174 *
175 * @resource
176 * @product AI Search
177 * @category AI
178 */
179export const SearchNamespace = Resource<SearchNamespace>(TypeId, {
180 aliases: ["Cloudflare.AiSearch.Namespace"],
181});
182
183/**
184 * Returns true if the given value is a SearchNamespace resource.
185 */
186export const isSearchNamespace = (value: unknown): value is SearchNamespace =>
187 isResourceOfType(value, TypeId);
188
189export const SearchNamespaceProvider = () =>
190 Provider.succeed(SearchNamespace, {
191 stables: ["name", "accountId", "createdAt"],
192 diff: Effect.fn(function* ({ id, olds, news, output }) {
193 const { accountId } = yield* yield* CloudflareEnvironment;
194 if (!isResolved(news)) return undefined;
195 if ((output?.accountId ?? accountId) !== accountId) {
196 return { action: "replace" } as const;
197 }
198 // The name is the namespace's identity (it is the API path
199 // parameter) — renaming is a replacement. Props are all optional, so a
200 // no-props resource resolves `news`/`olds` to `undefined` at runtime.
201 const oldName =
202 output?.name ?? (yield* createNamespaceName(id, olds?.name));
203 // Auto-generated names are engine-owned: the deployed name stays
204 // authoritative even if the generator would name this id differently
205 // today. Only an explicit user-provided name can force a replace.
206 const newName = news?.name ?? oldName;
207 if (newName !== oldName) {
208 // A user-pinned name collides with the still-existing old
209 // namespace only when both resolve to the same string — they
210 // don't here, so create-before-delete is safe.
211 return { action: "replace" } as const;
212 }
213 return undefined;
214 }),
215 read: Effect.fn(function* ({ id, output, olds }) {
216 const { accountId } = yield* yield* CloudflareEnvironment;
217 const acct = output?.accountId ?? accountId;
218 // The name is deterministic (explicit prop or generated from the
219 // logical id), so a cold read (lost state) resolves the same
220 // identifier as the original create did.
221 const name = output?.name ?? (yield* createNamespaceName(id, olds?.name));
222 const observed = yield* getNamespace(acct, name);
223 return observed ? toAttributes(observed, acct) : undefined;
224 }),
225 reconcile: Effect.fn(function* ({ id, news, output }) {
226 const { accountId } = yield* yield* CloudflareEnvironment;
227 const acct = output?.accountId ?? accountId;
228 // Props are all optional, so a no-props resource resolves `news` to
229 // `undefined` at runtime — fall back to the empty desired shape.
230 const props = news ?? {};
231 const name = output?.name ?? (yield* createNamespaceName(id, props.name));
232
233 // Observe — `output.name` is a cache, not a guarantee: a missing
234 // namespace falls through to create.
235 let observed = yield* getNamespace(acct, name);
236
237 if (!observed) {
238 // Ensure — greenfield (or out-of-band delete). A concurrent
239 // create surfaces as `NamespaceAlreadyExists` — treat it as a
240 // race and fall through to the sync path against the observed
241 // namespace.
242 const ensured = yield* aisearch
243 .createNamespace({
244 accountId: acct,
245 name,
246 description: props.description,
247 })
248 .pipe(
249 Effect.map((created) => ({ created: true as const, ns: created })),
250 Effect.catchTag("NamespaceAlreadyExists", (originalError) =>
251 Effect.gen(function* () {
252 const existing = yield* getNamespace(acct, name);
253 if (!existing) return yield* Effect.fail(originalError);
254 return { created: false as const, ns: existing };
255 }),
256 ),
257 );
258 if (ensured.created) {
259 return toAttributes(ensured.ns, acct);
260 }
261 observed = ensured.ns;
262 }
263
264 // The account-provided `default` namespace is reserved: it always
265 // exists and its metadata cannot be modified
266 // (`cannot_modify_default_namespace`). Adopt it as-is so it can be
267 // referenced/bound, but never attempt to mutate it.
268 if (name === "default") {
269 return toAttributes(observed, acct);
270 }
271
272 // Sync — the description is the only mutable aspect. Diff observed
273 // cloud state against desired; skip the PUT entirely on a no-op.
274 const observedDescription = normalize(observed.description);
275 if (observedDescription === props.description) {
276 return toAttributes(observed, acct);
277 }
278 const updated = yield* aisearch
279 .updateNamespace({
280 accountId: acct,
281 name,
282 // `null` clears a previously-set description.
283 description: props.description ?? null,
284 })
285 .pipe(
286 // The observe read can be eventually consistent: a namespace
287 // deleted out-of-band may still appear in `readNamespace` and
288 // then 404 here. Treat that as "missing" and recreate with the
289 // desired shape.
290 Effect.catchTag("NamespaceNotFound", () =>
291 aisearch.createNamespace({
292 accountId: acct,
293 name,
294 description: props.description,
295 }),
296 ),
297 );
298 return toAttributes(updated, acct);
299 }),
300 delete: Effect.fn(function* ({ output }) {
301 // The account-provided `default` namespace cannot be deleted
302 // (`cannot_modify_default_namespace`); binding/adopting it must never
303 // tear it down, so its delete is a no-op.
304 if (output.name === "default") return;
305 // A missing namespace (already deleted) is success. Instances inside
306 // the namespace must be deleted first — the engine orders that via
307 // the `namespace` reference on dependent resources.
308 yield* aisearch
309 .deleteNamespace({ accountId: output.accountId, name: output.name })
310 .pipe(Effect.catchTag("NamespaceNotFound", () => Effect.void));
311 }),
312 // Account-scoped collection: namespaces are enumerated directly under
313 // the account (no parent fan-out). Exhaustively paginate `listNamespaces`
314 // and hydrate each item into the exact `read` Attributes shape.
315 list: Effect.fn(function* () {
316 const { accountId } = yield* yield* CloudflareEnvironment;
317 return yield* aisearch.listNamespaces.pages({ accountId }).pipe(
318 Stream.runCollect,
319 Effect.map((chunk) =>
320 Array.from(chunk).flatMap((page) =>
321 (page.result ?? [])
322 // The account-provided `default` namespace can't be deleted
323 // (`cannot_modify_default_namespace`); never enumerate it for
324 // account-wide teardown.
325 .filter((ns) => ns.name !== "default")
326 .map((ns) => toAttributes(ns, accountId)),
327 ),
328 ),
329 );
330 }),
331 });
332
333type ObservedNamespace = aisearch.ReadNamespaceResponse;
334
335/**
336 * Read a namespace by name, mapping "gone" (`NamespaceNotFound`,
337 * Cloudflare error code 7063) to `undefined`.
338 */
339const getNamespace = (accountId: string, name: string) =>
340 aisearch
341 .readNamespace({ accountId, name })
342 .pipe(
343 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(undefined)),
344 );
345
346const createNamespaceName = (id: string, name: string | undefined) =>
347 Effect.gen(function* () {
348 // Cloudflare restricts namespace names to lowercase alphanumerics and
349 // hyphens, 1-28 characters.
350 return (
351 name ??
352 (yield* createPhysicalName({ id, lowercase: true, maxLength: 28 }))
353 );
354 });
355
356/**
357 * Cloudflare returns `null` for an unset description; desired-state
358 * shapes leave it `undefined`. Collapse both to `undefined` for diffing.
359 */
360const normalize = <T>(value: T | null | undefined): T | undefined =>
361 value ?? undefined;
362
363const toAttributes = (
364 ns:
365 | ObservedNamespace
366 | aisearch.CreateNamespaceResponse
367 | aisearch.UpdateNamespaceResponse,
368 accountId: string,
369): SearchNamespaceAttributes => ({
370 name: ns.name,
371 accountId,
372 description: normalize(ns.description),
373 createdAt: ns.createdAt,
374});