1import * as firebasedataconnect from "@distilled.cloud/gcp/firebasedataconnect_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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 expandParent,
19 fieldMask,
20 fingerprint,
21 listAtNested,
22 listLabeledPages,
23 MAX_CONNECTOR_ID_LENGTH,
24 normalizeLocation,
25 parseName,
26 replaceOnIdentity,
27 ResourceNotResolved,
28 retryStaleEtag,
29 sameText,
30 stringMap,
31 toPhysicalId,
32 userLabels,
33 waitForDeleteOperation,
34 waitForOperation,
35 waitUntilExists,
36 waitUntilGone,
37 waitUntilReady,
38} from "./internal.ts";
39import type { Source } from "./ServicesSchema.ts";
40
41export type ClientCache = {
42 /**
43 * When true, connector source is validated for client-side caching
44 * (stricter aliases, etc.).
45 */
46 strictValidationEnabled?: boolean;
47 /**
48 * When true, responses include entityIds in GraphQL extensions for
49 * normalized client caching. Only enable when primary keys are not
50 * sensitive.
51 */
52 entityIdIncluded?: boolean;
53};
54
55export type ServicesConnectorProps = {
56 /**
57 * Parent Data Connect service. Full name
58 * `projects/{project}/locations/{location}/services/{service}` or the
59 * service id (combined with `location`). Immutable — changing it
60 * replaces the connector. The service must already have an active
61 * schema compatible with `source`.
62 */
63 service: string;
64 /**
65 * Region used when `service` is a bare id.
66 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
67 */
68 location?: string;
69 /**
70 * Connector id (the `{connector}` segment). If omitted, a unique
71 * RFC1035 name is generated from the stack, stage, and logical id.
72 * Immutable — changing it replaces the connector.
73 */
74 connectorId?: string;
75 /**
76 * GraphQL operations (queries and mutations) that make up the
77 * connector. Must be compatible with the active schema.
78 */
79 source: Source;
80 /**
81 * Client-side cache settings.
82 */
83 clientCache?: ClientCache;
84 /**
85 * Human-readable name. 63 character limit.
86 */
87 displayName?: string;
88 /**
89 * User annotations (preserved by external tools).
90 */
91 annotations?: Record<string, string>;
92 /**
93 * User labels. Alchemy ownership labels are merged in automatically.
94 */
95 labels?: Record<string, string>;
96};
97
98export type ServicesConnector = Resource<
99 "GCP.FirebaseDataConnect.ServicesConnector",
100 ServicesConnectorProps,
101 {
102 /** Full resource name. */
103 name: string;
104 /** Connector id (last path segment). */
105 connectorId: string;
106 /** Parent service name. */
107 service: string;
108 /** Project id. */
109 project: string;
110 /** Location id. */
111 location: string;
112 /** GraphQL operation source files. */
113 source: Source | undefined;
114 /** Client-side cache settings. */
115 clientCache: ClientCache | undefined;
116 /** Human-readable name. */
117 displayName: string | undefined;
118 /** True while Data Connect is compiling or deploying the connector. */
119 reconciling: boolean;
120 /** User annotations. */
121 annotations: Record<string, string>;
122 /** User labels (Alchemy ownership labels stripped). */
123 labels: Record<string, string>;
124 /** Server-generated resource uid. */
125 uid: string | undefined;
126 /** RFC3339 creation timestamp. */
127 createTime: string | undefined;
128 /** RFC3339 last-update timestamp. */
129 updateTime: string | undefined;
130 /** Server-computed etag. */
131 etag: string | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A Firebase Data Connect connector — a set of GraphQL queries and
139 * mutations compiled against the service's active schema.
140 *
141 * Changing `connectorId`, `service`, or `location` replaces the
142 * connector. Source, client cache, display name, labels, and
143 * annotations update in place. Create fails if the service has no
144 * schema or the operations are incompatible with it.
145 *
146 * ### Creating a Connector
147 * **Example:** Public query
148 * ```typescript
149 * const schema = yield* GCP.FirebaseDataConnect.ServicesSchema("Main", {
150 * service: service.name,
151 * source: {
152 * files: [{
153 * path: "schema.gql",
154 * content: "type Note @table { title: String! }",
155 * }],
156 * },
157 * datasources: [{ postgresql: { unlinked: true } }],
158 * });
159 * const connector = yield* GCP.FirebaseDataConnect.ServicesConnector(
160 * "Queries",
161 * {
162 * service: schema.service,
163 * source: {
164 * files: [{
165 * path: "queries.gql",
166 * content:
167 * "query ListNotes @auth(level: PUBLIC) { notes { id title } }",
168 * }],
169 * },
170 * labels: { env: "test" },
171 * },
172 * );
173 * ```
174 *
175 * ### Updating a Connector
176 * **Example:** Display name and labels
177 * ```typescript
178 * const connector = yield* GCP.FirebaseDataConnect.ServicesConnector(
179 * "Queries",
180 * {
181 * service: schema.service,
182 * source: {
183 * files: [{
184 * path: "queries.gql",
185 * content:
186 * "query ListNotes @auth(level: PUBLIC) { notes { id title } }",
187 * }],
188 * },
189 * displayName: "notes queries v2",
190 * labels: { env: "prod" },
191 * },
192 * );
193 * ```
194 *
195 * @resource
196 * @category FirebaseDataConnect
197 */
198export const ServicesConnector = Resource<ServicesConnector>(
199 "GCP.FirebaseDataConnect.ServicesConnector",
200);
201
202const resourceName = (service: string, connectorId: string) =>
203 `${service}/connectors/${connectorId}`;
204
205const toFile = (value: firebasedataconnect.File) => ({
206 content: value.content,
207 path: value.path,
208});
209
210const toSource = (
211 value: firebasedataconnect.Source | undefined,
212): Source | undefined =>
213 value === undefined ? undefined : { files: value.files?.map(toFile) };
214
215const toClientCache = (
216 value: firebasedataconnect.ClientCache | undefined,
217): ClientCache | undefined =>
218 value === undefined
219 ? undefined
220 : {
221 strictValidationEnabled: value.strictValidationEnabled,
222 entityIdIncluded: value.entityIdIncluded,
223 };
224
225const toAttrs = (
226 item: firebasedataconnect.Connector,
227 project: string,
228 region: string,
229): ServicesConnector["Attributes"] => {
230 const name = item.name ?? "";
231 const parsed = parseName(name, "connectors", region);
232 return {
233 name,
234 connectorId: parsed.id,
235 service: parsed.parent,
236 project: parsed.project || project,
237 location: parsed.location,
238 source: toSource(item.source),
239 clientCache: toClientCache(item.clientCache),
240 displayName: item.displayName,
241 reconciling: item.reconciling === true,
242 annotations: stringMap(item.annotations),
243 labels: userLabels(item.labels),
244 uid: item.uid,
245 createTime: item.createTime,
246 updateTime: item.updateTime,
247 etag: item.etag,
248 };
249};
250
251const getByName = (name: string) =>
252 firebasedataconnect
253 .getProjectsLocationsServicesConnectors({ name })
254 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
255
256const listOwned = (project: string) =>
257 listAtNested(project, "services/-", (parent) =>
258 listLabeledPages(
259 firebasedataconnect.listProjectsLocationsServicesConnectors.pages({
260 parent,
261 pageSize: 1000,
262 }),
263 (page) => page.connectors,
264 (item) => item.labels,
265 ),
266 );
267
268export const ServicesConnectorProvider = () =>
269 Provider.succeed(ServicesConnector, {
270 stables: [
271 "name",
272 "connectorId",
273 "service",
274 "project",
275 "location",
276 "uid",
277 "createTime",
278 ],
279
280 diff: Effect.fn(function* ({ news, olds, output }) {
281 if (!isResolved(news)) return undefined;
282 const env = yield* GcpEnvironment.current;
283 const location = normalizeLocation(
284 news.location ?? olds?.location ?? output?.location,
285 env.region,
286 );
287 const previousLocation = normalizeLocation(
288 olds?.location ?? output?.location,
289 env.region,
290 );
291 const previousParent =
292 (olds?.service ?? output?.service)
293 ? expandParent(
294 olds?.service ?? output?.service ?? "",
295 env.project,
296 previousLocation,
297 "services",
298 )
299 : undefined;
300 const nextParent = expandParent(
301 news.service,
302 env.project,
303 location,
304 "services",
305 );
306 return replaceOnIdentity({
307 previousId: olds?.connectorId ?? output?.connectorId,
308 nextId: news.connectorId ?? olds?.connectorId ?? output?.connectorId,
309 previousLocation,
310 nextLocation: location,
311 previousParent,
312 nextParent,
313 });
314 }),
315
316 read: Effect.fn(function* ({ id, olds, output }) {
317 const env = yield* GcpEnvironment.current;
318 const connectorId = yield* toPhysicalId(
319 id,
320 olds?.connectorId,
321 output?.connectorId,
322 "connector",
323 MAX_CONNECTOR_ID_LENGTH,
324 );
325 const location = normalizeLocation(
326 olds?.location ?? output?.location,
327 env.region,
328 );
329 const service =
330 output?.service ??
331 (olds?.service
332 ? expandParent(olds.service, env.project, location, "services")
333 : undefined);
334 const name =
335 output?.name ??
336 (service ? resourceName(service, connectorId) : undefined);
337 if (name === undefined) return undefined;
338 const existing = yield* getByName(name);
339 if (existing === undefined) return undefined;
340 const attrs = toAttrs(existing, env.project, env.region);
341 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
342 ? attrs
343 : Unowned(attrs);
344 }),
345
346 list: () =>
347 Effect.gen(function* () {
348 const env = yield* GcpEnvironment.current;
349 const items = yield* listOwned(env.project);
350 return items.map((item) => toAttrs(item, env.project, env.region));
351 }),
352
353 reconcile: Effect.fn(function* ({ id, news, output }) {
354 const env = yield* GcpEnvironment.current;
355 const connectorId = yield* toPhysicalId(
356 id,
357 news.connectorId,
358 output?.connectorId,
359 "connector",
360 MAX_CONNECTOR_ID_LENGTH,
361 );
362 const location = normalizeLocation(
363 news.location ?? output?.location,
364 env.region,
365 );
366 const service = expandParent(
367 news.service,
368 env.project,
369 location,
370 "services",
371 );
372 const name = resourceName(service, connectorId);
373 const desiredLabels = {
374 ...toLabels(news.labels),
375 ...(yield* createInternalLabels(id)),
376 };
377 const desiredAnnotations = news.annotations ?? {};
378 const body: firebasedataconnect.Connector = {
379 source: news.source,
380 clientCache: news.clientCache,
381 displayName: news.displayName,
382 annotations: desiredAnnotations,
383 labels: desiredLabels,
384 };
385
386 let current = yield* getByName(output?.name ?? name);
387
388 if (current === undefined) {
389 const created = yield* firebasedataconnect
390 .createProjectsLocationsServicesConnectors({
391 parent: service,
392 connectorId,
393 body,
394 })
395 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
396 if (created !== undefined) {
397 yield* waitForOperation(created);
398 }
399 current = yield* waitUntilExists(getByName(name), name);
400 }
401
402 if (current === undefined) {
403 return yield* new ResourceNotResolved({ name });
404 }
405
406 const observedLabels = tagRecord(current.labels);
407 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
408 const mask = fieldMask([
409 (upsert.length > 0 || removed.length > 0) && "labels",
410 fingerprint(stringMap(current.annotations)) !==
411 fingerprint(desiredAnnotations) && "annotations",
412 !sameText(current.displayName, news.displayName) && "displayName",
413 fingerprint(toSource(current.source)) !== fingerprint(news.source) &&
414 "source",
415 fingerprint(toClientCache(current.clientCache)) !==
416 fingerprint(news.clientCache) && "clientCache",
417 ]);
418
419 if (mask.length > 0) {
420 const patchName = current.name ?? name;
421 yield* retryStaleEtag(
422 firebasedataconnect
423 .patchProjectsLocationsServicesConnectors({
424 name: patchName,
425 updateMask: mask,
426 body,
427 })
428 .pipe(Effect.flatMap((operation) => waitForOperation(operation))),
429 );
430 current = yield* waitUntilExists(
431 getByName(current.name ?? name),
432 current.name ?? name,
433 );
434 }
435
436 current = yield* waitUntilReady(
437 getByName(current.name ?? name),
438 current.name ?? name,
439 );
440 return toAttrs(current, env.project, env.region);
441 }),
442
443 delete: Effect.fn(function* ({ output }) {
444 const operation = yield* firebasedataconnect
445 .deleteProjectsLocationsServicesConnectors({
446 name: output.name,
447 force: true,
448 })
449 .pipe(
450 Effect.retry({
451 while: (error) => error._tag === "Conflict",
452 times: 8,
453 schedule: Schedule.spaced("2 seconds"),
454 }),
455 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
456 );
457 if (operation !== undefined) {
458 yield* waitForDeleteOperation(operation);
459 }
460 yield* waitUntilGone(getByName(output.name), output.name);
461 }),
462 });