1import * as discoveryengine from "@distilled.cloud/gcp/discoveryengine_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 parentOf,
13 parseResourceName,
14 rfc1035,
15 sameJson,
16 toPhysical,
17 withSchemaVersion,
18} from "./internal.ts";
19import { waitForOperation } from "./operations.ts";
20
21export type CollectionsDataStoresSchemaProps = {
22 /**
23 * Parent data store resource name. Immutable — changing it replaces
24 * the schema.
25 */
26 dataStore: string;
27 /**
28 * Schema id (RFC-1034, max 63 characters). If omitted, a unique id is
29 * generated. Immutable — changing it replaces the schema.
30 */
31 schemaId?: string;
32 /**
33 * JSON Schema document.
34 */
35 jsonSchema?: string;
36 /**
37 * Structured schema representation.
38 */
39 structSchema?: Record<string, unknown>;
40};
41
42export type CollectionsDataStoresSchema = Resource<
43 "GCP.DiscoveryEngine.CollectionsDataStoresSchema",
44 CollectionsDataStoresSchemaProps,
45 {
46 /** Full resource name. */
47 name: string;
48 /** Schema id. */
49 schemaId: string;
50 /** Parent data store resource name. */
51 dataStore: string;
52 /** Project id. */
53 project: string;
54 /** Location id. */
55 location: string;
56 /** JSON Schema document. */
57 jsonSchema: string | undefined;
58 /** Structured schema. */
59 structSchema: Record<string, unknown> | undefined;
60 },
61 never,
62 Providers
63>;
64
65/**
66 * A Discovery Engine schema on a collection data store.
67 *
68 * Without labels, ownership rests on the deterministic id: `read` reports a
69 * resource it finds without prior state as unowned (adopt it with `--adopt`).
70 * Parent and schema id are immutable. Schema contents update in place.
71 *
72 * ### Creating a Schema
73 * **Example:** Custom schema on a store without a default schema
74 * ```typescript
75 * const store = yield* GCP.DiscoveryEngine.CollectionsDataStore("Docs", {
76 * skipDefaultSchemaCreation: true,
77 * });
78 * const schema = yield* GCP.DiscoveryEngine.CollectionsDataStoresSchema(
79 * "Fields",
80 * {
81 * dataStore: store.name,
82 * jsonSchema: JSON.stringify({
83 * type: "object",
84 * properties: { title: { type: "string" } },
85 * }),
86 * },
87 * );
88 * ```
89 *
90 * @resource
91 * @category DiscoveryEngine
92 */
93export const CollectionsDataStoresSchema =
94 Resource<CollectionsDataStoresSchema>(
95 "GCP.DiscoveryEngine.CollectionsDataStoresSchema",
96 );
97
98export class CollectionsDataStoresSchemaNotResolved extends Data.TaggedError(
99 "GCP.DiscoveryEngine.CollectionsDataStoresSchemaNotResolved",
100)<{
101 name: string;
102}> {}
103
104export class CollectionsDataStoresSchemaStillExists extends Data.TaggedError(
105 "GCP.DiscoveryEngine.CollectionsDataStoresSchemaStillExists",
106)<{
107 name: string;
108}> {}
109
110const toAttrs = (
111 schema: discoveryengine.GoogleCloudDiscoveryengineV1Schema,
112 project: string,
113) => {
114 const name = schema.name ?? "";
115 const parsed = parseResourceName(name, "schemas");
116 return {
117 name,
118 schemaId: parsed.id,
119 dataStore: parentOf(name, "schemas"),
120 project: parsed.project || project,
121 location: parsed.location,
122 jsonSchema: schema.jsonSchema,
123 structSchema: schema.structSchema as Record<string, unknown> | undefined,
124 };
125};
126
127const resourceName = (dataStore: string, schemaId: string) =>
128 `${dataStore}/schemas/${schemaId}`;
129
130const getByName = (name: string) =>
131 name.length === 0
132 ? Effect.succeed(undefined)
133 : discoveryengine
134 .getProjectsLocationsCollectionsDataStoresSchemas({ name })
135 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
136
137const waitUntilExists = (name: string) =>
138 getByName(name).pipe(
139 Effect.flatMap((schema) =>
140 schema
141 ? Effect.succeed(schema)
142 : Effect.fail(new CollectionsDataStoresSchemaNotResolved({ name })),
143 ),
144 Effect.retry({
145 while: (error) =>
146 error._tag ===
147 "GCP.DiscoveryEngine.CollectionsDataStoresSchemaNotResolved",
148 times: 8,
149 schedule: Schedule.spaced("1 second"),
150 }),
151 );
152
153const waitUntilGone = (name: string) =>
154 getByName(name).pipe(
155 Effect.flatMap((schema) =>
156 schema === undefined
157 ? Effect.void
158 : Effect.fail(new CollectionsDataStoresSchemaStillExists({ name })),
159 ),
160 Effect.retry({
161 while: (error) =>
162 error._tag ===
163 "GCP.DiscoveryEngine.CollectionsDataStoresSchemaStillExists",
164 times: 10,
165 schedule: Schedule.spaced("2 seconds"),
166 }),
167 );
168
169export const CollectionsDataStoresSchemaProvider = () =>
170 Provider.succeed(CollectionsDataStoresSchema, {
171 stables: ["name", "schemaId", "dataStore", "project", "location"],
172
173 diff: Effect.fn(function* ({ news, olds, output }) {
174 if (!isResolved(news)) return undefined;
175 const previousParent = olds?.dataStore ?? output?.dataStore;
176 const previousId = olds?.schemaId ?? output?.schemaId;
177 if (
178 (previousParent !== undefined && news.dataStore !== previousParent) ||
179 (previousId !== undefined &&
180 news.schemaId !== undefined &&
181 news.schemaId !== previousId)
182 ) {
183 return {
184 action: "replace" as const,
185 deleteFirst:
186 previousParent === news.dataStore &&
187 previousId !== undefined &&
188 news.schemaId === previousId,
189 };
190 }
191 return undefined;
192 }),
193
194 read: Effect.fn(function* ({ id, olds, output }) {
195 const env = yield* GcpEnvironment.current;
196 const dataStore = olds?.dataStore ?? output?.dataStore;
197 const schemaId = yield* toPhysical(
198 id,
199 olds?.schemaId,
200 output?.schemaId,
201 (name) => rfc1035(name, 32),
202 32,
203 );
204 const name =
205 output?.name ??
206 (dataStore !== undefined
207 ? resourceName(dataStore, schemaId)
208 : undefined);
209 if (name === undefined) return undefined;
210 const existing = yield* getByName(name);
211 if (existing === undefined) return undefined;
212 const attrs = toAttrs(existing, env.project);
213 // No labels field: without prior state it may not be ours.
214 return output !== undefined ? attrs : Unowned(attrs);
215 }),
216
217 reconcile: Effect.fn(function* ({ id, news, output }) {
218 const env = yield* GcpEnvironment.current;
219 const schemaId = yield* toPhysical(
220 id,
221 news.schemaId,
222 output?.schemaId,
223 (name) => rfc1035(name, 32),
224 32,
225 );
226 const name = resourceName(news.dataStore, schemaId);
227 const jsonSchema = withSchemaVersion(news.jsonSchema);
228
229 let current = yield* getByName(output?.name ?? name);
230
231 if (current === undefined) {
232 const created = yield* discoveryengine
233 .createProjectsLocationsCollectionsDataStoresSchemas({
234 parent: news.dataStore,
235 schemaId,
236 body: {
237 jsonSchema,
238 structSchema: news.structSchema,
239 },
240 })
241 .pipe(
242 Effect.retry({
243 while: (error) => error._tag === "DataStoreNotReady",
244 times: 8,
245 schedule: Schedule.spaced("5 seconds"),
246 }),
247 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
248 );
249 if (created !== undefined) {
250 yield* waitForOperation(created);
251 }
252 current = yield* waitUntilExists(name);
253 }
254
255 if (current === undefined) {
256 return yield* new CollectionsDataStoresSchemaNotResolved({ name });
257 }
258
259 const resource = current.name ?? name;
260 const jsonChanged = (current.jsonSchema ?? "") !== jsonSchema;
261 const structChanged = !sameJson(current.structSchema, news.structSchema);
262
263 if (jsonChanged || structChanged) {
264 const patched =
265 yield* discoveryengine.patchProjectsLocationsCollectionsDataStoresSchemas(
266 {
267 name: resource,
268 body: {
269 name: resource,
270 jsonSchema,
271 structSchema: news.structSchema,
272 },
273 },
274 );
275 yield* waitForOperation(patched);
276 current = yield* waitUntilExists(resource);
277 }
278
279 return toAttrs(current, env.project);
280 }),
281
282 delete: Effect.fn(function* ({ output }) {
283 const existing = yield* getByName(output.name);
284 if (existing === undefined) return;
285 const operation = yield* discoveryengine
286 .deleteProjectsLocationsCollectionsDataStoresSchemas({
287 name: output.name,
288 })
289 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
290 if (operation !== undefined) {
291 yield* waitForOperation(operation, { notFoundOk: true });
292 }
293 yield* waitUntilGone(output.name);
294 }),
295 });