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 fieldMask,
19 fingerprint,
20 listAtLocation,
21 listLabeledPages,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 retryStaleEtag,
28 sameText,
29 stringMap,
30 toPhysicalId,
31 userLabels,
32 waitForDeleteOperation,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36 waitUntilReady,
37} from "./internal.ts";
38
39export type ServiceProps = {
40 /**
41 * Service id (the `{service}` segment of
42 * `projects/{project}/locations/{location}/services/{service}`).
43 * If omitted, a unique RFC1035 name is generated from the stack,
44 * stage, and logical id. Immutable — changing it replaces the
45 * service. Must start with a letter, contain only lowercase letters,
46 * digits, and hyphens, and be at most 63 characters.
47 */
48 serviceId?: string;
49 /**
50 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
51 * replaces the service. `US-CENTRAL1` is accepted and normalized to
52 * `us-central1`.
53 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
54 */
55 location?: string;
56 /**
57 * Human-readable name. 63 character limit.
58 */
59 displayName?: string;
60 /**
61 * User annotations (preserved by external tools).
62 */
63 annotations?: Record<string, string>;
64 /**
65 * User labels. Alchemy ownership labels are merged in automatically.
66 */
67 labels?: Record<string, string>;
68};
69
70export type Service = Resource<
71 "GCP.FirebaseDataConnect.Service",
72 ServiceProps,
73 {
74 /** Full resource name. */
75 name: string;
76 /** Service id (last path segment). */
77 serviceId: string;
78 /** Project id. */
79 project: string;
80 /** Location id. */
81 location: string;
82 /** Human-readable name. */
83 displayName: string | undefined;
84 /** True while Data Connect is applying an LRO. */
85 reconciling: boolean;
86 /** User annotations. */
87 annotations: Record<string, string>;
88 /** User labels (Alchemy ownership labels stripped). */
89 labels: Record<string, string>;
90 /** Server-generated resource uid. */
91 uid: string | undefined;
92 /** RFC3339 creation timestamp. */
93 createTime: string | undefined;
94 /** RFC3339 last-update timestamp. */
95 updateTime: string | undefined;
96 /** Server-computed etag. */
97 etag: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A Firebase Data Connect service — the parent of a GraphQL schema and
105 * connectors.
106 *
107 * Changing `serviceId` or `location` replaces the service. Display
108 * name, labels, and annotations update in place.
109 *
110 * ### Creating a Service
111 * **Example:** Generated name
112 * ```typescript
113 * const service = yield* GCP.FirebaseDataConnect.Service("App", {
114 * labels: { env: "test" },
115 * });
116 * ```
117 *
118 * **Example:** Explicit id
119 * ```typescript
120 * const service = yield* GCP.FirebaseDataConnect.Service("App", {
121 * serviceId: "movies",
122 * displayName: "movie tracker",
123 * labels: { env: "prod" },
124 * });
125 * ```
126 *
127 * ### Updating a Service
128 * **Example:** Display name and labels
129 * ```typescript
130 * const service = yield* GCP.FirebaseDataConnect.Service("App", {
131 * displayName: "movie tracker v2",
132 * labels: { env: "prod", team: "data" },
133 * });
134 * ```
135 *
136 * @resource
137 * @category FirebaseDataConnect
138 */
139export const Service = Resource<Service>("GCP.FirebaseDataConnect.Service");
140
141const resourceName = (project: string, location: string, serviceId: string) =>
142 `projects/${project}/locations/${location}/services/${serviceId}`;
143
144const toAttrs = (
145 item: firebasedataconnect.Service,
146 project: string,
147 region: string,
148): Service["Attributes"] => {
149 const name = item.name ?? "";
150 const parsed = parseName(name, "services", region);
151 return {
152 name,
153 serviceId: parsed.id,
154 project: parsed.project || project,
155 location: parsed.location,
156 displayName: item.displayName,
157 reconciling: item.reconciling === true,
158 annotations: stringMap(item.annotations),
159 labels: userLabels(item.labels),
160 uid: item.uid,
161 createTime: item.createTime,
162 updateTime: item.updateTime,
163 etag: item.etag,
164 };
165};
166
167const getByName = (name: string) =>
168 firebasedataconnect
169 .getProjectsLocationsServices({ name })
170 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
171
172const listOwned = (project: string) =>
173 listAtLocation(project, (parent) =>
174 listLabeledPages(
175 firebasedataconnect.listProjectsLocationsServices.pages({
176 parent,
177 pageSize: 1000,
178 }),
179 (page) => page.services,
180 (item) => item.labels,
181 ),
182 );
183
184export const ServiceProvider = () =>
185 Provider.succeed(Service, {
186 stables: ["name", "serviceId", "project", "location", "uid", "createTime"],
187
188 diff: Effect.fn(function* ({ news, olds, output }) {
189 if (!isResolved(news)) return undefined;
190 const env = yield* GcpEnvironment.current;
191 return replaceOnIdentity({
192 previousId: olds?.serviceId ?? output?.serviceId,
193 nextId: news.serviceId ?? olds?.serviceId ?? output?.serviceId,
194 previousLocation: normalizeLocation(
195 olds?.location ?? output?.location,
196 env.region,
197 ),
198 nextLocation: normalizeLocation(
199 news.location ?? olds?.location ?? output?.location,
200 env.region,
201 ),
202 });
203 }),
204
205 read: Effect.fn(function* ({ id, olds, output }) {
206 const env = yield* GcpEnvironment.current;
207 const serviceId = yield* toPhysicalId(
208 id,
209 olds?.serviceId,
210 output?.serviceId,
211 "service",
212 );
213 const location = normalizeLocation(
214 olds?.location ?? output?.location,
215 env.region,
216 );
217 const name =
218 output?.name ?? resourceName(env.project, location, serviceId);
219 const existing = yield* getByName(name);
220 if (existing === undefined) return undefined;
221 const attrs = toAttrs(existing, env.project, env.region);
222 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
223 ? attrs
224 : Unowned(attrs);
225 }),
226
227 list: () =>
228 Effect.gen(function* () {
229 const env = yield* GcpEnvironment.current;
230 const items = yield* listOwned(env.project);
231 return items.map((item) => toAttrs(item, env.project, env.region));
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const env = yield* GcpEnvironment.current;
236 const serviceId = yield* toPhysicalId(
237 id,
238 news.serviceId,
239 output?.serviceId,
240 "service",
241 );
242 const location = normalizeLocation(
243 news.location ?? output?.location,
244 env.region,
245 );
246 const name = resourceName(env.project, location, serviceId);
247 const desiredLabels = {
248 ...toLabels(news.labels),
249 ...(yield* createInternalLabels(id)),
250 };
251 const desiredAnnotations = news.annotations ?? {};
252
253 let current = yield* getByName(output?.name ?? name);
254
255 if (current === undefined) {
256 const created = yield* firebasedataconnect
257 .createProjectsLocationsServices({
258 parent: parentOf(env.project, location),
259 serviceId,
260 body: {
261 displayName: news.displayName,
262 annotations: desiredAnnotations,
263 labels: desiredLabels,
264 },
265 })
266 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
267 if (created !== undefined) {
268 yield* waitForOperation(created);
269 }
270 current = yield* waitUntilExists(getByName(name), name);
271 }
272
273 if (current === undefined) {
274 return yield* new ResourceNotResolved({ name });
275 }
276
277 const observedLabels = tagRecord(current.labels);
278 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
279 const mask = fieldMask([
280 (upsert.length > 0 || removed.length > 0) && "labels",
281 fingerprint(stringMap(current.annotations)) !==
282 fingerprint(desiredAnnotations) && "annotations",
283 !sameText(current.displayName, news.displayName) && "displayName",
284 ]);
285
286 if (mask.length > 0) {
287 const serviceName = current.name ?? name;
288 // Child schema/connector updates bump the service etag while this
289 // patch is in flight: re-read the etag on each attempt.
290 yield* retryStaleEtag(
291 Effect.gen(function* () {
292 const latest = yield* getByName(serviceName);
293 const operation =
294 yield* firebasedataconnect.patchProjectsLocationsServices({
295 name: serviceName,
296 updateMask: mask,
297 body: {
298 etag: latest?.etag,
299 displayName: news.displayName,
300 annotations: desiredAnnotations,
301 labels: desiredLabels,
302 },
303 });
304 yield* waitForOperation(operation);
305 }),
306 );
307 current = yield* waitUntilExists(
308 getByName(current.name ?? name),
309 current.name ?? name,
310 );
311 }
312
313 current = yield* waitUntilReady(
314 getByName(current.name ?? name),
315 current.name ?? name,
316 );
317 return toAttrs(current, env.project, env.region);
318 }),
319
320 delete: Effect.fn(function* ({ output }) {
321 const operation = yield* firebasedataconnect
322 .deleteProjectsLocationsServices({
323 name: output.name,
324 force: true,
325 })
326 .pipe(
327 Effect.retry({
328 while: (error) => error._tag === "Conflict",
329 times: 8,
330 schedule: Schedule.spaced("2 seconds"),
331 }),
332 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
333 );
334 if (operation !== undefined) {
335 yield* waitForDeleteOperation(operation);
336 }
337 yield* waitUntilGone(getByName(output.name), output.name);
338 }),
339 });