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 DEFAULT_SCHEMA_ID,
19 expandParent,
20 fieldMask,
21 fingerprint,
22 listAtNested,
23 listLabeledPages,
24 normalizeLocation,
25 parseName,
26 replaceOnIdentity,
27 ResourceNotResolved,
28 retryStaleEtag,
29 sameText,
30 stringMap,
31 userLabels,
32 waitForDeleteOperation,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36 waitUntilReady,
37} from "./internal.ts";
38
39export type File = {
40 /** File contents (GraphQL SDL). */
41 content?: string;
42 /**
43 * Path relative to the Data Connect workspace, for example
44 * `schema.gql`.
45 */
46 path?: string;
47};
48
49export type Source = {
50 /** GraphQL source files that make up the schema. */
51 files?: File[];
52};
53
54export type CloudSqlInstance = {
55 /**
56 * Cloud SQL instance resource name
57 * `projects/{project}/locations/{location}/instances/{instance}`.
58 */
59 instance?: string;
60};
61
62export type HttpGraphql = {
63 /** HTTP GraphQL endpoint URI. */
64 uri?: string;
65 /** Request timeout, for example `"3.5s"`. */
66 timeout?: string;
67};
68
69export type PostgreSql = {
70 /** Cloud SQL instance used as the Postgres backend. */
71 cloudSql?: CloudSqlInstance;
72 /**
73 * When true, no Postgres instance is linked. Do not set `database`
74 * or `schemaValidation` together with `unlinked`.
75 */
76 unlinked?: boolean;
77 /** PostgreSQL database name. */
78 database?: string;
79 /**
80 * How much PostgreSQL schema validation to run before deploying
81 * (`NONE`, `STRICT`, `COMPATIBLE`).
82 */
83 schemaValidation?:
84 | firebasedataconnect.PostgreSqlSchemaValidationEnum
85 | (string & {});
86 /**
87 * Additive-only PostgreSQL schema migration before deploy
88 * (`MIGRATE_COMPATIBLE`).
89 */
90 schemaMigration?:
91 | firebasedataconnect.PostgreSqlSchemaMigrationEnum
92 | (string & {});
93 /**
94 * PostgreSQL schema name.
95 * @default "public"
96 */
97 schema?: string;
98};
99
100export type Datasource = {
101 /** HTTP GraphQL webhook backend. */
102 httpGraphql?: HttpGraphql;
103 /** PostgreSQL (Cloud SQL or unlinked) backend. */
104 postgresql?: PostgreSql;
105};
106
107export type ServicesSchemaProps = {
108 /**
109 * Parent Data Connect service. Full name
110 * `projects/{project}/locations/{location}/services/{service}` or the
111 * service id (combined with `location`). Immutable — changing it
112 * replaces the schema.
113 */
114 service: string;
115 /**
116 * Region used when `service` is a bare id.
117 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
118 */
119 location?: string;
120 /**
121 * Schema id. The API currently only accepts `main`. Immutable —
122 * changing it replaces the schema.
123 * @default "main"
124 */
125 schemaId?: string;
126 /**
127 * GraphQL SDL source files. Required.
128 */
129 source: Source;
130 /**
131 * Data sources linked by this schema. Required. Use
132 * `{ postgresql: { unlinked: true } }` when no Cloud SQL instance is
133 * attached, or `{ postgresql: { database, cloudSql } }` to link one.
134 */
135 datasources: Datasource[];
136 /**
137 * Human-readable name. 63 character limit.
138 */
139 displayName?: string;
140 /**
141 * User annotations (preserved by external tools).
142 */
143 annotations?: Record<string, string>;
144 /**
145 * User labels. Alchemy ownership labels are merged in automatically.
146 */
147 labels?: Record<string, string>;
148};
149
150export type ServicesSchema = Resource<
151 "GCP.FirebaseDataConnect.ServicesSchema",
152 ServicesSchemaProps,
153 {
154 /** Full resource name. */
155 name: string;
156 /** Schema id (`main`). */
157 schemaId: string;
158 /** Parent service name. */
159 service: string;
160 /** Project id. */
161 project: string;
162 /** Location id. */
163 location: string;
164 /** GraphQL source files. */
165 source: Source | undefined;
166 /** Linked data sources. */
167 datasources: Datasource[] | undefined;
168 /** Human-readable name. */
169 displayName: string | undefined;
170 /** True while Data Connect is compiling or deploying the schema. */
171 reconciling: boolean;
172 /** True when the Postgres backend is ephemeral in-memory storage. */
173 ephemeral: boolean;
174 /** User annotations. */
175 annotations: Record<string, string>;
176 /** User labels (Alchemy ownership labels stripped). */
177 labels: Record<string, string>;
178 /** Server-generated resource uid. */
179 uid: string | undefined;
180 /** RFC3339 creation timestamp. */
181 createTime: string | undefined;
182 /** RFC3339 last-update timestamp. */
183 updateTime: string | undefined;
184 /** Server-computed etag. */
185 etag: string | undefined;
186 },
187 never,
188 Providers
189>;
190
191/**
192 * The application schema of a Firebase Data Connect service. Only
193 * `schemas/main` is supported — one schema per service.
194 *
195 * Changing `service`, `location`, or `schemaId` replaces the schema.
196 * Source, datasources, display name, labels, and annotations update in
197 * place. Delete fails while connectors still exist; destroy connectors
198 * first (or delete the parent service with `force`).
199 *
200 * ### Creating a Schema
201 * **Example:** Unlinked Postgres schema
202 * ```typescript
203 * const service = yield* GCP.FirebaseDataConnect.Service("App", {});
204 * const schema = yield* GCP.FirebaseDataConnect.ServicesSchema("Main", {
205 * service: service.name,
206 * source: {
207 * files: [{
208 * path: "schema.gql",
209 * content: "type Note @table { title: String! }",
210 * }],
211 * },
212 * datasources: [{ postgresql: { unlinked: true } }],
213 * labels: { env: "test" },
214 * });
215 * ```
216 *
217 * **Example:** Cloud SQL Postgres
218 * ```typescript
219 * const schema = yield* GCP.FirebaseDataConnect.ServicesSchema("Main", {
220 * service: service.name,
221 * source: {
222 * files: [{
223 * path: "schema.gql",
224 * content: "type Movie @table { title: String! }",
225 * }],
226 * },
227 * datasources: [{
228 * postgresql: {
229 * database: "fdcdb",
230 * cloudSql: { instance: sqlInstance.name },
231 * schemaValidation: "NONE",
232 * schemaMigration: "MIGRATE_COMPATIBLE",
233 * },
234 * }],
235 * });
236 * ```
237 *
238 * ### Updating a Schema
239 * **Example:** New GraphQL types
240 * ```typescript
241 * const schema = yield* GCP.FirebaseDataConnect.ServicesSchema("Main", {
242 * service: service.name,
243 * source: {
244 * files: [{
245 * path: "schema.gql",
246 * content: "type Note @table { title: String! body: String }",
247 * }],
248 * },
249 * datasources: [{ postgresql: { unlinked: true } }],
250 * displayName: "notes v2",
251 * });
252 * ```
253 *
254 * @resource
255 * @category FirebaseDataConnect
256 */
257export const ServicesSchema = Resource<ServicesSchema>(
258 "GCP.FirebaseDataConnect.ServicesSchema",
259);
260
261const resourceName = (service: string, schemaId: string) =>
262 `${service}/schemas/${schemaId}`;
263
264const toFile = (value: firebasedataconnect.File): File => ({
265 content: value.content,
266 path: value.path,
267});
268
269const toSource = (
270 value: firebasedataconnect.Source | undefined,
271): Source | undefined =>
272 value === undefined ? undefined : { files: value.files?.map(toFile) };
273
274const toDatasource = (value: firebasedataconnect.Datasource): Datasource => ({
275 httpGraphql: value.httpGraphql
276 ? { uri: value.httpGraphql.uri, timeout: value.httpGraphql.timeout }
277 : undefined,
278 postgresql: value.postgresql
279 ? {
280 cloudSql: value.postgresql.cloudSql
281 ? { instance: value.postgresql.cloudSql.instance }
282 : undefined,
283 unlinked: value.postgresql.unlinked,
284 database: value.postgresql.database,
285 schemaValidation: value.postgresql.schemaValidation,
286 schemaMigration: value.postgresql.schemaMigration,
287 schema: value.postgresql.schema,
288 }
289 : undefined,
290});
291
292const toAttrs = (
293 item: firebasedataconnect.Firebasedataconnect_Schema,
294 project: string,
295 region: string,
296): ServicesSchema["Attributes"] => {
297 const name = item.name ?? "";
298 const parsed = parseName(name, "schemas", region);
299 return {
300 name,
301 schemaId: parsed.id,
302 service: parsed.parent,
303 project: parsed.project || project,
304 location: parsed.location,
305 source: toSource(item.source),
306 datasources: item.datasources?.map(toDatasource),
307 displayName: item.displayName,
308 reconciling: item.reconciling === true,
309 ephemeral:
310 item.datasources?.some((ds) => ds.postgresql?.ephemeral === true) ===
311 true,
312 annotations: stringMap(item.annotations),
313 labels: userLabels(item.labels),
314 uid: item.uid,
315 createTime: item.createTime,
316 updateTime: item.updateTime,
317 etag: item.etag,
318 };
319};
320
321const getByName = (name: string) =>
322 firebasedataconnect
323 .getProjectsLocationsServicesSchemas({ name })
324 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
325
326const listOwned = (project: string) =>
327 listAtNested(project, "services/-", (parent) =>
328 listLabeledPages(
329 firebasedataconnect.listProjectsLocationsServicesSchemas.pages({
330 parent,
331 pageSize: 1000,
332 }),
333 (page) => page.schemas,
334 (item) => item.labels,
335 ),
336 );
337
338const schemaIdOf = (value: string | undefined) =>
339 value === undefined || value.length === 0 ? DEFAULT_SCHEMA_ID : value;
340
341export const ServicesSchemaProvider = () =>
342 Provider.succeed(ServicesSchema, {
343 stables: [
344 "name",
345 "schemaId",
346 "service",
347 "project",
348 "location",
349 "uid",
350 "createTime",
351 ],
352
353 diff: Effect.fn(function* ({ news, olds, output }) {
354 if (!isResolved(news)) return undefined;
355 const env = yield* GcpEnvironment.current;
356 const location = normalizeLocation(
357 news.location ?? olds?.location ?? output?.location,
358 env.region,
359 );
360 const previousLocation = normalizeLocation(
361 olds?.location ?? output?.location,
362 env.region,
363 );
364 const previousParent =
365 (olds?.service ?? output?.service)
366 ? expandParent(
367 olds?.service ?? output?.service ?? "",
368 env.project,
369 previousLocation,
370 "services",
371 )
372 : undefined;
373 const nextParent = expandParent(
374 news.service,
375 env.project,
376 location,
377 "services",
378 );
379 return replaceOnIdentity({
380 previousId: olds?.schemaId ?? output?.schemaId,
381 nextId: schemaIdOf(news.schemaId ?? olds?.schemaId ?? output?.schemaId),
382 previousLocation,
383 nextLocation: location,
384 previousParent,
385 nextParent,
386 });
387 }),
388
389 read: Effect.fn(function* ({ id, olds, output }) {
390 const env = yield* GcpEnvironment.current;
391 const schemaId = schemaIdOf(olds?.schemaId ?? output?.schemaId);
392 const location = normalizeLocation(
393 olds?.location ?? output?.location,
394 env.region,
395 );
396 const service =
397 output?.service ??
398 (olds?.service
399 ? expandParent(olds.service, env.project, location, "services")
400 : undefined);
401 const name =
402 output?.name ?? (service ? resourceName(service, schemaId) : undefined);
403 if (name === undefined) return undefined;
404 const existing = yield* getByName(name);
405 if (existing === undefined) return undefined;
406 const attrs = toAttrs(existing, env.project, env.region);
407 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
408 ? attrs
409 : Unowned(attrs);
410 }),
411
412 list: () =>
413 Effect.gen(function* () {
414 const env = yield* GcpEnvironment.current;
415 const items = yield* listOwned(env.project);
416 return items.map((item) => toAttrs(item, env.project, env.region));
417 }),
418
419 reconcile: Effect.fn(function* ({ id, news, output }) {
420 const env = yield* GcpEnvironment.current;
421 const schemaId = schemaIdOf(news.schemaId ?? output?.schemaId);
422 const location = normalizeLocation(
423 news.location ?? output?.location,
424 env.region,
425 );
426 const service = expandParent(
427 news.service,
428 env.project,
429 location,
430 "services",
431 );
432 const name = resourceName(service, schemaId);
433 const desiredLabels = {
434 ...toLabels(news.labels),
435 ...(yield* createInternalLabels(id)),
436 };
437 const desiredAnnotations = news.annotations ?? {};
438 const body: firebasedataconnect.Firebasedataconnect_Schema = {
439 source: news.source,
440 datasources: news.datasources,
441 displayName: news.displayName,
442 annotations: desiredAnnotations,
443 labels: desiredLabels,
444 };
445
446 let current = yield* getByName(output?.name ?? name);
447
448 if (current === undefined) {
449 const created = yield* firebasedataconnect
450 .createProjectsLocationsServicesSchemas({
451 parent: service,
452 schemaId,
453 body,
454 })
455 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
456 if (created !== undefined) {
457 yield* waitForOperation(created);
458 }
459 current = yield* waitUntilExists(getByName(name), name);
460 }
461
462 if (current === undefined) {
463 return yield* new ResourceNotResolved({ name });
464 }
465
466 const observedLabels = tagRecord(current.labels);
467 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
468 const mask = fieldMask([
469 (upsert.length > 0 || removed.length > 0) && "labels",
470 fingerprint(stringMap(current.annotations)) !==
471 fingerprint(desiredAnnotations) && "annotations",
472 !sameText(current.displayName, news.displayName) && "displayName",
473 fingerprint(toSource(current.source)) !== fingerprint(news.source) &&
474 "source",
475 fingerprint(current.datasources?.map(toDatasource)) !==
476 fingerprint(news.datasources) && "datasources",
477 ]);
478
479 if (mask.length > 0) {
480 const patchName = current.name ?? name;
481 yield* retryStaleEtag(
482 firebasedataconnect
483 .patchProjectsLocationsServicesSchemas({
484 name: patchName,
485 updateMask: mask,
486 body,
487 })
488 .pipe(Effect.flatMap((operation) => waitForOperation(operation))),
489 );
490 current = yield* waitUntilExists(
491 getByName(current.name ?? name),
492 current.name ?? name,
493 );
494 }
495
496 current = yield* waitUntilReady(
497 getByName(current.name ?? name),
498 current.name ?? name,
499 );
500 return toAttrs(current, env.project, env.region);
501 }),
502
503 delete: Effect.fn(function* ({ output }) {
504 const operation = yield* firebasedataconnect
505 .deleteProjectsLocationsServicesSchemas({
506 name: output.name,
507 force: true,
508 })
509 .pipe(
510 Effect.retry({
511 while: (error) => error._tag === "Conflict",
512 times: 8,
513 schedule: Schedule.spaced("2 seconds"),
514 }),
515 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
516 );
517 if (operation !== undefined) {
518 yield* waitForDeleteOperation(operation);
519 }
520 yield* waitUntilGone(getByName(output.name), output.name);
521 }),
522 });