1import * as firebaserules from "@distilled.cloud/gcp/firebaserules_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 expandRulesetName,
12 getRelease,
13 isGeneratedReleaseId,
14 listReleases,
15 projectParent,
16 releaseIdOf,
17 releaseResourceName,
18 sameText,
19 toReleaseId,
20} from "./internal.ts";
21
22export type ReleaseProps = {
23 /**
24 * Release id (the `{release_id}` segment of
25 * `projects/{project}/releases/{release_id}`). May contain `/` (e.g.
26 * `cloud.firestore` or `prod/v23`). If omitted, a unique `alc-`
27 * prefixed name is generated from the stack, stage, and logical id.
28 * Firebase Rules releases have no labels field, so generated ids carry
29 * the `alc-` prefix for `list` / nuke. Immutable — changing it
30 * replaces the release.
31 */
32 releaseId?: string;
33 /**
34 * Ruleset this release points at
35 * (`projects/{project}/rulesets/{rulesetId}` or a bare ruleset id).
36 * The ruleset must exist. Updating this patches the release in place.
37 */
38 rulesetName: string;
39};
40
41export type Release = Resource<
42 "GCP.FirebaseRules.Release",
43 ReleaseProps,
44 {
45 /** Full resource name `projects/{project}/releases/{releaseId}`. */
46 name: string;
47 /** Release id (path after `/releases/`). */
48 releaseId: string;
49 /** Project id. */
50 project: string;
51 /** Full ruleset resource name this release refers to. */
52 rulesetName: string;
53 /** RFC3339 creation timestamp. */
54 createTime: string | undefined;
55 /** RFC3339 last-update timestamp. */
56 updateTime: string | undefined;
57 },
58 never,
59 Providers
60>;
61
62/**
63 * A named Firebase Security Rules release pointing at a `Ruleset`.
64 *
65 * Releases have no labels field. Generated release ids are prefixed
66 * `alc-` so `list` / nuke can find them. `releaseId` is identity —
67 * changing it replaces the release. `rulesetName` updates in place.
68 *
69 * ### Creating a Release
70 * **Example:** Generated id
71 * ```typescript
72 * const ruleset = yield* GCP.FirebaseRules.Ruleset("Firestore", {
73 * source: {
74 * files: [
75 * {
76 * name: "firestore.rules",
77 * content: "rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if false; } } }",
78 * },
79 * ],
80 * },
81 * });
82 * const release = yield* GCP.FirebaseRules.Release("Live", {
83 * rulesetName: ruleset.name,
84 * });
85 * ```
86 *
87 * **Example:** Named release
88 * ```typescript
89 * const release = yield* GCP.FirebaseRules.Release("Live", {
90 * releaseId: "prod",
91 * rulesetName: ruleset.name,
92 * });
93 * ```
94 *
95 * ### Updating a Release
96 * **Example:** Point at a new ruleset
97 * ```typescript
98 * const release = yield* GCP.FirebaseRules.Release("Live", {
99 * rulesetName: nextRuleset.name,
100 * });
101 * ```
102 *
103 * @resource
104 * @category FirebaseRules
105 */
106export const Release = Resource<Release>("GCP.FirebaseRules.Release");
107
108export class ReleaseNotResolved extends Data.TaggedError(
109 "GCP.FirebaseRules.ReleaseNotResolved",
110)<{
111 name: string;
112}> {}
113
114const toAttrs = (release: firebaserules.Release, project: string) => {
115 const name = release.name ?? "";
116 return {
117 name,
118 releaseId: releaseIdOf(name),
119 project,
120 rulesetName: release.rulesetName ?? "",
121 createTime: release.createTime,
122 updateTime: release.updateTime,
123 };
124};
125
126export const ReleaseProvider = () =>
127 Provider.succeed(Release, {
128 stables: ["name", "releaseId", "project", "createTime"],
129
130 diff: Effect.fn(function* ({ news, olds, output }) {
131 if (!isResolved(news)) return undefined;
132 const previous = olds?.releaseId ?? output?.releaseId;
133 if (
134 news.releaseId !== undefined &&
135 previous !== undefined &&
136 news.releaseId !== previous
137 ) {
138 return { action: "replace" as const, deleteFirst: false };
139 }
140 return undefined;
141 }),
142
143 read: Effect.fn(function* ({ id, olds, output }) {
144 const env = yield* GcpEnvironment.current;
145 const releaseId = yield* toReleaseId(
146 id,
147 olds?.releaseId,
148 output?.releaseId,
149 );
150 const name = output?.name ?? releaseResourceName(env.project, releaseId);
151 const existing = yield* getRelease(name);
152 if (existing === undefined) return undefined;
153 const attrs = toAttrs(existing, env.project);
154 if (
155 isGeneratedReleaseId(attrs.releaseId) ||
156 output?.name === attrs.name
157 ) {
158 return attrs;
159 }
160 return Unowned(attrs);
161 }),
162
163 list: () =>
164 Effect.gen(function* () {
165 const env = yield* GcpEnvironment.current;
166 const releases = yield* listReleases(env.project);
167 return releases
168 .filter((release) =>
169 isGeneratedReleaseId(releaseIdOf(release.name ?? "")),
170 )
171 .map((release) => toAttrs(release, env.project));
172 }),
173
174 reconcile: Effect.fn(function* ({ id, news, output }) {
175 const env = yield* GcpEnvironment.current;
176 const parent = projectParent(env.project);
177 const releaseId = yield* toReleaseId(
178 id,
179 news.releaseId,
180 output?.releaseId,
181 );
182 const name = releaseResourceName(env.project, releaseId);
183 const desiredRuleset = expandRulesetName(env.project, news.rulesetName);
184
185 let current = yield* getRelease(output?.name ?? name);
186
187 if (current === undefined) {
188 const created = yield* firebaserules
189 .createProjectsReleases({
190 name: parent,
191 body: {
192 name,
193 rulesetName: desiredRuleset,
194 },
195 })
196 .pipe(Effect.catchTag("Conflict", () => getRelease(name)));
197 current = created ?? undefined;
198 }
199
200 if (current === undefined) {
201 return yield* new ReleaseNotResolved({ name });
202 }
203
204 if (!sameText(current.rulesetName, desiredRuleset)) {
205 current = yield* firebaserules.patchProjectsReleases({
206 name: current.name ?? name,
207 body: {
208 release: {
209 name: current.name ?? name,
210 rulesetName: desiredRuleset,
211 },
212 updateMask: "rulesetName",
213 },
214 });
215 }
216
217 return toAttrs(current, env.project);
218 }),
219
220 delete: Effect.fn(function* ({ output }) {
221 if (!output.name) return;
222 yield* firebaserules
223 .deleteProjectsReleases({ name: output.name })
224 .pipe(Effect.catchTag("NotFound", () => Effect.void));
225 }),
226 });