diff --git a/distilled b/distilled index 61a84be97..f68bae75d 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 61a84be97a04a3248d4b807181a06e3f5b78783c +Subproject commit f68bae75dc89f0d83c3e4c10e2af5e348ea0988d diff --git a/package.json b/package.json index b1ad90ae3..163d2ddb3 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "logs:otel": "doppler run -c dev --project alchemy-v2 -- bun alchemy logs ./stacks/otel.ts", "nuke:aws:run": "bash scripts/nuke-aws.sh --no-dry-run --no-prompt --prompt-delay 3", "nuke:aws": "bash scripts/nuke-aws.sh", - "nuke": "bun alchemy unsafe nuke ./stacks/nuke.ts --exclude 'Cloudflare.Zone*' --exclude 'Cloudflare.Account' --exclude 'Cloudflare.Account.Member' --exclude 'Cloudflare.Dns*' --exclude 'Cloudflare.AccountApiToken' --exclude 'Cloudflare.Secret*' --profile testing --concurrency 32 --filter 'resource.Type === \"Cloudflare.Worker\" && resource.workerName?.startsWith(\"alchemy-state\") || resource.workerName === \"Api\"'", + "nuke": "bun alchemy unsafe nuke ./stacks/nuke.ts --exclude 'Cloudflare.Zone*' --exclude 'Cloudflare.Account' --exclude 'Cloudflare.Account.Member' --exclude 'Cloudflare.Dns*' --exclude 'Cloudflare.AccountApiToken' --exclude 'Cloudflare.Secret*' --exclude 'Cloudflare.Organization' --profile testing --concurrency 32 --filter 'resource.Type === \"Cloudflare.Worker\" && resource.workerName?.startsWith(\"alchemy-state\") || resource.workerName === \"Api\"'", "opencode": "OPENCODE_EXPERIMENTAL_OXFMT=true opencode", "prepare": "husky", "publish:npm": "bun run build && bun run --filter alchemy publish:npm", diff --git a/packages/alchemy/src/Cloudflare/AiSearch/AiSearch.ts b/packages/alchemy/src/Cloudflare/AiSearch/AiSearch.ts new file mode 100644 index 000000000..b22c9ddb3 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/AiSearch/AiSearch.ts @@ -0,0 +1,402 @@ +import * as Construct from "../../Construct.ts"; +import type { Input, InputProps } from "../../Input.ts"; +import { isResource } from "../../Resource.ts"; +import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { R2Bucket } from "../R2/R2Bucket.ts"; +import { + AiSearchInstance, + type AiSearchInstanceProps, + type AiSearchSourceParams, +} from "./AiSearchInstance.ts"; +import type { AiSearchNamespace } from "./AiSearchNamespace.ts"; +import { AiSearchToken } from "./AiSearchToken.ts"; + +type WebCrawlerParams = NonNullable; + +/** + * How a web crawler discovers and parses pages — Cloudflare's `parseType` + * folded together with its parse options. Defaults to `type: "sitemap"`. + */ +export type AiSearchParse = { + /** + * How pages are discovered: + * - `"sitemap"` (default) — read `/sitemap.xml` (found via + * `robots.txt`) and index the URLs it lists. + * - `"crawl"` — start at `source` and follow links. + * - `"feed-rss"` — treat the seed as an RSS / Atom feed. + * @default "sitemap" + */ + type?: NonNullable; +} & NonNullable; + +/** + * Link-discovery options for a web crawler (mainly for `parse.type: "crawl"`): + * `depth`, `includeSubdomains`, `includeExternalLinks`, `maxAge`, and `source` + * (`"all"` | `"sitemaps"` | `"links"`). + */ +export type AiSearchCrawl = NonNullable; + +/** + * Where crawled content is stored. Cloudflare provisions managed storage by + * default; set this to store output in an R2 bucket you control. + */ +export type AiSearchStore = { + /** R2 bucket to store crawl output in. */ + bucket: R2Bucket; + /** R2 data-residency jurisdiction for the store bucket. */ + jurisdiction?: string; +}; + +/** + * Props common to every AI Search pipeline, regardless of data source. The + * underlying instance's `type`, `source`, and `sourceParams` are derived from + * the source-specific variant, so they're omitted here. + */ +export type AiSearchSharedProps = Omit< + InputProps, + "type" | "source" | "sourceParams" | "namespace" +> & { + /** + * Namespace to group this pipeline under. Pass an {@link AiSearchNamespace} + * resource — the engine orders this pipeline after the namespace on deploy + * and tears it down before the namespace on destroy. Omit to place the + * pipeline in the account-provided `default` namespace. The namespace is + * immutable — changing it triggers a replacement. + * @default the account-provided `default` namespace + */ + namespace?: AiSearchNamespace; +}; + +/** + * An R2-backed AI Search pipeline. Passing an {@link R2Bucket} as `source` + * selects R2 as the data source. + */ +export type AiSearchR2Props = AiSearchSharedProps & { + /** + * The R2 bucket to index. AI Search needs a service token to read it; the + * construct provisions one unless you pass your own `tokenId`. + */ + source: R2Bucket; + /** Only index object keys under this prefix. */ + prefix?: string; + /** + * Micromatch glob patterns; only objects matching at least one are + * indexed (`*` within a path segment, `**` across segments). Max 10. + */ + include?: string[]; + /** + * Micromatch glob patterns; objects matching any are skipped. Exclude + * takes precedence over `include`. Max 10. + */ + exclude?: string[]; + /** R2 data-residency jurisdiction of the source bucket. */ + jurisdiction?: string; + parse?: never; + crawl?: never; + store?: never; +}; + +/** + * A web-crawler-backed AI Search pipeline. Passing a URL as `source` selects + * the web crawler as the data source (no service token needed). + */ +export type AiSearchWebCrawlerProps = AiSearchSharedProps & { + /** Seed URL to crawl and index. */ + source: Input; + /** How pages are discovered and parsed. */ + parse?: AiSearchParse; + /** How links are followed from the seed. */ + crawl?: AiSearchCrawl; + /** Where crawl output is stored (defaults to managed storage). */ + store?: AiSearchStore; + prefix?: never; + include?: never; + exclude?: never; + jurisdiction?: never; +}; + +/** + * Props for the {@link AiSearch} construct — a union discriminated by what you + * pass as `source`: an {@link R2Bucket} for an R2 source, or a URL string for + * a web crawl. + */ +export type AiSearchProps = AiSearchR2Props | AiSearchWebCrawlerProps; + +/** + * The result of the {@link AiSearch} construct. It *is* the underlying + * {@link AiSearchInstance}, augmented with the managed `serviceToken`, so it + * can be passed anywhere an `AiSearchInstance` is expected — + * `AiSearchInstance.bind(search)`, a Worker's `env`, etc. + */ +export type AiSearch = AiSearchInstance & { + /** + * The managed AI Search service token minted for an R2 source, or + * `undefined` when the source is a web crawler (no token needed) or you + * supplied your own `tokenId`. + */ + serviceToken: AiSearchToken | undefined; +}; + +/** + * A convenience construct over {@link AiSearchInstance} that auto-creates the + * sub-resources an AI Search instance typically needs, so a single call wires + * up a working pipeline. The data source is chosen by what you pass as + * `source` — an {@link R2Bucket} for R2, or a URL for a web crawl: + * + * - For an R2 source, it mints a least-privilege {@link AccountApiToken} + * (`AI Search Index Engine`, stable child `ApiToken`) and an + * {@link AiSearchToken} wrapping it (stable child `Token`), then passes + * that token to the instance. + * Cloudflare requires a service token to read an R2 bucket and only + * provisions one through the dashboard / Wrangler — never on a + * programmatic API create — so the construct provisions it for you. Pass + * your own `tokenId` to skip minting and reuse an existing token. + * - It creates the {@link AiSearchInstance} (child `Instance`) with the + * remaining props. + * + * Drop down to the low-level resources directly when you need to share a + * token across instances, adopt an existing one, or bind a namespace. + * + * The returned value *is* an {@link AiSearchInstance} (augmented with the + * managed `serviceToken`, `undefined` for a web crawler), so an `AiSearch` + * is usable anywhere an `AiSearchInstance` is expected — pass it straight to + * `AiSearchInstance.bind(search)` or a Worker's `env`. + * + * @resource + * @product AI Search + * @category AI + * @section Creating an AI Search pipeline + * @example R2-backed instance (token provisioned for you) + * Pass an {@link R2Bucket} as `source` — its presence selects R2. + * ```typescript + * const bucket = yield* Cloudflare.R2Bucket("docs"); + * const search = yield* Cloudflare.AiSearch("docs-search", { + * source: bucket, + * }); + * ``` + * + * @example Index only part of a bucket + * ```typescript + * const search = yield* Cloudflare.AiSearch("docs-search", { + * source: bucket, + * prefix: "docs/", + * include: ["/docs/**"], + * exclude: ["/docs/drafts/**"], + * }); + * ``` + * + * @example Reuse an existing service token + * ```typescript + * const search = yield* Cloudflare.AiSearch("docs-search", { + * source: bucket, + * tokenId: existingToken.id, + * }); + * ``` + * + * @example Web-crawler source + * Pass a URL as `source` to crawl and index a website (no service token + * needed). `parse.type` defaults to `"sitemap"`; use `"crawl"` to follow + * links from the seed instead. + * ```typescript + * const search = yield* Cloudflare.AiSearch("site-search", { + * source: "https://example.com", + * parse: { type: "crawl", contentSelector: [{ path: "/docs", selector: "main" }] }, + * crawl: { depth: 3, includeSubdomains: true }, + * }); + * ``` + * + * @example Store crawl output in your own bucket + * ```typescript + * const store = yield* Cloudflare.R2Bucket("crawl-store"); + * const search = yield* Cloudflare.AiSearch("site-search", { + * source: "https://example.com", + * parse: { type: "crawl" }, + * store: { bucket: store }, + * }); + * ``` + * + * @section Binding to an Effect Worker + * + * The returned `search` is an {@link AiSearchInstance}. Bind it during the + * Worker's init phase with `Cloudflare.AiSearchInstance.bind(search)`, which + * attaches the single-instance `ai_search` binding and hands back an + * Effect-native client whose `search` / `chatCompletions` methods return + * `Effect`s. Provide `AiSearchInstanceBindingLive` in the Worker's runtime + * layer. + * + * @example Effect Worker that answers from AI Search + * ```typescript + * import * as Cloudflare from "alchemy/Cloudflare"; + * import * as Effect from "effect/Effect"; + * import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export default class Api extends Cloudflare.Worker()( + * "api", + * { main: import.meta.filename }, + * Effect.gen(function* () { + * const bucket = yield* Cloudflare.R2Bucket("docs"); + * const aiSearch = yield* Cloudflare.AiSearch("docs-search", { + * source: bucket, + * }); + * const search = yield* Cloudflare.AiSearchInstance.bind(aiSearch); + * + * return { + * fetch: Effect.gen(function* () { + * const request = yield* HttpServerRequest; + * const query = new URL(request.url).searchParams.get("q") ?? ""; + * const answer = yield* search.chatCompletions({ + * messages: [{ role: "user", content: query }], + * }); + * return yield* HttpServerResponse.json(answer); + * }), + * }; + * }).pipe(Effect.provide(Cloudflare.AiSearchInstanceBindingLive)), + * ) {} + * ``` + * + * @section Binding to an Async Worker + * + * For a vanilla `async fetch` Worker, pass the `search` under `Worker.env`. + * The engine attaches the same single-instance `ai_search` binding (see + * `toBinding` in `WorkerAsyncBindings.ts`), orders the deploy + * bucket → instance → worker, and `InferEnv` types `env.SEARCH` as the + * runtime `AiSearchInstance` handle — no hand-written types. + * + * @example Async Worker that answers from AI Search + * ```typescript + * // stack.ts + * const bucket = yield* Cloudflare.R2Bucket("docs"); + * const search = yield* Cloudflare.AiSearch("docs-search", { + * source: bucket, + * }); + * + * export const Api = Cloudflare.Worker("api", { + * main: "./worker.ts", + * env: { SEARCH: search }, + * }); + * export type ApiEnv = Cloudflare.InferEnv; + * + * // worker.ts + * import type { ApiEnv } from "./stack.ts"; + * export default { + * async fetch(request: Request, env: ApiEnv): Promise { + * const query = new URL(request.url).searchParams.get("q") ?? ""; + * const answer = await env.SEARCH.chatCompletions({ + * messages: [{ role: "user", content: query }], + * }); + * return Response.json(answer); + * }, + * }; + * ``` + * + * @see https://developers.cloudflare.com/ai-search/ + */ +export const AiSearch = Construct.fn(function* ( + id: string, + props: AiSearchProps, +) { + const { + source, + prefix, + include, + exclude, + jurisdiction, + parse, + crawl, + store, + namespace, + ...shared + } = props; + + let tokenId = shared.tokenId; + let serviceToken: AiSearchToken | undefined; + let type: "r2" | "web-crawler"; + let instanceSource: Input; + let sourceParams: Input | undefined; + + // Discriminate the data source on what `source` is: an R2Bucket (a resource) + // indexes a bucket and needs a service token to read it; a URL crawls a seed + // and doesn't. + if (isResource(source)) { + const bucket = source as R2Bucket; + type = "r2"; + instanceSource = bucket.bucketName; + sourceParams = clean({ + prefix, + includeItems: include, + excludeItems: exclude, + r2Jurisdiction: jurisdiction, + }) as Input | undefined; + + // Cloudflare requires a service token to read an R2 source and only + // auto-creates one via the dashboard/Wrangler — not on a programmatic + // API create. Mint one ourselves unless the caller passed a `tokenId`. + if (tokenId === undefined) { + const { accountId } = yield* yield* CloudflareEnvironment; + const apiToken = yield* AccountApiToken("ApiToken", { + policies: [ + { + effect: "allow", + permissionGroups: ["AI Search Index Engine"], + resources: { + [`com.cloudflare.api.account.${accountId}`]: "*", + }, + }, + ], + }); + serviceToken = yield* AiSearchToken("Token", { + cfApiId: apiToken.tokenId, + cfApiKey: apiToken.value, + }); + tokenId = serviceToken.id; + } + } else { + type = "web-crawler"; + instanceSource = source as Input; + + const { type: parseType, ...parseOptions } = parse ?? {}; + const webCrawler = clean({ + parseType, + parseOptions: clean(parseOptions), + crawlOptions: crawl ? clean(crawl) : undefined, + storeOptions: store + ? clean({ + storageId: store.bucket.bucketName, + storageType: "r2" as const, + r2Jurisdiction: store.jurisdiction, + }) + : undefined, + }); + sourceParams = webCrawler + ? ({ webCrawler } as Input) + : undefined; + } + + const instance = yield* AiSearchInstance("Instance", { + ...shared, + // The instance is keyed by namespace name; pass the namespace's `name` + // output so the engine orders instance-after-namespace. + namespace: namespace?.name, + type, + source: instanceSource, + tokenId, + sourceParams, + }); + + // Return the instance itself (augmented with the managed `serviceToken`) + // so an `AiSearch` is usable anywhere an `AiSearchInstance` is expected — + // `AiSearchInstance.bind(search)`, `env: { SEARCH: search }`, etc. + return Object.assign(instance, { serviceToken }) as AiSearch; +}); + +/** Drop `undefined` entries; return `undefined` when nothing is left. */ +const clean = >( + obj: T, +): { [K in keyof T]: T[K] } | undefined => { + const entries = Object.entries(obj).filter(([, v]) => v !== undefined); + return entries.length + ? (Object.fromEntries(entries) as { [K in keyof T]: T[K] }) + : undefined; +}; diff --git a/packages/alchemy/src/Cloudflare/AiSearch/AiSearchBinding.ts b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchBinding.ts new file mode 100644 index 000000000..763f625cf --- /dev/null +++ b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchBinding.ts @@ -0,0 +1,339 @@ +import type * as runtime from "@cloudflare/workers-types"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Binding from "../../Binding.ts"; +import type { ResourceLike } from "../../Resource.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { isWorker, WorkerEnvironment } from "../Workers/Worker.ts"; +import type { AiSearchInstance } from "./AiSearchInstance.ts"; +import type { AiSearchNamespace } from "./AiSearchNamespace.ts"; + +/** + * Error raised by AI Search runtime binding operations. + */ +export class AiSearchError extends Data.TaggedError("AiSearchError")<{ + /** + * Human-readable runtime error message. + */ + message: string; + /** + * Original error thrown by the Cloudflare AI Search runtime binding. + */ + cause: unknown; +}> {} + +/** + * Effect-native client for a Cloudflare AI Search Worker binding. + * + * Wraps the standalone `AiSearchInstance` runtime binding (the binding the + * `ai_search` type resolves to — the deprecated `AutoRAG` shape is gone) so + * each operation returns an Effect tagged with {@link AiSearchError}. Obtain + * one from `Cloudflare.AiSearchInstance.bind(instance)` (or a namespace + * client's `.get(instanceName)`) during the Worker's init phase. + */ +export interface AiSearchClient { + /** + * Effect resolving to the raw underlying Cloudflare `AiSearchInstance` + * binding. Use this for operations not surfaced below (`items`, `jobs`, + * `update`, or streaming `chatCompletions`). + */ + raw: Effect.Effect; + /** + * Retrieve the chunks most relevant to a query without generation. + */ + search( + params: runtime.AiSearchSearchRequest, + ): Effect.Effect< + runtime.AiSearchSearchResponse, + AiSearchError, + RuntimeContext + >; + /** + * Run retrieval-augmented generation: retrieve relevant chunks and answer + * with the configured model (non-streaming). + */ + chatCompletions( + params: runtime.AiSearchChatCompletionsRequest, + ): Effect.Effect< + runtime.AiSearchChatCompletionsResponse, + AiSearchError, + RuntimeContext + >; + /** + * Metadata about this instance (id, models, source, status, …). + */ + info(): Effect.Effect< + runtime.AiSearchInstanceInfo, + AiSearchError, + RuntimeContext + >; + /** + * Indexing statistics (item counts per status, last activity, engine). + */ + stats(): Effect.Effect< + runtime.AiSearchStatsResponse, + AiSearchError, + RuntimeContext + >; +} + +const tryAiSearch = ( + fn: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: (cause) => + new AiSearchError({ + message: + cause instanceof Error + ? cause.message + : "Unknown AI Search runtime error", + cause, + }), + }); + +/** + * Build an {@link AiSearchClient} from an Effect that lazily resolves the raw + * `AiSearchInstance` runtime binding. The resolution is deferred so + * `.bind(...)` works at both plan time (where `WorkerEnvironment` is absent) + * and runtime. + */ +const makeClient = ( + rawEff: Effect.Effect, +): AiSearchClient => { + const use = (fn: (raw: runtime.AiSearchInstance) => Promise) => + Effect.flatMap(rawEff, (raw) => tryAiSearch(() => fn(raw))); + return { + raw: rawEff, + search: (params) => use((raw) => raw.search(params)), + chatCompletions: (params) => use((raw) => raw.chatCompletions(params)), + info: () => use((raw) => raw.info()), + stats: () => use((raw) => raw.stats()), + } satisfies AiSearchClient; +}; + +/** + * Effect that, when run, yields a memoized Effect resolving the raw runtime + * binding under `logicalId`. The lookup is deferred via `WorkerEnvironment` + * (absent at plan time) so `.bind(...)` is safe in both phases. + */ +const resolveBinding = (logicalId: string) => + Effect.serviceOption(WorkerEnvironment).pipe( + Effect.map(Option.getOrUndefined), + Effect.map((env) => (env as Record | undefined)?.[logicalId]!), + Effect.cached, + ); + +/** + * Binding service turning an {@link AiSearchInstance} into an Effect-native + * {@link AiSearchClient} for Worker runtime code. The single-instance + * `ai_search` binding resolves directly to a runtime `AiSearchInstance`. + * + * Provide {@link AiSearchInstanceBindingLive} in the Worker's runtime layer. + * + * @binding + * @category AI + * + * @section Querying AI Search + * @example Retrieve and generate from a Worker + * Bind the instance during the Worker's init phase, then use `search` + * (retrieval only) or `chatCompletions` (retrieval + generation) from request + * handlers. + * ```typescript + * const search = yield* Cloudflare.AiSearchInstance.bind(instance); + * + * return { + * fetch: Effect.gen(function* () { + * const answer = yield* search.chatCompletions({ + * messages: [{ role: "user", content: "How do I deploy?" }], + * }); + * return yield* HttpServerResponse.json(answer); + * }), + * }; + * ``` + */ +export class AiSearchInstanceBinding extends Binding.Service< + AiSearchInstanceBinding, + (instance: AiSearchInstance) => Effect.Effect +>()("Cloudflare.AiSearch.InstanceBinding") {} + +/** + * Runtime layer for {@link AiSearchInstanceBinding}. + */ +export const AiSearchInstanceBindingLive = Layer.effect( + AiSearchInstanceBinding, + Effect.gen(function* () { + const Policy = yield* AiSearchInstanceBindingPolicy; + return Effect.fn(function* (instance: AiSearchInstance) { + yield* Policy(instance); + const rawEff = yield* resolveBinding( + instance.LogicalId, + ); + return makeClient(rawEff); + }); + }), +); + +/** + * Deploy-time policy attaching a single-instance `ai_search` binding to a + * Worker. + */ +export class AiSearchInstanceBindingPolicy extends Binding.Policy< + AiSearchInstanceBindingPolicy, + (instance: AiSearchInstance) => Effect.Effect +>()("Cloudflare.AiSearch.InstanceBinding") {} + +/** + * Live deploy-time policy layer for {@link AiSearchInstanceBindingPolicy}. + */ +export const AiSearchInstanceBindingPolicyLive = + AiSearchInstanceBindingPolicy.layer.succeed( + Effect.fn(function* (host: ResourceLike, instance: AiSearchInstance) { + if (isWorker(host)) { + yield* host.bind`${instance}`({ + bindings: [ + { + type: "ai_search", + name: instance.LogicalId, + instanceName: instance.instanceId, + namespace: instance.namespace, + }, + ], + }); + } else { + return yield* Effect.die( + new Error( + `AiSearchInstanceBinding does not support runtime '${host.Type}'`, + ), + ); + } + }), + ); + +/** + * Effect-native client for an `ai_search_namespace` binding, wrapping the + * runtime `AiSearchNamespace`. `.get(name)` selects an instance within the + * bound namespace and returns its {@link AiSearchClient}; `list` / `search` + * operate across the namespace. + */ +export interface AiSearchNamespaceClient { + /** + * Effect resolving to the raw underlying Cloudflare `AiSearchNamespace` + * binding. Use this for operations not surfaced below (`create`, `delete`, + * multi-instance `chatCompletions`). + */ + raw: Effect.Effect; + /** + * Select an instance within the bound namespace by name. + */ + get(instanceName: string): AiSearchClient; + /** + * List the instances within the bound namespace. + */ + list( + params?: runtime.AiSearchListInstancesParams, + ): Effect.Effect; + /** + * Search across multiple instances in the bound namespace (requires + * `ai_search_options.instance_ids`). + */ + search( + params: runtime.AiSearchMultiSearchRequest, + ): Effect.Effect< + runtime.AiSearchMultiSearchResponse, + AiSearchError, + RuntimeContext + >; +} + +/** + * Binding service turning an {@link AiSearchNamespace} into an + * {@link AiSearchNamespaceClient} for Worker runtime code. The + * `ai_search_namespace` binding resolves to a runtime `AiSearchNamespace` + * whose `.get(name)` selects an instance within the namespace at runtime. + * + * Provide {@link AiSearchNamespaceBindingLive} in the Worker's runtime layer. + * + * @binding + * @category AI + * + * @section Querying a namespace + * @example Select an instance at runtime + * ```typescript + * const ns = yield* Cloudflare.AiSearchNamespaceBinding.bind(namespace); + * + * return { + * fetch: Effect.gen(function* () { + * const answer = yield* ns.get("docs-search").chatCompletions({ + * messages: [{ role: "user", content: query }], + * }); + * return yield* HttpServerResponse.json(answer); + * }), + * }; + * ``` + */ +export class AiSearchNamespaceBinding extends Binding.Service< + AiSearchNamespaceBinding, + (namespace: AiSearchNamespace) => Effect.Effect +>()("Cloudflare.AiSearch.NamespaceBinding") {} + +/** + * Runtime layer for {@link AiSearchNamespaceBinding}. + */ +export const AiSearchNamespaceBindingLive = Layer.effect( + AiSearchNamespaceBinding, + Effect.gen(function* () { + const Policy = yield* AiSearchNamespaceBindingPolicy; + return Effect.fn(function* (namespace: AiSearchNamespace) { + yield* Policy(namespace); + const nsEff = yield* resolveBinding( + namespace.LogicalId, + ); + return { + raw: nsEff, + get: (instanceName) => + makeClient(nsEff.pipe(Effect.map((ns) => ns.get(instanceName)))), + list: (params) => + Effect.flatMap(nsEff, (ns) => tryAiSearch(() => ns.list(params))), + search: (params) => + Effect.flatMap(nsEff, (ns) => tryAiSearch(() => ns.search(params))), + } satisfies AiSearchNamespaceClient; + }); + }), +); + +/** + * Deploy-time policy attaching an `ai_search_namespace` binding to a Worker. + */ +export class AiSearchNamespaceBindingPolicy extends Binding.Policy< + AiSearchNamespaceBindingPolicy, + (namespace: AiSearchNamespace) => Effect.Effect +>()("Cloudflare.AiSearch.NamespaceBinding") {} + +/** + * Live deploy-time policy layer for {@link AiSearchNamespaceBindingPolicy}. + */ +export const AiSearchNamespaceBindingPolicyLive = + AiSearchNamespaceBindingPolicy.layer.succeed( + Effect.fn(function* (host: ResourceLike, namespace: AiSearchNamespace) { + if (isWorker(host)) { + yield* host.bind`${namespace}`({ + bindings: [ + { + type: "ai_search_namespace", + name: namespace.LogicalId, + namespace: namespace.name, + }, + ], + }); + } else { + return yield* Effect.die( + new Error( + `AiSearchNamespaceBinding does not support runtime '${host.Type}'`, + ), + ); + } + }), + ); diff --git a/packages/alchemy/src/Cloudflare/AiSearch/Instance.ts b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchInstance.ts similarity index 55% rename from packages/alchemy/src/Cloudflare/AiSearch/Instance.ts rename to packages/alchemy/src/Cloudflare/AiSearch/AiSearchInstance.ts index d2ef14927..ad8c0a1ca 100644 --- a/packages/alchemy/src/Cloudflare/AiSearch/Instance.ts +++ b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchInstance.ts @@ -10,6 +10,7 @@ import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Providers } from "../Providers.ts"; +import { AiSearchInstanceBinding } from "./AiSearchBinding.ts"; const AiSearchInstanceTypeId = "Cloudflare.AiSearch.Instance" as const; type AiSearchInstanceTypeId = typeof AiSearchInstanceTypeId; @@ -102,6 +103,17 @@ export type AiSearchInstanceProps = { * @default ${app}-${id}-${stage}-${suffix} */ instanceId?: string; + /** + * Namespace this instance belongs to. AI Search instances are + * namespace-scoped; omitting this places the instance in the + * account-provided `default` namespace. Pass an `AiSearchNamespace`'s + * `name` output to group instances under a custom namespace (which also + * orders this instance after that namespace on deploy and before it on + * destroy). The namespace is immutable — changing it triggers a + * replacement. + * @default "default" + */ + namespace?: string; /** * Data source kind: `r2` indexes objects in an R2 bucket, `web-crawler` * crawls a seed URL. Changing it triggers a replacement. @@ -224,17 +236,30 @@ export type AiSearchInstanceProps = { * 1800, 3600, 7200, 14400, 21600, 43200, 86400. */ syncInterval?: number; + /** + * Kick off an initial indexing job right after the instance is first + * created, instead of waiting for the first scheduled sync. The job is + * triggered best-effort and not awaited — the deploy does not block on + * indexing, which can take much longer than a provisioning step should. + * Has no effect on updates or when no `source` is configured. + * @default false + */ + indexOnCreate?: boolean; }; export type AiSearchInstanceAttributes = { /** * AI Search instance id. Lowercase alphanumeric, hyphens, underscores. */ - id: string; + instanceId: string; /** * The Cloudflare account the instance belongs to. */ accountId: string; + /** + * Namespace the instance belongs to (`default` when unspecified). + */ + namespace: string; /** * Data source kind (`r2` or `web-crawler`). */ @@ -298,17 +323,30 @@ export type AiSearchInstance = Resource< * chat queries against it. Creation returns immediately; the initial * indexing run happens asynchronously. * - * The instance `id`, `type`, `source`, and `embeddingModel` are fixed at - * creation — changing any of them triggers a replacement. Everything else - * (models, chunking, caching, reranking, public endpoint, sync interval) - * is mutable in place. + * The instance `instanceId`, `namespace`, `type`, `source`, and + * `embeddingModel` are fixed at creation — changing any of them triggers a + * replacement. Everything else (models, chunking, caching, reranking, + * public endpoint, sync interval) is mutable in place. + * + * For the common R2 case, prefer the {@link AiSearch} construct, which also + * mints the service token the indexer needs to read your bucket. Use this + * low-level resource directly when you manage the token yourself, share one + * token across instances, or group instances under an {@link + * AiSearchNamespace}. * + * @resource + * @product AI Search + * @category AI * @section Creating an Instance * @example R2-backed instance + * An R2 source needs a service token to read the bucket. Either pass a + * `tokenId` (see {@link AiSearchToken}) or let the {@link AiSearch} + * construct provision one for you. * ```typescript * const bucket = yield* Cloudflare.R2Bucket("docs", {}); * const search = yield* Cloudflare.AiSearchInstance("docs-search", { * source: bucket.bucketName, + * tokenId: serviceToken.id, * }); * ``` * @@ -325,7 +363,111 @@ export type AiSearchInstance = Resource< * }); * ``` * - * @example Web-crawler instance + * @section R2 source options + * For an `r2` source, `sourceParams` filters which objects are indexed (all + * fields optional): + * - `prefix` — only index keys under this prefix. + * - `includeItems` / `excludeItems` — micromatch glob patterns (`*` within a + * path segment, `**` across segments; max 10 each). Only objects matching an + * `includeItems` pattern are indexed; `excludeItems` takes precedence. + * - `r2Jurisdiction` — R2 data-residency jurisdiction of the source bucket. + * @example Index only part of a bucket + * ```typescript + * const search = yield* Cloudflare.AiSearchInstance("docs-search", { + * source: bucket.bucketName, + * tokenId: serviceToken.id, + * sourceParams: { + * prefix: "docs/", + * includeItems: ["/docs/**"], + * excludeItems: ["/docs/drafts/**"], + * }, + * }); + * ``` + * + * @section Web-crawler source options + * `sourceParams.webCrawler` tunes how a `web-crawler` source is fetched, + * parsed, and stored. All fields are optional. + * + * `parseType` selects how pages are discovered: + * - `"sitemap"` (Cloudflare default) — read `/sitemap.xml` (discovered + * via `robots.txt`) and index the URLs it lists. + * - `"crawl"` — start at `source` and follow links. + * - `"feed-rss"` — treat the seed as an RSS / Atom feed. + * + * `crawlOptions` controls link discovery (mainly for `parseType: "crawl"`): + * - `depth` — how many links deep to follow from the seed. + * - `includeSubdomains` — also crawl subdomains of the seed host. + * - `includeExternalLinks` — follow links off the seed host. + * - `maxAge` — skip re-fetching pages younger than this (seconds). + * - `source` — where links come from: `"all"`, `"sitemaps"`, or `"links"`. + * + * `parseOptions` controls how each page is parsed: + * - `useBrowserRendering` — render JS in a headless browser before parsing. + * - `includeImages` — index image content. + * - `specificSitemaps` — explicit sitemap URLs to read (for `"sitemap"`). + * - `contentSelector` — `{ path, selector }[]` CSS selectors scoping which + * part of a page is indexed per URL path. + * - `includeHeaders` — extra request headers sent while crawling. + * + * `storeOptions` overrides where crawled content is stored — Cloudflare + * provisions managed storage by default: + * - `storageId` — R2 bucket name to store crawl output in. + * - `storageType` — `"r2"`. + * - `r2Jurisdiction` — R2 data-residency jurisdiction for the store bucket. + * @example Basic web-crawler instance + * ```typescript + * const search = yield* Cloudflare.AiSearchInstance("site-search", { + * type: "web-crawler", + * source: "https://example.com", + * sourceParams: { webCrawler: { parseType: "crawl" } }, + * }); + * ``` + * @example Fully-configured crawl + * ```typescript + * const search = yield* Cloudflare.AiSearchInstance("site-search", { + * type: "web-crawler", + * source: "https://example.com", + * sourceParams: { + * webCrawler: { + * parseType: "crawl", + * crawlOptions: { + * depth: 3, + * includeSubdomains: true, + * includeExternalLinks: false, + * maxAge: 86_400, + * source: "all", + * }, + * parseOptions: { + * useBrowserRendering: true, + * includeImages: false, + * contentSelector: [{ path: "/docs", selector: "main" }], + * }, + * }, + * }, + * }); + * ``` + * @example Sitemap and RSS sources + * ```typescript + * // Index the URLs listed in one or more sitemaps (the default parse mode). + * const fromSitemap = yield* Cloudflare.AiSearchInstance("sitemap-search", { + * type: "web-crawler", + * source: "https://example.com", + * sourceParams: { + * webCrawler: { + * parseType: "sitemap", + * parseOptions: { specificSitemaps: ["https://example.com/sitemap.xml"] }, + * }, + * }, + * }); + * + * // Treat the seed as an RSS / Atom feed. + * const fromFeed = yield* Cloudflare.AiSearchInstance("feed-search", { + * type: "web-crawler", + * source: "https://example.com/feed.xml", + * sourceParams: { webCrawler: { parseType: "feed-rss" } }, + * }); + * ``` + * @example Store crawl output in a specific R2 bucket * ```typescript * const search = yield* Cloudflare.AiSearchInstance("site-search", { * type: "web-crawler", @@ -333,18 +475,91 @@ export type AiSearchInstance = Resource< * sourceParams: { * webCrawler: { * parseType: "crawl", - * crawlOptions: { depth: 2, includeSubdomains: true }, * storeOptions: { storageId: "my-crawl-bucket", storageType: "r2" }, * }, * }, * }); * ``` * + * @section Grouping under a namespace + * Instances live in a namespace (the account-provided `default` when + * unspecified). Pass an {@link AiSearchNamespace}'s `name` to group related + * instances — the engine then orders this instance after the namespace on + * deploy. The namespace is immutable; changing it replaces the instance. + * @example Place the instance in a custom namespace + * ```typescript + * const ns = yield* Cloudflare.AiSearchNamespace("docs-ns", {}); + * const search = yield* Cloudflare.AiSearchInstance("docs-search", { + * source: bucket.bucketName, + * namespace: ns.name, + * }); + * ``` + * + * @section Binding to an Effect Worker + * Bind the instance during the Worker's init phase with + * `Cloudflare.AiSearchInstance.bind(instance)`, which attaches the + * single-instance `ai_search` binding and returns an Effect-native client + * whose `search` / `chatCompletions` methods return `Effect`s. Provide + * {@link AiSearchInstanceBindingLive} in the Worker's runtime layer. + * @example Effect Worker that answers from AI Search + * ```typescript + * import * as Cloudflare from "alchemy/Cloudflare"; + * import * as Effect from "effect/Effect"; + * import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export default class Api extends Cloudflare.Worker()( + * "api", + * { main: import.meta.filename }, + * Effect.gen(function* () { + * const search = yield* Cloudflare.AiSearchInstance.bind(Search); + * + * return { + * fetch: Effect.gen(function* () { + * const request = yield* HttpServerRequest; + * const query = new URL(request.url).searchParams.get("q") ?? ""; + * const answer = yield* search.chatCompletions({ + * messages: [{ role: "user", content: query }], + * }); + * return yield* HttpServerResponse.json(answer); + * }), + * }; + * }).pipe(Effect.provide(Cloudflare.AiSearchInstanceBindingLive)), + * ) {} + * ``` + * + * @section Binding to an Async Worker + * For a vanilla `async fetch` Worker, pass the instance under `Worker.env`. + * The engine attaches the same `ai_search` binding and `InferEnv` types + * `env.SEARCH` as the runtime `AiSearchInstance` handle. + * @example Async Worker via `env` + * ```typescript + * export const Api = Cloudflare.Worker("api", { + * main: "./worker.ts", + * env: { SEARCH: search }, + * }); + * export type ApiEnv = Cloudflare.InferEnv; + * + * // worker.ts + * export default { + * async fetch(request: Request, env: ApiEnv): Promise { + * const query = new URL(request.url).searchParams.get("q") ?? ""; + * return Response.json( + * await env.SEARCH.chatCompletions({ + * messages: [{ role: "user", content: query }], + * }), + * ); + * }, + * }; + * ``` + * * @see https://developers.cloudflare.com/ai-search/ */ export const AiSearchInstance = Resource( AiSearchInstanceTypeId, -); +)({ + bind: AiSearchInstanceBinding.bind, +}); /** * Returns true if the given value is an AiSearchInstance resource. @@ -354,17 +569,35 @@ export const isAiSearchInstance = (value: unknown): value is AiSearchInstance => export const AiSearchInstanceProvider = () => Provider.succeed(AiSearchInstance, { - stables: ["id", "accountId", "type", "embeddingModel", "createdAt"], + stables: [ + "instanceId", + "accountId", + "namespace", + "type", + "embeddingModel", + "createdAt", + ], diff: Effect.fn(function* ({ id, olds, news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; if (!isResolved(news)) return undefined; if ((output?.accountId ?? accountId) !== accountId) { return { action: "replace" } as const; } + // The namespace is immutable on the Cloudflare side (it is the API + // path) — moving an instance between namespaces is a replacement. The + // new instance lives at a different path, so create-before-delete is + // always safe here regardless of whether the id is pinned. + const newNamespace = resolveNamespace(news.namespace); + const oldNamespace = resolveNamespace( + output?.namespace ?? olds.namespace, + ); + if (newNamespace !== oldNamespace) { + return { action: "replace" } as const; + } // The instance id is its identity — renaming is a replacement. const newId = yield* createInstanceId(id, news.instanceId); const oldId = - output?.id ?? (yield* createInstanceId(id, olds.instanceId)); + output?.instanceId ?? (yield* createInstanceId(id, olds.instanceId)); if (newId !== oldId) { return { action: "replace" } as const; } @@ -400,53 +633,62 @@ export const AiSearchInstanceProvider = () => read: Effect.fn(function* ({ id, output, olds }) { const { accountId } = yield* yield* CloudflareEnvironment; const acct = output?.accountId ?? accountId; + const namespace = resolveNamespace(output?.namespace ?? olds?.namespace); // The id is deterministic (explicit prop or generated from the // logical id + instance id), so a cold read (lost state) resolves // the same identifier as the original create did. const instanceId = - output?.id ?? (yield* createInstanceId(id, olds?.instanceId)); - const observed = yield* getInstance(acct, instanceId); - return observed ? toAttributes(observed, acct) : undefined; + output?.instanceId ?? (yield* createInstanceId(id, olds?.instanceId)); + const observed = yield* getInstance(acct, namespace, instanceId); + return observed ? toAttributes(observed, acct, namespace) : undefined; }), list: Effect.fn(function* () { const { accountId } = yield* yield* CloudflareEnvironment; - // Enumerate every instance id in the account, paginating - // exhaustively (the list payload omits some fields `read` - // returns). - const ids = yield* aisearch.listInstances.pages({ accountId }).pipe( - Stream.runCollect, - Effect.map((chunk) => - Array.from(chunk).flatMap((page) => - (page.result ?? []).map((instance) => instance.id), + // Instances are namespace-scoped, so enumerate every namespace + // (always including the account-provided `default`) and fan out a + // paginated instance list per namespace. + const namespaces = yield* aisearch.listNamespaces + .pages({ accountId }) + .pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.result ?? []).map((ns) => ns.name), + ), ), - ), - ); - // Hydrate each into the exact `read` Attributes shape; an item - // that vanished between list and read (typed NotFound) is - // dropped. + ); + const allNamespaces = Array.from(new Set(["default", ...namespaces])); const rows = yield* Effect.forEach( - ids, - (instanceId) => - getInstance(accountId, instanceId).pipe( - Effect.map((observed) => - observed ? toAttributes(observed, accountId) : undefined, + allNamespaces, + (namespace) => + aisearch.listNamespaceInstances + .pages({ accountId, name: namespace }) + .pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.result ?? []).map((instance) => + toAttributes(instance, accountId, namespace), + ), + ), + ), + // A namespace deleted between list and fan-out is simply empty. + Effect.catchTag("NamespaceNotFound", () => Effect.succeed([])), ), - ), - { concurrency: 10 }, - ); - return rows.filter( - (row): row is AiSearchInstanceAttributes => row !== undefined, + { concurrency: 5 }, ); + return rows.flat(); }), reconcile: Effect.fn(function* ({ id, news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; const acct = output?.accountId ?? accountId; + const namespace = resolveNamespace(news.namespace); const instanceId = - output?.id ?? (yield* createInstanceId(id, news.instanceId)); + output?.instanceId ?? (yield* createInstanceId(id, news.instanceId)); - // Observe — `output.id` is a cache, not a guarantee: a NotFound + // Observe — `output.instanceId` is a cache, not a guarantee: a NotFound // falls through to "missing" and we recreate. - let observed = yield* getInstance(acct, instanceId); + let observed = yield* getInstance(acct, namespace, instanceId); if (!observed) { // Ensure — greenfield (or out-of-band delete): create with the @@ -454,8 +696,9 @@ export const AiSearchInstanceProvider = () => // the instance already exists (a race), fall through to the // sync path against the observed instance. const ensured = yield* aisearch - .createInstance({ + .createNamespaceInstance({ accountId: acct, + name: namespace, id: instanceId, type: news.type ?? "r2", ...toMutableBody(news), @@ -468,16 +711,40 @@ export const AiSearchInstanceProvider = () => })), Effect.catchTag("InstanceAlreadyExists", (originalError) => Effect.gen(function* () { - const existing = yield* getInstance(acct, instanceId); + const existing = yield* getInstance( + acct, + namespace, + instanceId, + ); if (!existing) return yield* Effect.fail(originalError); return { created: false as const, instance: existing }; }), ), ); if (ensured.created) { - // Indexing starts asynchronously; we deliberately do NOT wait - // for the first sync to finish. - return toAttributes(ensured.instance, acct); + // Optionally kick off the initial index instead of waiting for + // the first scheduled sync. Best-effort and NOT awaited for + // completion — the deploy must not block on indexing (the + // scheduled sync is the backstop if this transiently fails). + if ((news.indexOnCreate ?? false) && news.source !== undefined) { + yield* aisearch + .createNamespaceInstanceJob({ + accountId: acct, + name: namespace, + id: instanceId, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + "AI Search initial index job failed to start; the scheduled sync will index instead.", + error, + ), + ), + ); + } + // Indexing itself starts asynchronously; we deliberately do NOT + // wait for the first sync to finish. + return toAttributes(ensured.instance, acct, namespace); } observed = ensured.instance; } @@ -490,31 +757,47 @@ export const AiSearchInstanceProvider = () => const observedRecord = observed as unknown as Record; const dirty = Object.entries(desired).some( ([key, value]) => + // Cloudflare's read projection always returns `tokenId: null` + // (the association is write-only), so it can never be diffed + // against desired — excluding it avoids perpetual false drift. + // It still rides along in the create body and in any update PUT + // triggered by other fields. + key !== "tokenId" && value !== undefined && !deepEqual(normalize(observedRecord[key]), normalize(value), { stripNullish: true, }), ); if (!dirty) { - return toAttributes(observed, acct); + return toAttributes(observed, acct, namespace); } const updated = yield* aisearch - .updateInstance({ + .updateNamespaceInstance({ accountId: acct, + name: namespace, id: instanceId, ...preserveObserved(observed), ...defined(desired), }) .pipe(retryTokenPropagation); - return toAttributes(updated, acct); + return toAttributes(updated, acct, namespace); }), delete: Effect.fn(function* ({ output }) { - // The managed Vectorize index is torn down asynchronously; a - // missing instance (already deleted) is success. + // The managed Vectorize index is torn down asynchronously; a missing + // instance or namespace (already deleted) is success. yield* aisearch - .deleteInstance({ accountId: output.accountId, id: output.id }) - .pipe(Effect.catchTag("NotFound", () => Effect.void)); + .deleteNamespaceInstance({ + accountId: output.accountId, + name: output.namespace, + id: output.instanceId, + }) + .pipe( + Effect.catchTag( + ["AiSearchInstanceNotFound", "NamespaceNotFound"], + () => Effect.void, + ), + ); }), }); @@ -533,21 +816,38 @@ const retryTokenPropagation = ( effect.pipe( Effect.retry({ while: (e) => e._tag === "InvalidTokenCredentials", - schedule: Schedule.exponential("1 second"), - times: 6, + // A full-body update PUT re-sends `source`, which makes Cloudflare + // re-validate the (write-only, auto-provisioned) R2 service token — + // opening a fresh propagation window each time. Gentle exponential + // growth keeps the per-attempt delay bounded (~17s max) while giving + // the token longer overall to settle (~70s across 10 attempts). + schedule: Schedule.exponential("1 second", 1.5), + times: 10, }), ); -type ObservedInstance = aisearch.ReadInstanceResponse; +type ObservedInstance = aisearch.ReadNamespaceInstanceResponse; + +/** + * Resolve the namespace name an instance lives in, defaulting to the + * account-provided `default` namespace. + */ +const resolveNamespace = (namespace: string | undefined): string => + namespace ?? "default"; /** - * Read an instance by id, mapping "gone" (`NotFound`, Cloudflare error - * code 7002) to `undefined`. + * Read an instance by id within its namespace, mapping "gone" to + * `undefined` — either the instance is missing (`AiSearchInstanceNotFound`, + * code 7002) or its whole namespace is (`NamespaceNotFound`, code 7063). */ -const getInstance = (accountId: string, id: string) => +const getInstance = (accountId: string, namespace: string, id: string) => aisearch - .readInstance({ accountId, id }) - .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + .readNamespaceInstance({ accountId, name: namespace, id }) + .pipe( + Effect.catchTag(["AiSearchInstanceNotFound", "NamespaceNotFound"], () => + Effect.succeed(undefined), + ), + ); const createInstanceId = (id: string, instanceId: string | undefined) => Effect.gen(function* () { @@ -635,15 +935,35 @@ const defined = >(value: T): Partial => Object.entries(value).filter(([, v]) => v !== undefined), ) as Partial; +/** + * The instance fields `read` projects, common to the create / read / + * update / list-item response shapes (all optional but `id`). Decoupling + * from the exact distilled response unions lets the namespace-scoped list + * items — which carry a reduced field set — flow through unchanged. + */ +type InstanceLike = { + id: string; + type?: string | null; + source?: string | null; + tokenId?: string | null; + aiGatewayId?: string | null; + embeddingModel?: string | null; + aiSearchModel?: string | null; + status?: string | null; + paused?: boolean | null; + publicEndpointId?: string | null; + createdAt?: string | null; + modifiedAt?: string | null; +}; + const toAttributes = ( - instance: - | aisearch.ReadInstanceResponse - | aisearch.CreateInstanceResponse - | aisearch.UpdateInstanceResponse, + instance: InstanceLike, accountId: string, + namespace: string, ): AiSearchInstanceAttributes => ({ - id: instance.id, + instanceId: instance.id, accountId, + namespace, // Distilled widens generated string enums to open unions. type: (normalize(instance.type) ?? "r2") as AiSearchInstanceSourceType, source: normalize(instance.source), diff --git a/packages/alchemy/src/Cloudflare/AiSearch/Namespace.ts b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchNamespace.ts similarity index 67% rename from packages/alchemy/src/Cloudflare/AiSearch/Namespace.ts rename to packages/alchemy/src/Cloudflare/AiSearch/AiSearchNamespace.ts index 2f06ed5bf..2b0f134f8 100644 --- a/packages/alchemy/src/Cloudflare/AiSearch/Namespace.ts +++ b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchNamespace.ts @@ -9,6 +9,7 @@ import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Providers } from "../Providers.ts"; +import { AiSearchNamespaceBinding } from "./AiSearchBinding.ts"; const AiSearchNamespaceTypeId = "Cloudflare.AiSearch.Namespace" as const; type AiSearchNamespaceTypeId = typeof AiSearchNamespaceTypeId; @@ -67,6 +68,13 @@ export type AiSearchNamespace = Resource< * changing it triggers a replacement; only the `description` is mutable * in place. * + * The account-provided `default` namespace is reserved: it always exists + * and Cloudflare disallows modifying or deleting it. Alchemy adopts it so + * it can be referenced and bound, but never updates or tears it down. + * + * @resource + * @product AI Search + * @category AI * @section Creating a Namespace * @example Generated name * ```typescript @@ -83,6 +91,7 @@ export type AiSearchNamespace = Resource< * * @section Updating a Namespace * @example Change the description in place + * Only the `description` is mutable; changing `name` replaces the namespace. * ```typescript * const ns = yield* Cloudflare.AiSearchNamespace("docs", { * name: "docs-search", @@ -90,11 +99,89 @@ export type AiSearchNamespace = Resource< * }); * ``` * + * @section Grouping pipelines + * Group {@link AiSearch} pipelines under the namespace by passing the + * namespace resource itself to each pipeline's `namespace` prop. The engine + * orders each pipeline after the namespace on deploy and tears them down + * before it on destroy. + * @example Two pipelines in one namespace + * ```typescript + * const ns = yield* Cloudflare.AiSearchNamespace("docs", {}); + * const guides = yield* Cloudflare.AiSearch("guides", { + * source: guidesBucket, + * namespace: ns, + * }); + * const api = yield* Cloudflare.AiSearch("api", { + * source: apiBucket, + * namespace: ns, + * }); + * ``` + * + * @section Binding to an Effect Worker + * Bind the namespace with `Cloudflare.AiSearchNamespace.bind(namespace)`, + * which attaches the `ai_search_namespace` binding and returns a client + * whose `.get(name)` selects an instance within the namespace at runtime. + * Provide {@link AiSearchNamespaceBindingLive} in the Worker's runtime + * layer. + * @example Select an instance per request + * ```typescript + * import * as Cloudflare from "alchemy/Cloudflare"; + * import * as Effect from "effect/Effect"; + * import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; + * import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + * + * export default class Api extends Cloudflare.Worker()( + * "api", + * { main: import.meta.filename }, + * Effect.gen(function* () { + * const ns = yield* Cloudflare.AiSearchNamespace.bind(Docs); + * + * return { + * fetch: Effect.gen(function* () { + * const url = new URL((yield* HttpServerRequest).url); + * const instance = url.searchParams.get("instance") ?? "guides"; + * const query = url.searchParams.get("q") ?? ""; + * const answer = yield* ns.get(instance).chatCompletions({ + * messages: [{ role: "user", content: query }], + * }); + * return yield* HttpServerResponse.json(answer); + * }), + * }; + * }).pipe(Effect.provide(Cloudflare.AiSearchNamespaceBindingLive)), + * ) {} + * ``` + * + * @section Binding to an Async Worker + * For a vanilla `async fetch` Worker, pass the namespace under `Worker.env`. + * `InferEnv` types `env.SEARCH` as the runtime `AiSearchNamespace` handle. + * @example Async Worker via `env` + * ```typescript + * export const Api = Cloudflare.Worker("api", { + * main: "./worker.ts", + * env: { SEARCH: namespace }, + * }); + * export type ApiEnv = Cloudflare.InferEnv; + * + * // worker.ts + * export default { + * async fetch(request: Request, env: ApiEnv): Promise { + * const query = new URL(request.url).searchParams.get("q") ?? ""; + * return Response.json( + * await env.SEARCH.get("guides").chatCompletions({ + * messages: [{ role: "user", content: query }], + * }), + * ); + * }, + * }; + * ``` + * * @see https://developers.cloudflare.com/ai-search/ */ export const AiSearchNamespace = Resource( AiSearchNamespaceTypeId, -); +)({ + bind: AiSearchNamespaceBinding.bind, +}); /** * Returns true if the given value is an AiSearchNamespace resource. @@ -115,10 +202,11 @@ export const AiSearchNamespaceProvider = () => return { action: "replace" } as const; } // The name is the namespace's identity (it is the API path - // parameter) — renaming is a replacement. - const newName = yield* createNamespaceName(id, news.name); + // parameter) — renaming is a replacement. Props are all optional, so a + // no-props resource resolves `news`/`olds` to `undefined` at runtime. + const newName = yield* createNamespaceName(id, news?.name); const oldName = - output?.name ?? (yield* createNamespaceName(id, olds.name)); + output?.name ?? (yield* createNamespaceName(id, olds?.name)); if (newName !== oldName) { // A user-pinned name collides with the still-existing old // namespace only when both resolve to the same string — they @@ -140,7 +228,10 @@ export const AiSearchNamespaceProvider = () => reconcile: Effect.fn(function* ({ id, news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; const acct = output?.accountId ?? accountId; - const name = output?.name ?? (yield* createNamespaceName(id, news.name)); + // Props are all optional, so a no-props resource resolves `news` to + // `undefined` at runtime — fall back to the empty desired shape. + const props = news ?? {}; + const name = output?.name ?? (yield* createNamespaceName(id, props.name)); // Observe — `output.name` is a cache, not a guarantee: a missing // namespace falls through to create. @@ -155,7 +246,7 @@ export const AiSearchNamespaceProvider = () => .createNamespace({ accountId: acct, name, - description: news.description, + description: props.description, }) .pipe( Effect.map((created) => ({ created: true as const, ns: created })), @@ -173,10 +264,18 @@ export const AiSearchNamespaceProvider = () => observed = ensured.ns; } + // The account-provided `default` namespace is reserved: it always + // exists and its metadata cannot be modified + // (`cannot_modify_default_namespace`). Adopt it as-is so it can be + // referenced/bound, but never attempt to mutate it. + if (name === "default") { + return toAttributes(observed, acct); + } + // Sync — the description is the only mutable aspect. Diff observed // cloud state against desired; skip the PUT entirely on a no-op. const observedDescription = normalize(observed.description); - if (observedDescription === news.description) { + if (observedDescription === props.description) { return toAttributes(observed, acct); } const updated = yield* aisearch @@ -184,7 +283,7 @@ export const AiSearchNamespaceProvider = () => accountId: acct, name, // `null` clears a previously-set description. - description: news.description ?? null, + description: props.description ?? null, }) .pipe( // The observe read can be eventually consistent: a namespace @@ -195,13 +294,17 @@ export const AiSearchNamespaceProvider = () => aisearch.createNamespace({ accountId: acct, name, - description: news.description, + description: props.description, }), ), ); return toAttributes(updated, acct); }), delete: Effect.fn(function* ({ output }) { + // The account-provided `default` namespace cannot be deleted + // (`cannot_modify_default_namespace`); binding/adopting it must never + // tear it down, so its delete is a no-op. + if (output.name === "default") return; // A missing namespace (already deleted) is success. Instances inside // the namespace must be deleted first — the engine orders that via // the `namespace` reference on dependent resources. diff --git a/packages/alchemy/src/Cloudflare/AiSearch/Token.ts b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchToken.ts similarity index 96% rename from packages/alchemy/src/Cloudflare/AiSearch/Token.ts rename to packages/alchemy/src/Cloudflare/AiSearch/AiSearchToken.ts index 1c489d3e5..3f1ac9e79 100644 --- a/packages/alchemy/src/Cloudflare/AiSearch/Token.ts +++ b/packages/alchemy/src/Cloudflare/AiSearch/AiSearchToken.ts @@ -107,7 +107,9 @@ export type AiSearchToken = Resource< * `Cloudflare.AccountApiToken` to mint the underlying API token in the * same stack, then reference the service token's `id` from an AI Search * instance's `tokenId` prop. - * + * @resource + * @product AI Search + * @category AI * @section Creating a Token * @example Minting the underlying API token in the same stack * ```typescript @@ -257,10 +259,15 @@ export const AiSearchTokenProvider = () => .pipe( Effect.retry({ while: (e) => e._tag === "TokenInUseByInstances", + // The referencing instance tears down its managed Vectorize + // index asynchronously and can hold the token well past a + // minute. Cap the per-attempt delay at 5s and give it a + // generous overall budget (~2min) so the token delete waits + // out the instance instead of failing the teardown. schedule: Schedule.exponential("1 second").pipe( Schedule.either(Schedule.spaced("5 seconds")), ), - times: 10, + times: 24, }), Effect.catchTag("TokenNotFound", () => Effect.void), ); diff --git a/packages/alchemy/src/Cloudflare/AiSearch/index.ts b/packages/alchemy/src/Cloudflare/AiSearch/index.ts index 506df6890..202bf8802 100644 --- a/packages/alchemy/src/Cloudflare/AiSearch/index.ts +++ b/packages/alchemy/src/Cloudflare/AiSearch/index.ts @@ -1,3 +1,5 @@ -export * from "./Instance.ts"; -export * from "./Namespace.ts"; -export * from "./Token.ts"; +export * from "./AiSearch.ts"; +export * from "./AiSearchBinding.ts"; +export * from "./AiSearchInstance.ts"; +export * from "./AiSearchNamespace.ts"; +export * from "./AiSearchToken.ts"; diff --git a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts index 1e876954e..f30063348 100644 --- a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts +++ b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts @@ -37,9 +37,9 @@ export type GetBindingType = : T extends Cloudflare.AiGateway ? Ai : T extends Cloudflare.AiSearchInstance - ? AutoRAG + ? AiSearchInstance : T extends Cloudflare.AiSearchNamespace - ? { get(instanceName: string): AutoRAG } + ? AiSearchNamespace : T extends Cloudflare.SendEmail ? SendEmail : T extends Cloudflare.AnalyticsEngineDataset diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index 425b11ab0..2eb2f2090 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -238,12 +238,14 @@ export const LocalWorkerProvider = () => props: WorkerPropsWithDev; bindings: ResourceBinding[]; }) { + const { accountId } = yield* yield* CloudflareEnvironment; const name = yield* createWorkerName(id, props.name); const compatibility = getCompatibility(props); const workerBindings: BindingHook[] = [ Text.local("ALCHEMY_PHASE", "runtime"), Text.local("ALCHEMY_STACK_NAME", stack.name), Text.local("ALCHEMY_STAGE", stack.stage), + Text.local("ALCHEMY_CLOUDFLARE_ACCOUNT_ID", accountId), ...Object.entries(props.env ?? {}).map(([key, value]) => { const unredacted = Redacted.isRedacted(value) ? Redacted.value(value) diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 44a146a68..42899a598 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -141,7 +141,8 @@ export type WorkerServices = | Worker | Request | WorkerExecutionContext - | WorkerEnvironment; + | WorkerEnvironment + | CloudflareEnvironment; export type WorkerShape = Main; @@ -1478,6 +1479,11 @@ export const LiveWorkerProvider = () => name: "ALCHEMY_STAGE", text: stack.stage, }, + { + type: "plain_text", + name: "ALCHEMY_CLOUDFLARE_ACCOUNT_ID", + text: accountId, + }, ); // Add environment variables as metadata bindings if (news.env) { diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index 8bfad22e3..91e808e29 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -6,8 +6,8 @@ import * as Output from "../../Output.ts"; import type { ResourceBinding } from "../../Resource.ts"; import { isYieldableEffectLike } from "../../Util/effect.ts"; import { isAiGateway } from "../AiGateway/AiGateway.ts"; -import { isAiSearchInstance } from "../AiSearch/Instance.ts"; -import { isAiSearchNamespace } from "../AiSearch/Namespace.ts"; +import { isAiSearchInstance } from "../AiSearch/AiSearchInstance.ts"; +import { isAiSearchNamespace } from "../AiSearch/AiSearchNamespace.ts"; import { isAnalyticsEngineDataset } from "../AnalyticsEngine/AnalyticsEngineDataset.ts"; import { isArtifacts } from "../Artifacts/Artifacts.ts"; import { isBrowser } from "../Browser/Browser.ts"; @@ -196,7 +196,7 @@ const toBinding = ( return { type: "ai_search", name: bindingName, - instanceName: binding.id, + instanceName: binding.instanceId, namespace: binding.namespace, }; } else if (isAiSearchNamespace(binding)) { diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts index 32b47c019..3b0a11fcd 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts @@ -7,8 +7,8 @@ import * as Binding from "../../Binding.ts"; import type { Rpc } from "../../Rpc.ts"; import { isYieldableEffectLike } from "../../Util/effect.ts"; import type { AiGateway } from "../AiGateway/AiGateway.ts"; -import type { AiSearchInstance } from "../AiSearch/Instance.ts"; -import type { AiSearchNamespace } from "../AiSearch/Namespace.ts"; +import type { AiSearchInstance } from "../AiSearch/AiSearchInstance.ts"; +import type { AiSearchNamespace } from "../AiSearch/AiSearchNamespace.ts"; import { AnalyticsEngineDataset } from "../AnalyticsEngine/AnalyticsEngineDataset.ts"; import { Artifacts } from "../Artifacts/Artifacts.ts"; import { Browser } from "../Browser/Browser.ts"; @@ -26,8 +26,8 @@ import type { VectorizeIndex } from "../Vectorize/VectorizeIndex.ts"; import type { Assets } from "./Assets.ts"; import type { DurableObjectNamespaceLike } from "./DurableObjectNamespace.ts"; import type { DynamicWorkerLoader } from "./DynamicWorkerLoader.ts"; -import type { VersionMetadata } from "./VersionMetadata.ts"; import { makeRpcStub } from "./Rpc.ts"; +import type { VersionMetadata } from "./VersionMetadata.ts"; import { isWorker, Worker, WorkerEnvironment } from "./Worker.ts"; export type WorkerBinding = Exclude< diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBridge.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBridge.ts index 3390de6b1..bede8b6c3 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBridge.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBridge.ts @@ -17,6 +17,7 @@ import { ExecutionContext } from "../../ExecutionContext.ts"; import { makeEntrypointLayer } from "../../Runtime.ts"; import { Self } from "../../Self.ts"; import { Stack } from "../../Stack.ts"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import cloudflare_workers from "./cloudflare_workers.ts"; import { isScopeEjected } from "./HttpServer.ts"; import { @@ -241,6 +242,16 @@ export const getWorkerExport = ({ ), ), Layer.provideMerge(Layer.succeed(WorkerEnvironment, env)), + Layer.provideMerge( + Layer.succeed( + CloudflareEnvironment, + // TODO(sam): fix this with maybe a CloudflareAccountId Effect service + // @ts-expect-error - this is hacky, but we only need and have this property + Effect.succeed({ + account: (env as any).ALCHEMY_CLOUDFLARE_ACCOUNT_ID, + }), + ), + ), Layer.provideMerge( Layer.succeed( MinimumLogLevel, diff --git a/packages/alchemy/test/Cloudflare/AiSearch/AiSearch.test.ts b/packages/alchemy/test/Cloudflare/AiSearch/AiSearch.test.ts new file mode 100644 index 000000000..16fe70318 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AiSearch/AiSearch.test.ts @@ -0,0 +1,152 @@ +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Test from "@/Test/Vitest"; +import * as aisearch from "@distilled.cloud/cloudflare/aisearch"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import AiSearchCrawlTargetWorker from "./fixtures/crawl-target-worker.ts"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Type-level coverage: the `AiSearch` construct result *is* an +// `AiSearchInstance`, so it can be passed anywhere one is expected. These +// assertions fail to compile the moment the construct stops being +// substitutable for an instance. The two binding styles are also exercised at +// runtime by the `bindings-stack` fixtures (Effect Worker `bind(...)` + +// async Worker `env`). +declare const _search: Cloudflare.AiSearch; +// 1. Assignable to an `AiSearchInstance` (and thus to `AiSearchInstance.bind`, +// which is how an Effect Worker attaches the `ai_search` binding). These +// assertions live inside a never-invoked closure so the type checks compile +// without executing the `declare`d binding (which has no runtime value). +void (() => { + const _asInstance: Cloudflare.AiSearchInstance = _search; + void _asInstance; + void Cloudflare.AiSearchInstance.bind(_search); +}); +// 2. As a Worker `env` binding, `InferEnv` resolves it to the same runtime +// handle (`AiSearchInstance`) it would for a plain `AiSearchInstance`. +type _EnvSearch = Cloudflare.InferEnv<{ + SEARCH: Cloudflare.AiSearch; +}>["SEARCH"]; +type _EnvInstance = Cloudflare.InferEnv<{ + SEARCH: Cloudflare.AiSearchInstance; +}>["SEARCH"]; +const _envSame: _EnvSearch extends _EnvInstance ? true : never = true; +void _envSame; + +const getInstance = (accountId: string, id: string, namespace = "default") => + aisearch.readNamespaceInstance({ accountId, name: namespace, id }).pipe( + Effect.retry({ + while: (e) => e._tag === "Forbidden", + schedule: Schedule.exponential("500 millis"), + times: 8, + }), + ); + +const expectGone = (accountId: string, id: string, namespace = "default") => + getInstance(accountId, id, namespace).pipe( + Effect.flatMap(() => Effect.fail({ _tag: "InstanceNotDeleted" } as const)), + Effect.catchTag( + ["AiSearchInstanceNotFound", "NamespaceNotFound"], + () => Effect.void, + ), + Effect.retry({ + while: (e) => e._tag === "InstanceNotDeleted", + schedule: Schedule.exponential("500 millis").pipe( + Schedule.both(Schedule.recurs(10)), + ), + }), + ); + +// The `AiSearch` construct composes an R2 bucket, a managed service token +// (AccountApiToken + AiSearchToken children), and the instance — a single +// `yield*` wires the whole pipeline together. +const program = () => + Effect.gen(function* () { + const bucket = yield* Cloudflare.R2Bucket("AiSearchSource", {}); + const search = yield* Cloudflare.AiSearch("Search", { + source: bucket, + }); + return { bucket, search, serviceToken: search.serviceToken }; + }); + +test.provider( + "construct auto-creates a managed token and wires it into the instance", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const deployed = yield* stack.deploy(program()); + + const { search, serviceToken } = deployed; + expect(search.instanceId).toBeTruthy(); + // The managed service token was minted as a child and wired in. + expect(serviceToken).toBeDefined(); + expect(search.tokenId).toEqual(serviceToken!.id); + + // Cloudflare's read projection hides the token association + // (`tokenId` comes back `null`), so verify the instance exists rather + // than re-asserting the token id off the read path. + const live = yield* getInstance(accountId, search.instanceId); + expect(live.id).toEqual(search.instanceId); + + yield* stack.destroy(); + + yield* expectGone(accountId, search.instanceId); + }).pipe(logLevel), + { timeout: 300_000 }, +); + +// A web-crawler source crawls a seed URL and needs no service token, so the +// construct must NOT mint an AccountApiToken / AiSearchToken — `token` comes +// back undefined. Cloudflare only crawls a domain the account owns, so the +// crawl is seeded at a Worker we deploy (its `workers.dev` URL is owned by the +// account); `parseType: "crawl"` walks pages instead of requiring a sitemap. +const crawlerProgram = () => + Effect.gen(function* () { + const target = yield* AiSearchCrawlTargetWorker; + // Exercise the flattened source groups end-to-end: `parse` (parseType + + // parse options) and `crawl` (link-discovery options) must translate into + // the distilled `sourceParams.webCrawler.{parseType,parseOptions,crawlOptions}`. + const search = yield* Cloudflare.AiSearch("Search", { + source: target.url.as(), + parse: { type: "crawl", useBrowserRendering: false }, + crawl: { depth: 2, includeSubdomains: false }, + }); + return { target, search, serviceToken: search.serviceToken }; + }); + +test.provider( + "web-crawler source skips token minting", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const deployed = yield* stack.deploy(crawlerProgram()); + + const { search, serviceToken } = deployed; + // No service token is minted for a web crawler. + expect(serviceToken).toBeUndefined(); + expect(search.type).toEqual("web-crawler"); + + const live = yield* getInstance(accountId, search.instanceId); + expect(live.type).toEqual("web-crawler"); + + yield* stack.destroy(); + + yield* expectGone(accountId, search.instanceId); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/AiSearch/Bindings.test.ts b/packages/alchemy/test/Cloudflare/AiSearch/Bindings.test.ts new file mode 100644 index 000000000..435de5327 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AiSearch/Bindings.test.ts @@ -0,0 +1,122 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import Stack from "./fixtures/bindings-stack.ts"; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Cloudflare.providers(), +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const stack = beforeAll(deploy(Stack)); +afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); + +// Deploying the Worker succeeding at all proves Cloudflare accepted both the +// `ai_search` and `ai_search_namespace` bindings. The `/bindings` route then +// confirms they are injected and shaped correctly at runtime. +test( + "worker deploys with ai_search + ai_search_namespace bindings injected", + Effect.gen(function* () { + const { url } = yield* stack; + expect(url).toBeTypeOf("string"); + + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(`${url}/bindings`).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new Error(`Worker not ready: ${res.status}`)), + ), + // Cap the backoff at 3s so a fresh worker that takes a while to start + // serving 200s keeps getting polled steadily, rather than the + // unbounded exponential delay overshooting the test timeout. + Effect.retry({ + schedule: Schedule.exponential("500 millis").pipe( + Schedule.either(Schedule.spaced("3 seconds")), + ), + times: 40, + }), + ); + + const body = (yield* res.json) as { + search: string; + searchChatCompletions: string; + searchSearch: string; + ns: string; + nsGet: string; + }; + + // Single-instance `ai_search` binding resolves to an `AiSearchInstance` + // object exposing `search()` / `chatCompletions()`. + expect(body.search).toBe("object"); + expect(body.searchChatCompletions).toBe("function"); + expect(body.searchSearch).toBe("function"); + // `ai_search_namespace` binding resolves to an `AiSearchNamespace` with + // `.get()`. + expect(body.ns).toBe("object"); + expect(body.nsGet).toBe("function"); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +// The Effect worker attaches the same two binding flavors via +// `AiSearchInstanceBinding.bind(...)` / `AiSearchNamespaceBinding.bind(...)` +// and reads them through the Effect-native client. Resolving each client's +// `raw` runtime handle proves the Effect-first path wires through to the +// live runtime bindings. +test( + "effect worker resolves ai_search + ai_search_namespace via Effect clients", + Effect.gen(function* () { + const { effectUrl } = yield* stack; + expect(effectUrl).toBeTypeOf("string"); + + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(`${effectUrl}/bindings`).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new Error(`Worker not ready: ${res.status}`)), + ), + // Cap the backoff at 3s so a fresh worker that takes a while to start + // serving 200s keeps getting polled steadily, rather than the + // unbounded exponential delay overshooting the test timeout. + Effect.retry({ + schedule: Schedule.exponential("500 millis").pipe( + Schedule.either(Schedule.spaced("3 seconds")), + ), + times: 40, + }), + ); + + const body = (yield* res.json) as { + mode: string; + searchRaw: string; + searchChatCompletions: string; + searchSearch: string; + nsRaw: string; + nsChatCompletions: string; + }; + + expect(body.mode).toBe("effect"); + // `AiSearchInstanceBinding.bind(...).raw` resolves to the runtime + // `AiSearchInstance` exposing `search()` / `chatCompletions()`. + expect(body.searchRaw).toBe("object"); + expect(body.searchChatCompletions).toBe("function"); + expect(body.searchSearch).toBe("function"); + // `AiSearchNamespaceBinding.bind(...).raw` resolves to the runtime + // `AiSearchNamespace`; `.get(name)` scopes to an instance exposing + // `chatCompletions()`. The namespace handle may be a callable runtime + // proxy (`typeof` `"function"`) or an object. + expect(["object", "function"]).toContain(body.nsRaw); + expect(body.nsChatCompletions).toBe("function"); + }).pipe(logLevel), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/AiSearch/Instance.test.ts b/packages/alchemy/test/Cloudflare/AiSearch/Instance.test.ts index aa32f62bf..b85cb9092 100644 --- a/packages/alchemy/test/Cloudflare/AiSearch/Instance.test.ts +++ b/packages/alchemy/test/Cloudflare/AiSearch/Instance.test.ts @@ -7,6 +7,7 @@ import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { MinimumLogLevel } from "effect/References"; import * as Schedule from "effect/Schedule"; +import AiSearchCrawlTargetWorker from "./fixtures/crawl-target-worker.ts"; const { test } = Test.make({ providers: Cloudflare.providers() }); @@ -18,9 +19,10 @@ const logLevel = Effect.provideService( // The scoped API token the test harness mints propagates eventually- // consistently across Cloudflare's edge — ride out 403 blips // (`Forbidden`, declared in the distilled error union) on the test's -// own out-of-band verification calls. -const getInstance = (accountId: string, id: string) => - aisearch.readInstance({ accountId, id }).pipe( +// own out-of-band verification calls. Instances are namespace-scoped, so +// verify through the namespace-scoped read (defaulting to `default`). +const getInstance = (accountId: string, id: string, namespace = "default") => + aisearch.readNamespaceInstance({ accountId, name: namespace, id }).pipe( Effect.retry({ while: (e) => e._tag === "Forbidden", schedule: Schedule.exponential("500 millis"), @@ -28,12 +30,16 @@ const getInstance = (accountId: string, id: string) => }), ); -const expectGone = (accountId: string, id: string) => - getInstance(accountId, id).pipe( +const expectGone = (accountId: string, id: string, namespace = "default") => + getInstance(accountId, id, namespace).pipe( Effect.flatMap(() => Effect.fail({ _tag: "InstanceNotDeleted" } as const)), - // A missing instance surfaces as `NotFound` (Cloudflare error code - // 7002) — that's the success condition here. - Effect.catchTag("NotFound", () => Effect.void), + // A missing instance (`AiSearchInstanceNotFound`, code 7002) or a + // missing enclosing namespace (`NamespaceNotFound`, code 7063) is the + // success condition here. + Effect.catchTag( + ["AiSearchInstanceNotFound", "NamespaceNotFound"], + () => Effect.void, + ), Effect.retry({ while: (e) => e._tag === "InstanceNotDeleted", schedule: Schedule.exponential("500 millis").pipe( @@ -67,13 +73,13 @@ test.provider( // Create — engine-generated instance id, default settings. const initial = yield* stack.deploy(program()); - expect(initial.instance.id).toBeTruthy(); + expect(initial.instance.instanceId).toBeTruthy(); expect(initial.instance.accountId).toEqual(accountId); expect(initial.instance.type).toEqual("r2"); expect(initial.instance.source).toEqual(initial.bucket.bucketName); - const live = yield* getInstance(accountId, initial.instance.id); - expect(live.id).toEqual(initial.instance.id); + const live = yield* getInstance(accountId, initial.instance.instanceId); + expect(live.id).toEqual(initial.instance.instanceId); expect(live.source).toEqual(initial.bucket.bucketName); expect(live.type).toEqual("r2"); @@ -87,7 +93,7 @@ test.provider( }), ); - expect(updated.instance.id).toEqual(initial.instance.id); + expect(updated.instance.instanceId).toEqual(initial.instance.instanceId); expect(updated.instance.aiSearchModel).toEqual( "@cf/meta/llama-3.3-70b-instruct-fp8-fast", ); @@ -98,7 +104,7 @@ test.provider( // readback until the mutated props land before asserting, bounded. const liveUpdated = yield* getInstance( accountId, - updated.instance.id, + updated.instance.instanceId, ).pipe( Effect.flatMap((live) => live.aiSearchModel === "@cf/meta/llama-3.3-70b-instruct-fp8-fast" @@ -127,11 +133,11 @@ test.provider( chunkOverlap: 15, }), ); - expect(noop.instance.id).toEqual(initial.instance.id); + expect(noop.instance.instanceId).toEqual(initial.instance.instanceId); yield* stack.destroy(); - yield* expectGone(accountId, initial.instance.id); + yield* expectGone(accountId, initial.instance.instanceId); // Destroy again — delete must be idempotent (already gone). yield* stack.destroy(); @@ -158,20 +164,22 @@ test.provider( // The embedding model defines the vector space and is fixed at // creation — a new physical instance exists. - expect(replaced.instance.id).not.toEqual(initial.instance.id); + expect(replaced.instance.instanceId).not.toEqual( + initial.instance.instanceId, + ); expect(replaced.instance.embeddingModel).toEqual( "@cf/baai/bge-large-en-v1.5", ); - const live = yield* getInstance(accountId, replaced.instance.id); + const live = yield* getInstance(accountId, replaced.instance.instanceId); expect(live.embeddingModel).toEqual("@cf/baai/bge-large-en-v1.5"); // The old instance was deleted as part of the replacement. - yield* expectGone(accountId, initial.instance.id); + yield* expectGone(accountId, initial.instance.instanceId); yield* stack.destroy(); - yield* expectGone(accountId, replaced.instance.id); + yield* expectGone(accountId, replaced.instance.instanceId); }).pipe(logLevel), { timeout: 240_000 }, ); @@ -191,7 +199,11 @@ test.provider( // must observe the instance as missing and recreate it instead of // failing on a 404. yield* aisearch - .deleteInstance({ accountId, id: initial.instance.id }) + .deleteNamespaceInstance({ + accountId, + name: "default", + id: initial.instance.instanceId, + }) .pipe( Effect.retry({ while: (e) => e._tag === "Forbidden", @@ -199,25 +211,25 @@ test.provider( times: 8, }), ); - yield* expectGone(accountId, initial.instance.id); + yield* expectGone(accountId, initial.instance.instanceId); const healed = yield* stack.deploy(program({ maxNumResults: 10 })); - expect(healed.instance.id).toEqual(initial.instance.id); - const live = yield* getInstance(accountId, healed.instance.id); - expect(live.id).toEqual(initial.instance.id); + expect(healed.instance.instanceId).toEqual(initial.instance.instanceId); + const live = yield* getInstance(accountId, healed.instance.instanceId); + expect(live.id).toEqual(initial.instance.instanceId); yield* stack.destroy(); - yield* expectGone(accountId, healed.instance.id); + yield* expectGone(accountId, healed.instance.instanceId); }).pipe(logLevel), { timeout: 240_000 }, ); -// Canonical `list()` test (account collection): `list()` enumerates every -// AI Search instance in the account via `listInstances`, paginating -// exhaustively, and hydrates each into the `read` Attributes shape. Deploy -// an instance and assert its id appears in the result. +// Canonical `list()` test: instances are namespace-scoped, so `list()` +// enumerates every namespace (including the account-provided `default`) and +// fans out a paginated instance list per namespace, hydrating each into the +// `read` Attributes shape. Deploy an instance and assert its id appears. test.provider( "list enumerates the deployed instance", (stack) => @@ -231,11 +243,106 @@ test.provider( ); const all = yield* provider.list(); - expect(all.some((x) => x.id === deployed.instance.id)).toBe(true); + expect( + all.some((x) => x.instanceId === deployed.instance.instanceId), + ).toBe(true); + + yield* stack.destroy(); + + yield* expectGone( + deployed.instance.accountId, + deployed.instance.instanceId, + ); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +// A web-crawler source crawls a seed URL and needs no service token (unlike +// an R2 source). Cloudflare only crawls a domain the account owns, so the +// crawl is seeded at a Worker we deploy (its `workers.dev` URL is owned by the +// account); `parseType: "crawl"` walks pages instead of requiring a sitemap. +test.provider( + "creates a web-crawler instance (no service token)", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const target = yield* AiSearchCrawlTargetWorker; + const instance = yield* Cloudflare.AiSearchInstance("Search", { + type: "web-crawler", + source: target.url.as(), + sourceParams: { + webCrawler: { + parseType: "crawl", + }, + }, + }); + return { target, instance }; + }), + ); + + // Creating without any tokenId proves a web-crawler needs no token. + expect(initial.instance.type).toEqual("web-crawler"); + expect(initial.instance.source).toEqual(initial.target.url); + + const live = yield* getInstance(accountId, initial.instance.instanceId); + expect(live.type).toEqual("web-crawler"); yield* stack.destroy(); - yield* expectGone(deployed.instance.accountId, deployed.instance.id); + yield* expectGone(accountId, initial.instance.instanceId); + }).pipe(logLevel), + { timeout: 300_000 }, +); + +// A program that places the instance in a custom namespace. The instance's +// `namespace` references the namespace's `name` output, so the engine orders +// instance-after-namespace on deploy (and namespace-after-instance on +// destroy, so the namespace's instances are torn down before it). +const nsProgram = (props?: Partial) => + Effect.gen(function* () { + const namespace = yield* Cloudflare.AiSearchNamespace("AiSearchNs", {}); + const bucket = yield* Cloudflare.R2Bucket("AiSearchSource", {}); + const instance = yield* Cloudflare.AiSearchInstance("Search", { + source: bucket.bucketName, + namespace: namespace.name, + ...props, + }); + return { namespace, bucket, instance }; + }); + +test.provider( + "creates an instance in a custom namespace and moving namespaces replaces", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const initial = yield* stack.deploy(nsProgram()); + expect(initial.instance.namespace).toEqual(initial.namespace.name); + expect(initial.namespace.name).not.toEqual("default"); + + // The instance is readable in its namespace, but NOT in `default`. + const live = yield* getInstance( + accountId, + initial.instance.instanceId, + initial.namespace.name, + ); + expect(live.id).toEqual(initial.instance.instanceId); + yield* expectGone(accountId, initial.instance.instanceId, "default"); + + yield* stack.destroy(); + + yield* expectGone( + accountId, + initial.instance.instanceId, + initial.namespace.name, + ); }).pipe(logLevel), { timeout: 240_000 }, ); diff --git a/packages/alchemy/test/Cloudflare/AiSearch/Namespace.test.ts b/packages/alchemy/test/Cloudflare/AiSearch/Namespace.test.ts index 4c86dcda6..c28060ca3 100644 --- a/packages/alchemy/test/Cloudflare/AiSearch/Namespace.test.ts +++ b/packages/alchemy/test/Cloudflare/AiSearch/Namespace.test.ts @@ -63,9 +63,9 @@ const expectDescription = ( ) => getNamespace(accountId, name).pipe( Effect.repeat({ - schedule: Schedule.spaced("1 second"), + schedule: Schedule.spaced("2 seconds"), until: (ns) => (ns.description ?? undefined) === expected, - times: 15, + times: 30, }), Effect.map((ns) => expect(ns.description ?? undefined).toEqual(expected)), ); @@ -192,6 +192,31 @@ test.provider( { timeout: 240_000 }, ); +test.provider( + "adopts the reserved default namespace without deleting it on teardown", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + // The account-provided `default` namespace always exists; adopting it + // is bindable but must never be created or torn down. + const adopted = yield* stack.deploy(program({ name: "default" })); + expect(adopted.namespace.name).toEqual("default"); + + const live = yield* getNamespace(accountId, "default"); + expect(live.name).toEqual("default"); + + yield* stack.destroy(); + + // Destroy is a no-op for the default namespace — it must still exist. + const stillThere = yield* getNamespace(accountId, "default"); + expect(stillThere.name).toEqual("default"); + }).pipe(logLevel), + { timeout: 120_000 }, +); + test.provider( "recreates after out-of-band delete", (stack) => diff --git a/packages/alchemy/test/Cloudflare/AiSearch/Token.test.ts b/packages/alchemy/test/Cloudflare/AiSearch/Token.test.ts index 7b0b28096..bbcf3cda8 100644 --- a/packages/alchemy/test/Cloudflare/AiSearch/Token.test.ts +++ b/packages/alchemy/test/Cloudflare/AiSearch/Token.test.ts @@ -208,7 +208,7 @@ test.provider( expect(deployed.instance.tokenId).toEqual(deployed.token.id); const live = yield* aisearch - .readInstance({ accountId, id: deployed.instance.id }) + .readInstance({ accountId, id: deployed.instance.instanceId }) .pipe( Effect.retry({ while: (e) => e._tag === "Forbidden", diff --git a/packages/alchemy/test/Cloudflare/AiSearch/fixtures/bindings-stack.ts b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/bindings-stack.ts new file mode 100644 index 000000000..4ec48dc14 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/bindings-stack.ts @@ -0,0 +1,50 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Alchemy from "@/index"; +import * as Effect from "effect/Effect"; +import * as path from "pathe"; +import AiSearchEffectBindingsWorker from "./effect-bindings-worker.ts"; + +/** + * Deploys two Workers covering both AI Search binding flavors via both + * access styles: + * - The async worker takes the bindings through `env` (`SEARCH` is a + * single-instance `ai_search` binding; `NS` is an `ai_search_namespace` + * binding) and reads them as raw runtime objects. + * - The Effect worker attaches the same two binding flavors via + * `AiSearchInstanceBinding.bind(...)` / `AiSearchNamespaceBinding.bind(...)` + * and reads them through the Effect-native client. + * + * The single-instance binding is sourced from the `AiSearch` construct (not + * the low-level `AiSearchInstance`), proving the construct's result is usable + * anywhere an `AiSearchInstance` is — here, passed straight to a Worker's + * `env`. The construct's `source` references the bucket and the worker's + * `env` references the search + namespace, so the engine orders the deploy + * bucket → instance/namespace → worker. + */ +export default Alchemy.Stack( + "AiSearchBindingsStack", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const bucket = yield* Cloudflare.R2Bucket("AiSearchBindingBucket", {}); + const namespace = yield* Cloudflare.AiSearchNamespace( + "AiSearchBindingNs", + {}, + ); + const search = yield* Cloudflare.AiSearch("AiSearchBindingInstance", { + source: bucket, + }); + const asyncWorker = yield* Cloudflare.Worker("AiSearchBindingsWorker", { + main: path.resolve(import.meta.dirname, "bindings-worker.ts"), + url: true, + env: { SEARCH: search, NS: namespace }, + }); + const effectWorker = yield* AiSearchEffectBindingsWorker; + return { + url: asyncWorker.url.as(), + effectUrl: effectWorker.url.as(), + }; + }), +); diff --git a/packages/alchemy/test/Cloudflare/AiSearch/fixtures/bindings-worker.ts b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/bindings-worker.ts new file mode 100644 index 000000000..88d71c3cc --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/bindings-worker.ts @@ -0,0 +1,34 @@ +/// + +/** + * Plain (non-Effect) Worker handler exercising the two AI Search binding + * shapes wired via `env`: + * - `SEARCH` is a single-instance `ai_search` binding (an `AiSearchInstance`). + * - `NS` is an `ai_search_namespace` binding (`.get(name)` selects an + * instance within the namespace). + * + * The `/bindings` route only reports that the bindings are present and + * shaped correctly at runtime — it does not run a query (that needs indexed + * data). Proving the bindings are injected is enough to validate that the + * Worker deploy accepted them. + */ +interface Env { + SEARCH: AiSearchInstance; + NS: AiSearchNamespace; +} + +export default { + fetch: async (request: Request, env: Env) => { + const url = new URL(request.url); + if (url.pathname === "/bindings") { + return Response.json({ + search: typeof env.SEARCH, + searchChatCompletions: typeof env.SEARCH?.chatCompletions, + searchSearch: typeof env.SEARCH?.search, + ns: typeof env.NS, + nsGet: typeof env.NS?.get, + }); + } + return new Response("ok"); + }, +}; diff --git a/packages/alchemy/test/Cloudflare/AiSearch/fixtures/crawl-target-worker.ts b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/crawl-target-worker.ts new file mode 100644 index 000000000..50b3be859 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/crawl-target-worker.ts @@ -0,0 +1,49 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +const page = (title: string, body: string) => + HttpServerResponse.html( + `${title}` + + `${body}`, + ); + +/** + * A tiny Worker that serves a small crawlable site. Its `workers.dev` URL is a + * domain the account owns, so it can seed an AI Search web-crawler instance + * (which rejects domains the account hasn't verified). + * + * The instance uses `parseType: "crawl"` to walk pages from the seed. We avoid + * the default `sitemap` parse because Cloudflare validates the sitemap + * synchronously at create time and discovers it via `robots.txt`, which a + * freshly-deployed `workers.dev` URL doesn't serve reliably in time. + */ +export default class AiSearchCrawlTargetWorker extends Cloudflare.Worker()( + "AiSearchCrawlTargetWorker", + { + main: import.meta.filename, + }, + Effect.gen(function* () { + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const { pathname } = new URL(request.url); + + if (pathname === "/docs") { + return page( + "Docs", + "

Alchemy docs

Indexable documentation content.

", + ); + } + + return page( + "Crawl Target", + "

Alchemy AI Search crawl target

" + + "

This page exists so AI Search has something to index.

" + + '

Docs

', + ); + }), + }; + }), +) {} diff --git a/packages/alchemy/test/Cloudflare/AiSearch/fixtures/effect-bindings-worker.ts b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/effect-bindings-worker.ts new file mode 100644 index 000000000..8b2191f71 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AiSearch/fixtures/effect-bindings-worker.ts @@ -0,0 +1,64 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Effect-native Worker fixture exercising the Effect-first AI Search bindings: + * - `AiSearchInstanceBinding.bind(search)` attaches the single-instance + * `ai_search` binding and returns an Effect-native `AiSearchClient`. The + * `search` is the `AiSearch` construct's result — proving it's usable + * anywhere an `AiSearchInstance` is, here passed straight to `bind(...)`. + * - `AiSearchNamespaceBinding.bind(namespace)` attaches the + * `ai_search_namespace` binding; `.get(name)` scopes to an instance. + * + * The `/bindings` route resolves each client's `raw` runtime handle (an + * `AiSearchInstance` / `AiSearchNamespace`) and reports its runtime shape — + * proving the Effect clients wire through to the live runtime bindings (it + * does not query, which needs indexed data). + */ +export default class AiSearchEffectBindingsWorker extends Cloudflare.Worker()( + "AiSearchEffectBindingsWorker", + { + main: import.meta.filename, + }, + Effect.gen(function* () { + const bucket = yield* Cloudflare.R2Bucket("AiSearchEffectBindingBucket"); + const namespace = yield* Cloudflare.AiSearchNamespace( + "AiSearchEffectBindingNs", + ); + const aiSearch = yield* Cloudflare.AiSearch( + "AiSearchEffectBindingInstance", + { source: bucket }, + ); + const search = yield* Cloudflare.AiSearchInstance.bind(aiSearch); + const ns = yield* Cloudflare.AiSearchNamespace.bind(namespace); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.url.includes("/bindings")) { + const raw = yield* search.raw; + const nsRaw = yield* ns.raw; + return yield* HttpServerResponse.json({ + mode: "effect", + searchRaw: typeof raw, + searchChatCompletions: typeof raw.chatCompletions, + searchSearch: typeof raw.search, + nsRaw: typeof nsRaw, + nsChatCompletions: typeof nsRaw.get("docs-search").chatCompletions, + }); + } + return HttpServerResponse.text("ok"); + }), + }; + }).pipe( + Effect.provide( + Layer.mergeAll( + Cloudflare.AiSearchInstanceBindingLive, + Cloudflare.AiSearchNamespaceBindingLive, + ), + ), + ), +) {} diff --git a/website/src/content/docs/tutorial/cloudflare/ai-search.mdx b/website/src/content/docs/tutorial/cloudflare/ai-search.mdx new file mode 100644 index 000000000..a3f0c49f9 --- /dev/null +++ b/website/src/content/docs/tutorial/cloudflare/ai-search.mdx @@ -0,0 +1,322 @@ +--- +title: Add AI Search (AutoRAG) +description: Stand up a Cloudflare AI Search (AutoRAG) pipeline over an R2 bucket with one AiSearch call, bind it into your Worker as a typed Effect client, and answer questions over your own documents. +sidebar: + order: 6.5 +--- + +Cloudflare **AI Search** indexes the documents in an +R2 bucket and answers questions over them with retrieval-augmented +generation — chunking, embedding, vector search, reranking, and +generation, all managed by Cloudflare. + +A working pipeline needs more than one resource: the instance itself, +plus a scoped API token so the indexer can read your R2 bucket. The +`Cloudflare.AiSearch` helper wires all of that up in a single call. +We'll use it here and unpack exactly what it creates. + +## Create the R2 bucket + +AI Search indexes objects from an R2 bucket, so that's the source. +Create `src/Docs.ts` with a bucket definition. + +```typescript +// src/Docs.ts +import * as Cloudflare from "alchemy/Cloudflare"; + +export const Docs = Cloudflare.R2Bucket("Docs", {}); +``` + +This is the bucket you'll drop Markdown, PDFs, or text files into — +AI Search picks them up on its next sync. + +## Declare the AI Search pipeline + +Add an `AiSearch` construct and pass the `Docs` bucket as `bucket`. That +field selects R2 as the data source. + +```typescript +// src/Search.ts +import * as Cloudflare from "alchemy/Cloudflare"; +import { Docs } from "./Docs.ts"; + +export const Search = Cloudflare.AiSearch("Search", { + source: Docs, +}); +``` + +`AiSearch` is a **construct**, not a single resource. Calling it expands +into several resources so you don't have to wire them up by hand. + +:::note[Crawling a website instead] +Passing a URL as `source` (instead of a bucket) indexes a website by +crawling it, with optional `parse` / `crawl` / `store` options and no +service token. This tutorial focuses on the R2 source — see the +[`AiSearch` reference](/providers/cloudflare/aisearch/aisearch) for the +web-crawler options. +::: + +## What the helper creates + +For an R2 source, the indexer needs a service token to read your bucket. +Cloudflare only creates that token for you through the dashboard or +Wrangler — never on a programmatic API create — so `AiSearch` provisions +it as stable children of the construct: + +- a least-privilege `Cloudflare.AccountApiToken` scoped to the + **AI Search Index Engine** permission group, +- a `Cloudflare.AiSearchToken` that wraps that API token into the + service-token shape AI Search expects, +- the `Cloudflare.AiSearchInstance` itself, with the service token's + id wired into its `tokenId`. + +So the single `AiSearch("Search", …)` call above is shorthand for +roughly this: + +```typescript +const apiToken = yield* Cloudflare.AccountApiToken("Token", { + policies: [ + { + effect: "allow", + permissionGroups: ["AI Search Index Engine"], + resources: { [`com.cloudflare.api.account.${accountId}`]: "*" }, + }, + ], +}); +const serviceToken = yield* Cloudflare.AiSearchToken("Token", { + cfApiId: apiToken.tokenId, + cfApiKey: apiToken.value, +}); +const instance = yield* Cloudflare.AiSearchInstance("Instance", { + source: Docs.bucketName, + tokenId: serviceToken.id, +}); +``` + +`AiSearch` translates the `source` bucket into the instance's `type: "r2"` + +`source: bucket.bucketName` for you. The value it returns **is** the +`AiSearchInstance` (with the managed `serviceToken` attached), so you can pass +it straight to anything that expects an `AiSearchInstance` — no destructuring +needed. + +## Index only part of the bucket + +By default AI Search indexes every object in the bucket. Pass `prefix` to +scope indexing to one key prefix, and `include` / `exclude` glob patterns +for finer control. + +```diff lang="typescript" +export const Search = Cloudflare.AiSearch("Search", { + source: Docs, ++ prefix: "docs/", ++ include: ["/docs/**"], ++ exclude: ["/docs/drafts/**"], +}); +``` + +`prefix` limits indexing to keys under `docs/`. `include` / `exclude` are +[micromatch](https://github.com/micromatch/micromatch) glob patterns (`*` +matches within a path segment, `**` across segments; up to 10 each) — only +objects matching an `include` pattern are indexed, and `exclude` wins over +`include`. + +## Reuse an existing token + +If your account already has a registered AI Search service token, pass +its `tokenId` and `AiSearch` skips minting the `AccountApiToken` + +`AiSearchToken` children, wiring your token into the instance instead. + +```typescript +export const Search = Cloudflare.AiSearch("Search", { + source: Docs, + tokenId: existingToken.id, +}); +``` + +Otherwise the default — letting the construct provision a least-privilege +token that lives in your stack's state and is torn down on `destroy` — is +the simplest path. + +## Group pipelines under a namespace + +Pass an `AiSearchNamespace` resource as `namespace` to group related +pipelines under it instead of the account-provided `default` namespace. + +```typescript +const Knowledge = Cloudflare.AiSearchNamespace("Knowledge", {}); + +export const Search = Cloudflare.AiSearch("Search", { + source: Docs, + namespace: Knowledge, +}); +``` + +Passing the namespace resource (not its name) lets the engine order the +pipeline after the namespace on deploy and tear it down before the +namespace on destroy. The namespace is immutable — changing it replaces +the pipeline. + +## Add both to the stack + +```diff lang="typescript" +// alchemy.run.ts ++import { Docs } from "./src/Docs.ts"; ++import { Search } from "./src/Search.ts"; + import Api from "./src/Api.ts"; + + export default Alchemy.Stack( + "CloudflareWorkerExample", + { providers: Cloudflare.providers(), state: Cloudflare.state() }, + Effect.gen(function* () { + const api = yield* Api; ++ const docs = yield* Docs; ++ const search = yield* Search; + + return { + url: api.url.as(), ++ bucket: docs.bucketName, ++ search: search.instanceId, + }; + }), + ); +``` + +`yield* Search` registers the construct — the bucket, both tokens, and +the instance all get created on the next deploy, in dependency order. +The value is the `AiSearchInstance` itself, so `search.instanceId` reads +straight off it. + +## Bind the instance into the Worker + +`Cloudflare.AiSearchInstance.bind(search)` attaches the +single-instance `ai_search` binding and returns a typed Effect client. +Bind it during the Worker's init phase. + +```diff lang="typescript" +// src/Api.ts + import * as Cloudflare from "alchemy/Cloudflare"; + import * as Effect from "effect/Effect"; ++import { Search } from "./Search.ts"; + + export default class Api extends Cloudflare.Worker()( + "Api", + { main: import.meta.filename }, + Effect.gen(function* () { ++ const aiSearch = yield* Search; ++ const search = yield* Cloudflare.AiSearchInstance.bind(aiSearch); + + return { + fetch: Effect.gen(function* () { + // …existing routes + }), + }; +- }), ++ }).pipe(Effect.provide(Cloudflare.AiSearchInstanceBindingLive)), + ) {} +``` + +`Cloudflare.AiSearchInstanceBindingLive` is the runtime side of the +binding. Provide it once at the bottom of the Init layer chain and the +`bind(...)` above resolves to a live `AiSearchInstance` handle at runtime. + +## Answer questions on `/ask` + +`search.chatCompletions({ messages })` runs the full RAG pipeline — +retrieve the relevant chunks, then answer with the configured generation +model. It returns an Effect, so call it like any other. + +```diff lang="typescript" ++import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; ++import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; ++ const url = new URL(request.url, "http://api"); + ++ if (url.pathname === "/ask") { ++ const query = url.searchParams.get("q") ?? "What is this about?"; ++ const answer = yield* search ++ .chatCompletions({ messages: [{ role: "user", content: query }] }) ++ .pipe(Effect.orDie); ++ return yield* HttpServerResponse.json({ ++ response: answer.choices[0]?.message.content, ++ sources: answer.chunks.map((c) => c.item.key), ++ }); ++ } + + return HttpServerResponse.text("Not Found", { status: 404 }); + }), + }; +``` + +`answer.choices[0].message.content` is the generated text; `answer.chunks` +are the source chunks it drew from, so we surface their item keys as +citations. `Effect.orDie` turns an `AiSearchError` into a 500 — swap in +`Effect.catchTag("AiSearchError", …)` if you want typed handling. + +## Retrieve chunks without generation on `/search` + +When you only want the matching chunks (e.g. to render your own UI or +feed another model), use `search.search({ query })` — same retrieval, +no generation step, so it's cheaper and faster. + +```diff lang="typescript" ++ if (url.pathname === "/search") { ++ const query = url.searchParams.get("q") ?? ""; ++ const hits = yield* search.search({ query }).pipe(Effect.orDie); ++ return yield* HttpServerResponse.json(hits.chunks); ++ } +``` + +`hits.chunks` is the ranked list of chunks with their scores and source +metadata — no generated answer, because nothing was generated. + +## Try it + +Deploy, upload a document to the bucket, and ask a question. + +```sh +bun alchemy deploy + +# upload a doc into the indexed bucket (via wrangler) +echo "Alchemy deploys with 'bun alchemy deploy'." > faq.txt +bunx wrangler r2 object put "$(bun alchemy stack output bucket)/faq.txt" --file faq.txt --remote + +# ask over it +curl "$(bun alchemy stack output url)/ask?q=How%20do%20I%20deploy%3F" +``` + +You can also drag files straight into the bucket from the Cloudflare +dashboard → **R2** → your bucket — anything in there gets indexed. + +Indexing is asynchronous — a freshly uploaded file takes a short while +to appear in results. Open the Cloudflare dashboard → **AI** → +**AI Search** → your instance to watch the sync status and confirm the +document was indexed before querying. + +## Drop down to the low-level resources + +The `AiSearch` helper is the fast path for the common R2 case. Reach +for the underlying resources directly when you need to: + +- **share one token across many instances** — create the + `AiSearchToken` once and pass its `id` as `tokenId` to each + `AiSearchInstance`, +- **adopt an existing instance or token** rather than create one. + +Everything the helper does is just these resources composed — there's +no behavior you lose by dropping down. + +## What you have now + +- An R2 bucket whose contents are indexed for retrieval. +- A managed, least-privilege API token + AI Search service token, both + owned by your stack and torn down on `destroy`. +- An `AiSearchInstance` bound into your Worker as a typed Effect client. +- `/ask` (full RAG) and `/search` (retrieval only) routes over your own + documents. + +For the full prop surface — embedding/generation models, R2 prefix and +include/exclude filters, reranking, query rewriting, and similarity +caching — see the [AI Search resource reference](/providers/cloudflare/aisearch/aisearch).