1import * as firestore from "@distilled.cloud/gcp/firestore_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import * as Schedule from "effect/Schedule";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 databaseIdOf,
14 databaseNameOf,
15 jsonEqual,
16 lastSegment,
17 parseDatabaseName,
18 stringFromMap,
19} from "./internal.ts";
20
21const DEFAULT_QUERY_SCOPE = "COLLECTION";
22const DEFAULT_API_SCOPE = "ANY_API";
23
24export type IndexQueryScope =
25 | firestore.GoogleFirestoreAdminV1IndexQueryScopeEnum
26 | (string & {});
27export type IndexApiScope =
28 | firestore.GoogleFirestoreAdminV1IndexApiScopeEnum
29 | (string & {});
30export type IndexDensity =
31 | firestore.GoogleFirestoreAdminV1IndexDensityEnum
32 | (string & {});
33export type IndexFieldOrder =
34 | firestore.GoogleFirestoreAdminV1IndexFieldOrderEnum
35 | (string & {});
36export type IndexFieldArrayConfig =
37 | firestore.GoogleFirestoreAdminV1IndexFieldArrayConfigEnum
38 | (string & {});
39
40export type IndexVectorConfig = {
41 /** Vector dimension this configuration applies to. */
42 dimension?: number;
43 /** Flat (exhaustive) vector index. */
44 flat?: Record<string, never>;
45};
46
47export type IndexField = {
48 /**
49 * Field path to index. `__name__` is valid. For composite indexes
50 * the API appends `__name__` when it is omitted as the last field.
51 */
52 fieldPath?: string;
53 /** Order for inequality / sort (`ASCENDING`, `DESCENDING`). */
54 order?: IndexFieldOrder;
55 /** Array-contains indexing (`CONTAINS`). */
56 arrayConfig?: IndexFieldArrayConfig;
57 /** Vector nearest-neighbor configuration. */
58 vectorConfig?: IndexVectorConfig;
59};
60
61export type DatabasesCollectionGroupsIndexProps = {
62 /**
63 * Parent database. Full name `projects/{project}/databases/{database}`
64 * or the database id. Immutable — changing it replaces the index.
65 */
66 database: string;
67 /**
68 * Collection group id (`users`, `posts`, …). Immutable — changing it
69 * replaces the index.
70 */
71 collectionGroup: string;
72 /**
73 * Indexed fields. Composite indexes need 2–100 fields. Immutable —
74 * changing fields replaces the index.
75 */
76 fields: IndexField[];
77 /**
78 * Query scope.
79 * @default "COLLECTION"
80 */
81 queryScope?: IndexQueryScope;
82 /**
83 * API scope.
84 * @default "ANY_API"
85 */
86 apiScope?: IndexApiScope;
87 /**
88 * Index density. Immutable — changing it replaces the index.
89 */
90 density?: IndexDensity;
91 /**
92 * Unique index. Immutable — changing it replaces the index.
93 * @default false
94 */
95 unique?: boolean;
96 /**
97 * Multikey index (MongoDB-compatible API). Immutable.
98 * @default false
99 */
100 multikey?: boolean;
101 /**
102 * Shard count. Immutable — changing it replaces the index.
103 */
104 shardCount?: number;
105};
106
107export type DatabasesCollectionGroupsIndex = Resource<
108 "GCP.Firestore.DatabasesCollectionGroupsIndex",
109 DatabasesCollectionGroupsIndexProps,
110 {
111 /** Full resource name `.../collectionGroups/{collection}/indexes/{index}`. */
112 name: string;
113 /** Server-assigned index id. */
114 indexId: string;
115 /** Parent collection group resource name. */
116 collectionGroup: string;
117 /** Collection group id. */
118 collectionGroupId: string;
119 /** Parent database resource name. */
120 database: string;
121 /** Parent database id. */
122 databaseId: string;
123 /** Project id. */
124 project: string;
125 /** Query scope. */
126 queryScope: string | undefined;
127 /** API scope. */
128 apiScope: string | undefined;
129 /** Density. */
130 density: string | undefined;
131 /** Serving state (`CREATING`, `READY`, `NEEDS_REPAIR`). */
132 state: string | undefined;
133 /** Indexed fields. */
134 fields: IndexField[];
135 /** Whether the index is unique. */
136 unique: boolean;
137 /** Whether the index is multikey. */
138 multikey: boolean;
139 /** Shard count, if set. */
140 shardCount: number | undefined;
141 },
142 never,
143 Providers
144>;
145
146/**
147 * A composite Firestore index on a collection group.
148 *
149 * The index id is assigned by the API. Indexes are immutable — changing
150 * fields, scope, density, uniqueness, or the parent collection replaces
151 * the index. Indexes have no labels field: `read` reports an index it
152 * finds without prior state as unowned (adopt it with `--adopt`).
153 *
154 * ### Creating an Index
155 * **Example:** Composite index on a collection
156 * ```typescript
157 * const database = yield* GCP.Firestore.Database("App", {
158 * location: "us-central1",
159 * });
160 * const index = yield* GCP.Firestore.DatabasesCollectionGroupsIndex(
161 * "UsersByName",
162 * {
163 * database: database.name,
164 * collectionGroup: "users",
165 * queryScope: "COLLECTION",
166 * fields: [
167 * { fieldPath: "name", order: "ASCENDING" },
168 * { fieldPath: "created", order: "DESCENDING" },
169 * ],
170 * },
171 * );
172 * ```
173 *
174 * @resource
175 * @category Firestore
176 */
177export const DatabasesCollectionGroupsIndex =
178 Resource<DatabasesCollectionGroupsIndex>(
179 "GCP.Firestore.DatabasesCollectionGroupsIndex",
180 );
181
182export class IndexNotResolved extends Data.TaggedError(
183 "GCP.Firestore.IndexNotResolved",
184)<{
185 name: string;
186}> {}
187
188export class IndexStillExists extends Data.TaggedError(
189 "GCP.Firestore.IndexStillExists",
190)<{
191 name: string;
192}> {}
193
194const normalizeEnum = (value: string | undefined, fallback: string) => {
195 const next = (value ?? fallback).toUpperCase();
196 return next.endsWith("_UNSPECIFIED") ? fallback : next;
197};
198
199const fieldOf = (
200 field: IndexField | firestore.GoogleFirestoreAdminV1IndexField,
201): IndexField => ({
202 fieldPath: field.fieldPath,
203 order: field.order,
204 arrayConfig: field.arrayConfig,
205 vectorConfig:
206 field.vectorConfig !== undefined
207 ? {
208 dimension: field.vectorConfig.dimension,
209 flat: field.vectorConfig.flat !== undefined ? {} : undefined,
210 }
211 : undefined,
212});
213
214const canonicalizeFields = (
215 fields: readonly (IndexField | firestore.GoogleFirestoreAdminV1IndexField)[],
216) => {
217 const mapped = fields.map(fieldOf);
218 const last = mapped[mapped.length - 1];
219 if (last !== undefined && last.fieldPath !== "__name__") {
220 mapped.push({
221 fieldPath: "__name__",
222 order: last.order ?? "ASCENDING",
223 });
224 }
225 return mapped;
226};
227
228const fieldsKey = (
229 fields:
230 | readonly (IndexField | firestore.GoogleFirestoreAdminV1IndexField)[]
231 | undefined,
232) =>
233 JSON.stringify(
234 canonicalizeFields(fields ?? []).map((field) => ({
235 fieldPath: field.fieldPath ?? "",
236 order: field.order?.toUpperCase(),
237 arrayConfig: field.arrayConfig?.toUpperCase(),
238 vectorConfig: field.vectorConfig,
239 })),
240 );
241
242const collectionGroupParent = (
243 project: string,
244 database: string,
245 collectionGroup: string,
246) =>
247 `${databaseNameOf(project, database)}/collectionGroups/${lastSegment(collectionGroup)}`;
248
249const toAttrs = (
250 index: firestore.GoogleFirestoreAdminV1Index,
251 project: string,
252): DatabasesCollectionGroupsIndex["Attributes"] => {
253 const name = index.name ?? "";
254 const parsed = parseDatabaseName(name);
255 const database = databaseNameOf(parsed.project || project, parsed.databaseId);
256 return {
257 name,
258 indexId: parsed.indexId || lastSegment(name),
259 collectionGroup: `${database}/collectionGroups/${parsed.collectionGroup}`,
260 collectionGroupId: parsed.collectionGroup,
261 database,
262 databaseId: parsed.databaseId,
263 project: parsed.project || project,
264 queryScope: index.queryScope,
265 apiScope: index.apiScope,
266 density: index.density,
267 state: index.state,
268 fields: (index.fields ?? []).map(fieldOf),
269 unique: index.unique === true,
270 multikey: index.multikey === true,
271 shardCount: index.shardCount,
272 };
273};
274
275const desiredBody = (news: DatabasesCollectionGroupsIndexProps) => ({
276 queryScope: news.queryScope ?? DEFAULT_QUERY_SCOPE,
277 apiScope: news.apiScope ?? DEFAULT_API_SCOPE,
278 density: news.density,
279 unique: news.unique === true ? true : undefined,
280 multikey: news.multikey === true ? true : undefined,
281 shardCount: news.shardCount,
282 fields: news.fields.map(fieldOf),
283});
284
285const matchesDesired = (
286 index: firestore.GoogleFirestoreAdminV1Index,
287 news: DatabasesCollectionGroupsIndexProps,
288) => {
289 const desired = desiredBody(news);
290 return (
291 normalizeEnum(index.queryScope, DEFAULT_QUERY_SCOPE) ===
292 normalizeEnum(desired.queryScope, DEFAULT_QUERY_SCOPE) &&
293 normalizeEnum(index.apiScope, DEFAULT_API_SCOPE) ===
294 normalizeEnum(desired.apiScope, DEFAULT_API_SCOPE) &&
295 (index.unique === true) === (desired.unique === true) &&
296 (index.multikey === true) === (desired.multikey === true) &&
297 jsonEqual(index.shardCount, desired.shardCount) &&
298 (desired.density === undefined ||
299 normalizeEnum(index.density, desired.density) ===
300 normalizeEnum(desired.density, desired.density)) &&
301 fieldsKey(index.fields) === fieldsKey(desired.fields)
302 );
303};
304
305const getByName = (name: string) =>
306 firestore
307 .getProjectsDatabasesCollectionGroupsIndexes({ name })
308 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
309
310const listOnParent = (parent: string) =>
311 firestore.listProjectsDatabasesCollectionGroupsIndexes.pages({ parent }).pipe(
312 Stream.flatMap((page) => Stream.fromIterable(page.indexes ?? [])),
313 Stream.runCollect,
314 Effect.map((chunk) => Array.from(chunk)),
315 // The parent database is gone: it has no indexes.
316 Effect.catchTag("NotFound", () =>
317 Effect.succeed([] as firestore.GoogleFirestoreAdminV1Index[]),
318 ),
319 );
320
321const nameFromOperation = (
322 operation: firestore.GoogleLongrunningOperation,
323): string | undefined =>
324 stringFromMap(operation.response, "name") ??
325 stringFromMap(operation.metadata, "index") ??
326 stringFromMap(operation.metadata, "name");
327
328const resolveCreatedIndexName = (
329 operation: firestore.GoogleLongrunningOperation | undefined,
330 parent: string,
331 news: DatabasesCollectionGroupsIndexProps,
332) =>
333 Effect.gen(function* () {
334 if (operation !== undefined) {
335 const immediate = nameFromOperation(operation);
336 if (immediate !== undefined) return immediate;
337 if (operation.name !== undefined) {
338 const latest = yield* firestore
339 .getProjectsDatabasesOperations({ name: operation.name })
340 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(operation)));
341 const fromOp = nameFromOperation(latest);
342 if (fromOp !== undefined) return fromOp;
343 }
344 }
345 const match = (yield* listOnParent(parent)).find((index) =>
346 matchesDesired(index, news),
347 );
348 if (match?.name !== undefined) return match.name;
349 return yield* new IndexNotResolved({ name: `${parent}/indexes` });
350 }).pipe(
351 Effect.retry({
352 while: (error) => error._tag === "GCP.Firestore.IndexNotResolved",
353 times: 8,
354 schedule: Schedule.spaced("2 seconds"),
355 }),
356 );
357
358const waitUntilExists = (name: string) =>
359 getByName(name).pipe(
360 Effect.flatMap((index) =>
361 index
362 ? Effect.succeed(index)
363 : Effect.fail(new IndexNotResolved({ name })),
364 ),
365 Effect.retry({
366 while: (error) => error._tag === "GCP.Firestore.IndexNotResolved",
367 times: 8,
368 schedule: Schedule.spaced("2 seconds"),
369 }),
370 );
371
372const waitUntilGone = (name: string) =>
373 getByName(name).pipe(
374 Effect.filterOrFail(
375 (index) => index === undefined,
376 () => new IndexStillExists({ name }),
377 ),
378 Effect.retry({
379 while: (error) => error._tag === "GCP.Firestore.IndexStillExists",
380 times: 8,
381 schedule: Schedule.spaced("2 seconds"),
382 }),
383 Effect.asVoid,
384 );
385
386export const DatabasesCollectionGroupsIndexProvider = () =>
387 Provider.succeed(DatabasesCollectionGroupsIndex, {
388 stables: [
389 "name",
390 "indexId",
391 "collectionGroup",
392 "collectionGroupId",
393 "database",
394 "databaseId",
395 "project",
396 ],
397
398 diff: Effect.fn(function* ({ news, olds, output }) {
399 if (!isResolved(news)) return undefined;
400 const previousDatabase = databaseIdOf(
401 olds?.database ?? output?.database ?? output?.databaseId ?? "",
402 );
403 const nextDatabase = databaseIdOf(news.database);
404 const previousGroup = lastSegment(
405 olds?.collectionGroup ?? output?.collectionGroupId ?? "",
406 );
407 const nextGroup = lastSegment(news.collectionGroup);
408 const previousFields = fieldsKey(olds?.fields ?? output?.fields);
409 const nextFields = fieldsKey(news.fields);
410 const previousScope = normalizeEnum(
411 olds?.queryScope ?? output?.queryScope,
412 DEFAULT_QUERY_SCOPE,
413 );
414 const nextScope = normalizeEnum(news.queryScope, DEFAULT_QUERY_SCOPE);
415 const previousApi = normalizeEnum(
416 olds?.apiScope ?? output?.apiScope,
417 DEFAULT_API_SCOPE,
418 );
419 const nextApi = normalizeEnum(news.apiScope, DEFAULT_API_SCOPE);
420 const previousUnique = olds?.unique === true || output?.unique === true;
421 const nextUnique = news.unique === true;
422 const previousMultikey =
423 olds?.multikey === true || output?.multikey === true;
424 const nextMultikey = news.multikey === true;
425 const previousDensity = (
426 olds?.density ??
427 output?.density ??
428 ""
429 ).toUpperCase();
430 const nextDensity = (news.density ?? previousDensity).toUpperCase();
431 const previousShards = olds?.shardCount ?? output?.shardCount;
432 const nextShards = news.shardCount ?? previousShards;
433 if (
434 (previousDatabase.length > 0 && previousDatabase !== nextDatabase) ||
435 (previousGroup.length > 0 && previousGroup !== nextGroup) ||
436 previousFields !== nextFields ||
437 previousScope !== nextScope ||
438 previousApi !== nextApi ||
439 previousUnique !== nextUnique ||
440 previousMultikey !== nextMultikey ||
441 (news.density !== undefined && previousDensity !== nextDensity) ||
442 previousShards !== nextShards
443 ) {
444 return { action: "replace" as const };
445 }
446 return undefined;
447 }),
448
449 read: Effect.fn(function* ({ output }) {
450 const env = yield* GcpEnvironment.current;
451 const name = output?.name;
452 if (name === undefined || name.length === 0) return undefined;
453 const existing = yield* getByName(name);
454 if (existing === undefined) return undefined;
455 const attrs = toAttrs(existing, env.project);
456 // No labels field: without prior state it may not be ours.
457 return output !== undefined ? attrs : Unowned(attrs);
458 }),
459
460 reconcile: Effect.fn(function* ({ news, output }) {
461 const env = yield* GcpEnvironment.current;
462 const parent = collectionGroupParent(
463 env.project,
464 news.database,
465 news.collectionGroup,
466 );
467 const body = desiredBody(news);
468
469 let current =
470 output?.name !== undefined ? yield* getByName(output.name) : undefined;
471
472 if (current === undefined) {
473 const existing = yield* listOnParent(parent);
474 current = existing.find((index) => matchesDesired(index, news));
475 }
476
477 if (current === undefined) {
478 const created = yield* firestore
479 .createProjectsDatabasesCollectionGroupsIndexes({
480 parent,
481 body,
482 })
483 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
484 const name = yield* resolveCreatedIndexName(created, parent, news);
485 current = yield* waitUntilExists(name);
486 } else if (current.name !== undefined) {
487 current = yield* waitUntilExists(current.name);
488 }
489
490 if (current === undefined || current.name === undefined) {
491 return yield* new IndexNotResolved({ name: `${parent}/indexes` });
492 }
493
494 return toAttrs(current, env.project);
495 }),
496
497 delete: Effect.fn(function* ({ output }) {
498 yield* firestore
499 .deleteProjectsDatabasesCollectionGroupsIndexes({
500 name: output.name,
501 })
502 .pipe(Effect.catchTag("NotFound", () => Effect.void));
503 yield* waitUntilGone(output.name);
504 }),
505 });