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