1import * as datastore from "@distilled.cloud/gcp/datastore_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_ANCESTOR,
12 desiredBody,
13 getById,
14 IndexNotResolved,
15 indexIdFromOperation,
16 listIndexes,
17 matchesDesired,
18 normalizeEnum,
19 propertiesKey,
20 toAttrs,
21 toKind,
22 waitForOperation,
23 waitUntilDeletable,
24 waitUntilExists,
25 waitUntilGone,
26 type IndexedProperty,
27} from "./internal.ts";
28
29export type IndexAncestor =
30 | datastore.GoogleDatastoreAdminV1IndexAncestorEnum
31 | (string & {});
32
33export type { IndexedProperty };
34
35export type IndexProps = {
36 /**
37 * Entity kind this composite index applies to. If omitted, a unique
38 * kind is generated from the stack, stage, and logical id. Immutable
39 * — changing it replaces the index.
40 */
41 kind?: string;
42 /**
43 * Ancestor mode (`NONE` or `ALL_ANCESTORS`). Immutable — changing it
44 * replaces the index.
45 * @default "NONE"
46 */
47 ancestor?: IndexAncestor;
48 /**
49 * Ordered indexed properties. Composite indexes need 2–100
50 * properties; single-property indexes are built automatically and
51 * cannot be created here. Immutable — changing properties replaces
52 * the index.
53 */
54 properties: IndexedProperty[];
55};
56
57export type Index = Resource<
58 "GCP.Datastore.Index",
59 IndexProps,
60 {
61 /** Resource name `projects/{project}/indexes/{indexId}`. */
62 name: string;
63 /** Server-assigned index id. */
64 indexId: string;
65 /** Project id. */
66 project: string;
67 /** Entity kind. */
68 kind: string;
69 /** Ancestor mode. */
70 ancestor: string | undefined;
71 /** Serving state (`CREATING`, `READY`, `DELETING`, `ERROR`). */
72 state: string | undefined;
73 /** Indexed properties. */
74 properties: IndexedProperty[];
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A Cloud Datastore composite index.
82 *
83 * Indexes apply to the project's default Datastore-mode database.
84 * The index id is assigned by the API. Kind, ancestor mode, and
85 * properties are immutable — changing them replaces the index.
86 * Indexes have no labels field, so Alchemy tracks an index by the id in
87 * state; without state an index with the same definition is adopted when
88 * its kind is the generated one and reported as unowned otherwise.
89 *
90 * ### Creating an Index
91 * **Example:** Composite index on a kind
92 * ```typescript
93 * const index = yield* GCP.Datastore.Index("TasksByDone", {
94 * kind: "Task",
95 * ancestor: "NONE",
96 * properties: [
97 * { name: "done", direction: "ASCENDING" },
98 * { name: "priority", direction: "DESCENDING" },
99 * ],
100 * });
101 * ```
102 *
103 * **Example:** Ancestor index
104 * ```typescript
105 * const index = yield* GCP.Datastore.Index("CommentsByCreated", {
106 * kind: "Comment",
107 * ancestor: "ALL_ANCESTORS",
108 * properties: [
109 * { name: "created", direction: "DESCENDING" },
110 * { name: "author", direction: "ASCENDING" },
111 * ],
112 * });
113 * ```
114 *
115 * @resource
116 * @category Datastore
117 */
118export const Index = Resource<Index>("GCP.Datastore.Index");
119
120export { IndexNotResolved, IndexStillExists } from "./internal.ts";
121
122const replaceOnIdentity = (input: {
123 previousKind: string;
124 nextKind: string;
125 previousAncestor: string;
126 nextAncestor: string;
127 previousProperties: string;
128 nextProperties: string;
129}) => {
130 if (
131 (input.previousKind.length > 0 && input.previousKind !== input.nextKind) ||
132 (input.previousAncestor.length > 0 &&
133 input.previousAncestor !== input.nextAncestor) ||
134 (input.previousProperties.length > 0 &&
135 input.previousProperties !== input.nextProperties)
136 ) {
137 return { action: "replace" as const };
138 }
139 return undefined;
140};
141
142export const IndexProvider = () =>
143 Provider.succeed(Index, {
144 stables: ["name", "indexId", "project"],
145
146 diff: Effect.fn(function* ({ news, olds, output }) {
147 if (!isResolved(news)) return undefined;
148 return replaceOnIdentity({
149 previousKind: olds?.kind ?? output?.kind ?? "",
150 nextKind: news.kind ?? output?.kind ?? "",
151 previousAncestor: normalizeEnum(
152 olds?.ancestor ?? output?.ancestor,
153 DEFAULT_ANCESTOR,
154 ),
155 nextAncestor: normalizeEnum(news.ancestor, DEFAULT_ANCESTOR),
156 previousProperties: propertiesKey(
157 olds?.properties ?? output?.properties,
158 ),
159 nextProperties: propertiesKey(news.properties),
160 });
161 }),
162
163 read: Effect.fn(function* ({ id, olds, output }) {
164 const env = yield* GcpEnvironment.current;
165 if (output?.indexId !== undefined && output.indexId.length > 0) {
166 const existing = yield* getById(env.project, output.indexId);
167 return existing === undefined
168 ? undefined
169 : toAttrs(existing, env.project);
170 }
171 if (olds === undefined) return undefined;
172 // Indexes have no labels; an index is identified by its definition.
173 const generatedKind = yield* toKind(id, undefined, undefined);
174 const kind = olds.kind ?? generatedKind;
175 const found = (yield* listIndexes(env.project)).find((index) =>
176 matchesDesired(index, {
177 kind,
178 ancestor: olds.ancestor,
179 properties: olds.properties,
180 }),
181 );
182 if (found === undefined) return undefined;
183 const attrs = toAttrs(found, env.project);
184 // A generated kind is unique to this stack, stage and id.
185 return kind === generatedKind ? attrs : Unowned(attrs);
186 }),
187
188 reconcile: Effect.fn(function* ({ id, news, output }) {
189 const env = yield* GcpEnvironment.current;
190 const kind = yield* toKind(id, news.kind, output?.kind);
191 const ancestor = normalizeEnum(news.ancestor, DEFAULT_ANCESTOR);
192 const body = desiredBody({
193 kind,
194 ancestor,
195 properties: news.properties,
196 });
197 let current =
198 output?.indexId !== undefined && output.indexId.length > 0
199 ? yield* getById(env.project, output.indexId)
200 : undefined;
201
202 if (current === undefined) {
203 current = (yield* listIndexes(env.project)).find((index) =>
204 matchesDesired(index, {
205 kind,
206 ancestor,
207 properties: news.properties,
208 }),
209 );
210 }
211
212 if (current === undefined) {
213 const created = yield* datastore
214 .createProjectsIndexes({
215 projectId: env.project,
216 body,
217 })
218 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
219 if (created !== undefined) {
220 let indexId = indexIdFromOperation(created);
221 if (indexId === undefined && created.name !== undefined) {
222 const operation = yield* waitForOperation(created);
223 indexId = indexIdFromOperation(operation);
224 }
225 if (indexId !== undefined) {
226 current = yield* waitUntilExists(env.project, indexId);
227 }
228 }
229 if (current === undefined) {
230 current = (yield* listIndexes(env.project)).find((index) =>
231 matchesDesired(index, {
232 kind,
233 ancestor,
234 properties: news.properties,
235 }),
236 );
237 }
238 }
239
240 if (current === undefined || current.indexId === undefined) {
241 return yield* new IndexNotResolved({
242 indexId: output?.indexId ?? "",
243 });
244 }
245
246 return toAttrs(current, env.project);
247 }),
248
249 delete: Effect.fn(function* ({ output }) {
250 const env = yield* GcpEnvironment.current;
251 if (output.indexId.length > 0) {
252 yield* waitUntilDeletable(env.project, output.indexId).pipe(
253 Effect.catchTag("GCP.Datastore.IndexNotResolved", () => Effect.void),
254 );
255 yield* datastore
256 .deleteProjectsIndexes({
257 projectId: env.project,
258 indexId: output.indexId,
259 })
260 .pipe(
261 Effect.retry({
262 while: (error) =>
263 error._tag === "Conflict" || error._tag === "BadRequest",
264 times: 8,
265 schedule: Schedule.spaced("2 seconds"),
266 }),
267 Effect.catchTag("NotFound", () => Effect.void),
268 );
269 yield* waitUntilGone(env.project, output.indexId);
270 }
271 }),
272 });