1import * as storage from "@distilled.cloud/gcp/storage_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { DeleteNotConfirmed } from "../Errors.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 listAlchemyBuckets,
13 toFolderName,
14 withTrailingSlash,
15} from "./internal.ts";
16
17export type FolderProps = {
18 /**
19 * Name of the hierarchical-namespace bucket that contains this folder.
20 * Immutable — changing it replaces the folder.
21 */
22 bucketName: string;
23 /**
24 * Folder path. A trailing slash is added if omitted. If omitted
25 * entirely, a unique name is generated from the stack, stage, and
26 * logical id. Immutable — changing it replaces the folder.
27 */
28 folderName?: string;
29};
30
31export type Folder = Resource<
32 "GCP.Storage.Folder",
33 FolderProps,
34 {
35 /** Parent bucket name. */
36 bucketName: string;
37 /** Folder path, including the trailing slash. */
38 folderName: string;
39 /** Server-assigned id (`{bucket}/{folder}`). */
40 id: string | undefined;
41 /** Metadata generation. */
42 metageneration: string | undefined;
43 /** RFC3339 creation timestamp. */
44 createTime: string | undefined;
45 /** RFC3339 last-update timestamp. */
46 updateTime: string | undefined;
47 /** GCS self-link. */
48 selfLink: string | undefined;
49 },
50 never,
51 Providers
52>;
53
54/**
55 * A Cloud Storage folder in a hierarchical-namespace bucket.
56 *
57 * Folders exist only on buckets created with `hierarchicalNamespace:
58 * true` (which also requires uniform bucket-level access). Identity is
59 * `(bucket, folder name)`. Folders have no mutable fields and no labels,
60 * so `list` / `pnpm nuke:gcp` discover them by enumerating
61 * alchemy-labeled buckets.
62 *
63 * ### Creating a Folder
64 * **Example:** Generated name
65 * ```typescript
66 * const bucket = yield* GCP.Storage.Bucket("tree", {
67 * hierarchicalNamespace: true,
68 * uniformBucketLevelAccess: true,
69 * forceDestroy: true,
70 * });
71 * const folder = yield* GCP.Storage.Folder("uploads", {
72 * bucketName: bucket.bucketName,
73 * });
74 * ```
75 *
76 * **Example:** Explicit path
77 * ```typescript
78 * const folder = yield* GCP.Storage.Folder("uploads", {
79 * bucketName: bucket.bucketName,
80 * folderName: "uploads/incoming/",
81 * });
82 * ```
83 *
84 * @resource
85 * @category Storage
86 */
87export const Folder = Resource<Folder>("GCP.Storage.Folder");
88
89export class FolderNotResolved extends Data.TaggedError(
90 "GCP.Storage.FolderNotResolved",
91)<{
92 bucketName: string;
93 folderName: string;
94}> {}
95
96const toAttrs = (folder: storage.Folder, bucketName: string) => ({
97 bucketName: folder.bucket ?? bucketName,
98 folderName: withTrailingSlash(folder.name ?? ""),
99 id: folder.id,
100 metageneration: folder.metageneration,
101 createTime: folder.createTime,
102 updateTime: folder.updateTime,
103 selfLink: folder.selfLink,
104});
105
106const getByName = (bucketName: string, folderName: string) =>
107 storage
108 .getFolders({ bucket: bucketName, folder: folderName })
109 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
110
111const listOnBucket = (bucketName: string) =>
112 storage.listFolders.items({ bucket: bucketName, pageSize: 1000 }).pipe(
113 Stream.runCollect,
114 Effect.map((chunk) => Array.from(chunk)),
115 Effect.catchTag("NotFound", () => Effect.succeed([] as storage.Folder[])),
116 );
117
118const waitUntilGone = (bucketName: string, folderName: string) =>
119 getByName(bucketName, folderName).pipe(
120 Effect.map((existing) =>
121 existing === undefined ? ("gone" as const) : ("found" as const),
122 ),
123 Effect.repeat({
124 schedule: Schedule.spaced("1 second"),
125 until: (status) => status === "gone",
126 times: 30,
127 }),
128 Effect.flatMap((status): Effect.Effect<void, DeleteNotConfirmed> =>
129 status === "gone"
130 ? Effect.void
131 : Effect.fail(
132 new DeleteNotConfirmed({
133 resource: `${bucketName}/folders/${folderName}`,
134 }),
135 ),
136 ),
137 );
138
139export const FolderProvider = () =>
140 Provider.succeed(Folder, {
141 stables: ["bucketName", "folderName", "id", "selfLink", "createTime"],
142
143 diff: Effect.fn(function* ({ news, olds, output }) {
144 if (!isResolved(news)) return undefined;
145 const previousBucket = olds?.bucketName ?? output?.bucketName;
146 if (previousBucket !== undefined && news.bucketName !== previousBucket) {
147 return { action: "replace" as const, deleteFirst: false };
148 }
149 const previousName = olds?.folderName ?? output?.folderName;
150 if (
151 news.folderName !== undefined &&
152 previousName !== undefined &&
153 withTrailingSlash(news.folderName) !== withTrailingSlash(previousName)
154 ) {
155 return { action: "replace" as const, deleteFirst: false };
156 }
157 return undefined;
158 }),
159
160 read: Effect.fn(function* ({ id, olds, output }) {
161 const bucketName = olds?.bucketName ?? output?.bucketName;
162 if (!bucketName) return undefined;
163 const folderName = yield* toFolderName(
164 id,
165 olds?.folderName,
166 output?.folderName,
167 );
168 const existing = yield* getByName(bucketName, folderName);
169 if (existing === undefined) return undefined;
170 return toAttrs(existing, bucketName);
171 }),
172
173 list: () =>
174 Effect.gen(function* () {
175 const buckets = yield* listAlchemyBuckets();
176 const pages = yield* Effect.forEach(
177 buckets,
178 (bucket) => {
179 const bucketName = bucket.name;
180 if (!bucketName || bucket.hierarchicalNamespace?.enabled !== true) {
181 return Effect.succeed([] as Array<Folder["Attributes"]>);
182 }
183 return listOnBucket(bucketName).pipe(
184 Effect.map((items) =>
185 items.map((item) => toAttrs(item, bucketName)),
186 ),
187 );
188 },
189 { concurrency: 8 },
190 );
191 return pages.flat();
192 }),
193
194 reconcile: Effect.fn(function* ({ id, news, output }) {
195 const bucketName = news.bucketName;
196 const folderName = yield* toFolderName(
197 id,
198 news.folderName,
199 output?.folderName,
200 );
201
202 let current = yield* getByName(bucketName, folderName);
203
204 if (current === undefined) {
205 const created = yield* storage
206 .insertFolders({
207 bucket: bucketName,
208 recursive: true,
209 body: { name: folderName },
210 })
211 .pipe(
212 Effect.catchTag("Conflict", () =>
213 getByName(bucketName, folderName),
214 ),
215 );
216 current = created ?? undefined;
217 }
218
219 if (current === undefined || !(current.name ?? current.id)) {
220 return yield* new FolderNotResolved({ bucketName, folderName });
221 }
222
223 return toAttrs(current, bucketName);
224 }),
225
226 delete: Effect.fn(function* ({ output }) {
227 const bucketName = output.bucketName;
228 const folderName = output.folderName;
229 if (!bucketName || !folderName) return;
230 yield* storage
231 .deleteFolders({ bucket: bucketName, folder: folderName })
232 .pipe(Effect.catchTag("NotFound", () => Effect.void));
233 yield* waitUntilGone(bucketName, folderName);
234 }),
235 });