1import * as datastore from "@distilled.cloud/gcp/datastore_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
8
9export const DEFAULT_ANCESTOR = "NONE";
10export const DEFAULT_DIRECTION = "ASCENDING";
11export const MAX_KIND_LENGTH = 63;
12
13export class IndexNotResolved extends Data.TaggedError(
14 "GCP.Datastore.IndexNotResolved",
15)<{
16 indexId: string;
17}> {}
18
19export class IndexStillExists extends Data.TaggedError(
20 "GCP.Datastore.IndexStillExists",
21)<{
22 indexId: string;
23}> {}
24
25export const stringFromMap = (
26 map: datastore.DocumentMap | undefined,
27 key: string,
28): string | undefined => {
29 const value = map?.[key];
30 if (typeof value === "string" && value.length > 0) return value;
31 if (value !== null && typeof value === "object") {
32 const record = value as { stringValue?: unknown };
33 if (
34 typeof record.stringValue === "string" &&
35 record.stringValue.length > 0
36 ) {
37 return record.stringValue;
38 }
39 }
40 return undefined;
41};
42
43export const normalizeEnum = (value: string | undefined, fallback: string) => {
44 const next = (value ?? fallback).toUpperCase();
45 return next.endsWith("_UNSPECIFIED") ? fallback : next;
46};
47
48export const rfc1035Kind = (name: string, maxLength = MAX_KIND_LENGTH) => {
49 let next = name
50 .replace(/[^a-zA-Z0-9_]/g, "_")
51 .replace(/_+/g, "_")
52 .replace(/^_+|_+$/g, "");
53 if (!/^[A-Za-z]/.test(next)) next = `K${next}`;
54 next = next.slice(0, maxLength).replace(/_+$/g, "");
55 if (next.length === 0) next = "Kind";
56 return next.slice(0, maxLength);
57};
58
59export const toKind = (
60 id: string,
61 kind: string | undefined,
62 existing?: string,
63) =>
64 Effect.gen(function* () {
65 if (kind !== undefined && kind.length > 0) return kind;
66 if (existing !== undefined && existing.length > 0) return existing;
67 return rfc1035Kind(
68 yield* createPhysicalName({
69 id,
70 maxLength: MAX_KIND_LENGTH,
71 lowercase: false,
72 }),
73 );
74 });
75
76export type IndexedProperty = {
77 /** Property name to index. */
78 name: string;
79 /**
80 * Sort direction.
81 * @default "ASCENDING"
82 */
83 direction?:
84 | datastore.GoogleDatastoreAdminV1IndexedPropertyDirectionEnum
85 | (string & {});
86};
87
88export const propertyOf = (
89 property: IndexedProperty | datastore.GoogleDatastoreAdminV1IndexedProperty,
90): IndexedProperty => ({
91 name: property.name ?? "",
92 direction: normalizeEnum(property.direction, DEFAULT_DIRECTION),
93});
94
95export const propertiesKey = (
96 properties:
97 | readonly IndexedProperty[]
98 | readonly datastore.GoogleDatastoreAdminV1IndexedProperty[]
99 | undefined,
100) => JSON.stringify((properties ?? []).map(propertyOf));
101
102export const desiredBody = (input: {
103 kind: string;
104 ancestor?: string;
105 properties: IndexedProperty[];
106}): datastore.GoogleDatastoreAdminV1Index => ({
107 kind: input.kind,
108 ancestor: normalizeEnum(input.ancestor, DEFAULT_ANCESTOR),
109 properties: input.properties.map(propertyOf),
110});
111
112export const matchesDesired = (
113 index: datastore.GoogleDatastoreAdminV1Index,
114 input: {
115 kind: string;
116 ancestor?: string;
117 properties: IndexedProperty[];
118 },
119) => {
120 const desired = desiredBody(input);
121 return (
122 (index.kind ?? "") === desired.kind &&
123 normalizeEnum(index.ancestor, DEFAULT_ANCESTOR) ===
124 normalizeEnum(desired.ancestor, DEFAULT_ANCESTOR) &&
125 propertiesKey(index.properties ?? []) ===
126 propertiesKey(desired.properties ?? [])
127 );
128};
129
130export const toAttrs = (
131 index: datastore.GoogleDatastoreAdminV1Index,
132 project: string,
133) => {
134 const indexId = index.indexId ?? "";
135 return {
136 name:
137 indexId.length > 0
138 ? `projects/${index.projectId ?? project}/indexes/${indexId}`
139 : `projects/${index.projectId ?? project}/indexes`,
140 indexId,
141 project: index.projectId ?? project,
142 kind: index.kind ?? "",
143 ancestor: index.ancestor,
144 state: index.state,
145 properties: (index.properties ?? []).map(propertyOf),
146 };
147};
148
149export const getById = (projectId: string, indexId: string) =>
150 datastore
151 .getProjectsIndexes({ projectId, indexId })
152 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
153
154export const listIndexes = (projectId: string) =>
155 datastore.listProjectsIndexes
156 .pages({
157 projectId,
158 })
159 .pipe(
160 Stream.runCollect,
161 Effect.map((pages) =>
162 Array.from(pages).flatMap((page) => page.indexes ?? []),
163 ),
164 );
165
166/** Wait for a Datastore admin operation (index builds can take minutes). */
167export const waitForOperation = (
168 operation: datastore.GoogleLongrunningOperation,
169) =>
170 waitForGcpOperation(
171 operation,
172 (name) => datastore.getProjectsOperations({ name }),
173 { budget: "20 minutes" },
174 ).pipe(
175 // ALREADY_EXISTS (6): an identical index already exists.
176 Effect.catchIf(
177 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
178 () => Effect.void,
179 ),
180 // Re-read the finished operation for its typed response/metadata.
181 Effect.flatMap(() =>
182 operation.name === undefined || operation.name.length === 0
183 ? Effect.succeed(operation)
184 : datastore.getProjectsOperations({ name: operation.name }),
185 ),
186 );
187
188export const indexIdFromOperation = (
189 operation: datastore.GoogleLongrunningOperation | undefined,
190): string | undefined => {
191 if (operation === undefined) return undefined;
192 return (
193 stringFromMap(operation.response, "indexId") ??
194 stringFromMap(operation.metadata, "indexId") ??
195 stringFromMap(operation.metadata, "index")
196 );
197};
198
199export const waitUntilExists = (projectId: string, indexId: string) =>
200 getById(projectId, indexId).pipe(
201 Effect.flatMap((index) =>
202 index
203 ? Effect.succeed(index)
204 : Effect.fail(new IndexNotResolved({ indexId })),
205 ),
206 Effect.retry({
207 while: (error) => error._tag === "GCP.Datastore.IndexNotResolved",
208 times: 8,
209 schedule: Schedule.spaced("2 seconds"),
210 }),
211 );
212
213export const waitUntilGone = (projectId: string, indexId: string) =>
214 getById(projectId, indexId).pipe(
215 Effect.filterOrFail(
216 (index) => index === undefined,
217 () => new IndexStillExists({ indexId }),
218 ),
219 Effect.retry({
220 while: (error) => error._tag === "GCP.Datastore.IndexStillExists",
221 times: 24,
222 schedule: Schedule.spaced("5 seconds"),
223 }),
224 Effect.asVoid,
225 );
226
227export const waitUntilDeletable = (projectId: string, indexId: string) =>
228 getById(projectId, indexId).pipe(
229 Effect.filterOrFail(
230 (index) => index === undefined || index.state !== "DELETING",
231 () => new IndexNotResolved({ indexId }),
232 ),
233 Effect.retry({
234 while: (error) => error._tag === "GCP.Datastore.IndexNotResolved",
235 times: 8,
236 schedule: Schedule.spaced("2 seconds"),
237 }),
238 );