1import * as vectorize from "@distilled.cloud/cloudflare/vectorize";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10
11export type MetadataIndexType = "string" | "number" | "boolean";
12
13export type MetadataIndexProps = {
14 /**
15 * Name of the parent Vectorize index. Pass `index.indexName` from a
16 * `VectorizeIndex` to track the dependency. Changing the parent index
17 * triggers a replacement.
18 */
19 indexName: string;
20 /**
21 * The metadata property to index. Filter expressions in `query` use this
22 * name (e.g. `{ category: { $eq: "books" } }`). Cannot be changed after
23 * creation — updating triggers a replacement.
24 */
25 propertyName: string;
26 /**
27 * The type of metadata values stored under `propertyName`. Cannot be
28 * changed after creation — updating triggers a replacement.
29 */
30 indexType: MetadataIndexType;
31};
32
33export type MetadataIndexAttributes = {
34 propertyName: string;
35 indexType: MetadataIndexType;
36 indexName: string;
37 accountId: string;
38 mutationId: string | undefined;
39};
40
41export type MetadataIndex = Resource<
42 "Cloudflare.VectorizeMetadataIndex",
43 MetadataIndexProps,
44 MetadataIndexAttributes,
45 never,
46 Providers
47>;
48
49/**
50 * A metadata index on a Cloudflare Vectorize index.
51 *
52 * Metadata indexes enable filtering query results by metadata properties.
53 * Without a metadata index on a property, that property cannot be used in
54 * the `filter` of a `query()` call.
55 *
56 * A metadata index is identified by its parent index and `propertyName` and
57 * is immutable — changing the property name, type, or parent index triggers
58 * a replacement.
59 * ### Creating a Metadata Index
60 * **Example:** Index a string metadata property
61 * ```typescript
62 * const index = yield* Cloudflare.Vectorize.Index("my-index", {
63 * dimensions: 768,
64 * metric: "cosine",
65 * });
66 *
67 * yield* Cloudflare.Vectorize.MetadataIndex("CategoryMetaIndex", {
68 * indexName: index.indexName,
69 * propertyName: "category",
70 * indexType: "string",
71 * });
72 * ```
73 *
74 * **Example:** Index a numeric metadata property
75 * ```typescript
76 * yield* Cloudflare.Vectorize.MetadataIndex("PriceMetaIndex", {
77 * indexName: index.indexName,
78 * propertyName: "price",
79 * indexType: "number",
80 * });
81 * ```
82 *
83 * @see https://developers.cloudflare.com/vectorize/reference/metadata-filtering/
84 *
85 * @resource
86 * @product Vectorize
87 * @category AI
88 */
89export const MetadataIndex = Resource<MetadataIndex>(
90 "Cloudflare.VectorizeMetadataIndex",
91);
92
93export const MetadataIndexProvider = () =>
94 Provider.succeed(MetadataIndex, {
95 stables: ["propertyName", "indexName", "accountId"],
96 diff: Effect.fn(function* ({ olds, news, output }) {
97 const { accountId } = yield* yield* CloudflareEnvironment;
98 if (!isResolved(news)) return undefined;
99 if ((output?.accountId ?? accountId) !== accountId) {
100 return { action: "replace" } as const;
101 }
102 const newIndexName = news.indexName;
103 const oldIndexName = output?.indexName ?? olds.indexName;
104 if (
105 (oldIndexName ?? newIndexName) !== newIndexName ||
106 (olds.propertyName ?? news.propertyName) !== news.propertyName ||
107 (olds.indexType ?? news.indexType) !== news.indexType
108 ) {
109 return { action: "replace" } as const;
110 }
111 return undefined;
112 }),
113 read: Effect.fn(function* ({ output, olds }) {
114 const { accountId } = yield* yield* CloudflareEnvironment;
115 const acct = output?.accountId ?? accountId;
116 const indexName = output?.indexName ?? olds?.indexName;
117 const propertyName = output?.propertyName ?? olds?.propertyName;
118 if (!indexName || !propertyName) return undefined;
119 const existing = yield* findExisting(acct, indexName, propertyName);
120 if (!existing?.propertyName || !existing.indexType) return undefined;
121 return {
122 propertyName: existing.propertyName,
123 indexType: existing.indexType,
124 indexName,
125 accountId: acct,
126 mutationId: output?.mutationId,
127 };
128 }),
129 reconcile: Effect.fn(function* ({ news, output }) {
130 const { accountId } = yield* yield* CloudflareEnvironment;
131 const acct = output?.accountId ?? accountId;
132 const indexName = news.indexName;
133
134 // Observe — list metadata indexes on the parent and look for one
135 // matching propertyName.
136 const existing = yield* findExisting(acct, indexName, news.propertyName);
137
138 // Ensure — create if missing. Cloudflare returns 409 Conflict on
139 // duplicate; tolerate the race by reusing the prior mutationId.
140 let mutationId = output?.mutationId;
141 if (!existing) {
142 const created = yield* vectorize
143 .createIndexMetadataIndex({
144 accountId: acct,
145 indexName,
146 propertyName: news.propertyName,
147 indexType: news.indexType,
148 })
149 .pipe(
150 Effect.catchTag("MetadataIndexAlreadyExists", () =>
151 Effect.succeed({ mutationId: output?.mutationId }),
152 ),
153 );
154 mutationId = created.mutationId ?? undefined;
155 }
156
157 return {
158 propertyName: news.propertyName,
159 indexType: news.indexType,
160 indexName,
161 accountId: acct,
162 mutationId,
163 };
164 }),
165 delete: Effect.fn(function* ({ output }) {
166 yield* vectorize
167 .deleteIndexMetadataIndex({
168 accountId: output.accountId,
169 indexName: output.indexName,
170 propertyName: output.propertyName,
171 })
172 .pipe(
173 Effect.catchTag(
174 ["NotFound", "Gone", "MetadataIndexNotFound"],
175 () => Effect.void,
176 ),
177 );
178 }),
179 list: Effect.fn(function* () {
180 const { accountId } = yield* yield* CloudflareEnvironment;
181
182 // Parent fan-out: metadata indexes are sub-resources of a Vectorize
183 // index. Enumerate every parent index (account-scoped, paginated),
184 // then list metadata indexes per index with bounded concurrency.
185 const indexNames = yield* vectorize.listIndexes.pages({ accountId }).pipe(
186 Stream.runCollect,
187 Effect.map((chunk) =>
188 Array.from(chunk).flatMap((page) =>
189 (page.result ?? [])
190 .map((index) => index.name)
191 .filter((name): name is string => name != null),
192 ),
193 ),
194 // A parent index deleted by a concurrent operation mid-enumeration
195 // can fail the account-scoped pagination with "index deleted" (typed
196 // as Gone) — skip the whole enumeration rather than throw.
197 Effect.catchTag(["NotFound", "Gone"], () =>
198 Effect.succeed<string[]>([]),
199 ),
200 );
201
202 const rows = yield* Effect.forEach(
203 indexNames,
204 (indexName) =>
205 vectorize.listIndexMetadataIndexes({ accountId, indexName }).pipe(
206 Effect.map((res) =>
207 (res.metadataIndexes ?? []).flatMap(
208 (m): MetadataIndexAttributes[] => {
209 if (m.propertyName == null || m.indexType == null) {
210 return [];
211 }
212 return [
213 {
214 propertyName: m.propertyName,
215 indexType: m.indexType.toLowerCase() as MetadataIndexType,
216 indexName,
217 accountId,
218 mutationId: undefined,
219 },
220 ];
221 },
222 ),
223 ),
224 // Parent index removed between enumeration and read; skip it.
225 Effect.catchTag(["NotFound", "Gone"], () =>
226 Effect.succeed<MetadataIndexAttributes[]>([]),
227 ),
228 ),
229 { concurrency: 10 },
230 );
231
232 return rows.flat();
233 }),
234 });
235
236const findExisting = (acct: string, indexName: string, propertyName: string) =>
237 vectorize
238 .listIndexMetadataIndexes({
239 accountId: acct,
240 indexName,
241 })
242 .pipe(
243 Effect.map((res) => {
244 const index = res.metadataIndexes?.find(
245 (m) => m.propertyName === propertyName,
246 );
247 return index
248 ? {
249 propertyName: index.propertyName,
250 indexType: index.indexType?.toLowerCase() as MetadataIndexType,
251 }
252 : undefined;
253 }),
254 Effect.catchTag(["NotFound", "Gone"], () => Effect.succeed(undefined)),
255 );