1import * as resourceSharing from "@distilled.cloud/cloudflare/resource-sharing";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import type { ShareableResourceType, ShareStatus } from "./Share.ts";
12
13const TypeId = "Cloudflare.ResourceSharing.ShareResource" as const;
14type TypeId = typeof TypeId;
15
16export type ShareResourceProps = {
17 /**
18 * The share this resource belongs to. Changing the share triggers a
19 * replacement.
20 */
21 shareId: string;
22 /**
23 * Type of the shared resource (e.g. `gateway-policy`). Changing the type
24 * triggers a replacement.
25 */
26 resourceType: ShareableResourceType;
27 /**
28 * Identifier of the resource being shared (e.g. the gateway policy id).
29 * Changing it triggers a replacement.
30 */
31 resourceId: string;
32 /**
33 * Account that owns the resource being shared. Changing it triggers a
34 * replacement.
35 * @default the current account
36 */
37 resourceAccountId?: string;
38 /**
39 * Resource metadata forwarded to the share API. The only mutable field.
40 * @default {}
41 */
42 meta?: unknown;
43};
44
45export type ShareResourceAttributes = {
46 /**
47 * Server-assigned share-resource identifier — distinct from the shared
48 * `resourceId`. Stable across updates.
49 */
50 shareResourceId: string;
51 /**
52 * The Cloudflare account that owns the share.
53 */
54 accountId: string;
55 /**
56 * The share this resource belongs to.
57 */
58 shareId: string;
59 /**
60 * Type of the shared resource.
61 */
62 resourceType: ShareableResourceType;
63 /**
64 * Identifier of the resource being shared.
65 */
66 resourceId: string;
67 /**
68 * Account that owns the resource being shared.
69 */
70 resourceAccountId: string;
71 /**
72 * Resource metadata.
73 */
74 meta: unknown;
75 /**
76 * Version of the shared resource.
77 */
78 resourceVersion: number;
79 /**
80 * Lifecycle status of the share resource.
81 */
82 status: ShareStatus;
83 /**
84 * When the share resource was created.
85 */
86 created: string;
87 /**
88 * When the share resource was last modified.
89 */
90 modified: string;
91};
92
93export type ShareResource = Resource<
94 TypeId,
95 ShareResourceProps,
96 ShareResourceAttributes,
97 never,
98 Providers
99>;
100
101/**
102 * A resource entry on an existing Cloudflare share — adds a shareable
103 * resource (gateway policy, custom ruleset, …) to a `Share` incrementally.
104 *
105 * Only `meta` is mutable in place; changing the share, type, id, or owning
106 * account triggers a replacement. A share must always retain at least one
107 * resource — the last entry cannot be deleted (delete the `Share` instead).
108 * Do not manage the same entry both inline on `Share.resources` and through
109 * this resource.
110 * ### Adding a Resource to a Share
111 * **Example:** Share an additional gateway policy
112 * ```typescript
113 * const entry = yield* Cloudflare.ResourceSharing.ShareResource("ExtraPolicy", {
114 * shareId: share.shareId,
115 * resourceType: "gateway-policy",
116 * resourceId: policy.ruleId,
117 * });
118 * ```
119 *
120 * ### Updating Metadata
121 * **Example:** Update `meta` in place
122 * ```typescript
123 * const entry = yield* Cloudflare.ResourceSharing.ShareResource("ExtraPolicy", {
124 * shareId: share.shareId,
125 * resourceType: "gateway-policy",
126 * resourceId: policy.ruleId,
127 * meta: { note: "rotated" },
128 * });
129 * ```
130 *
131 * @see https://developers.cloudflare.com/fundamentals/manage-account-resources/
132 *
133 * @resource
134 * @product Resource Sharing
135 * @category Account & Identity
136 */
137export const ShareResource = Resource<ShareResource>(TypeId);
138
139/**
140 * Returns true if the given value is a ShareResource resource.
141 */
142export const isShareResource = (value: unknown): value is ShareResource =>
143 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
144
145export const ShareResourceProvider = () =>
146 Provider.succeed(ShareResource, {
147 stables: [
148 "shareResourceId",
149 "accountId",
150 "shareId",
151 "resourceType",
152 "resourceId",
153 "resourceAccountId",
154 "created",
155 ],
156 diff: Effect.fn(function* ({ olds, news, output }) {
157 if (!isResolved(news)) return undefined;
158 const { accountId } = yield* yield* CloudflareEnvironment;
159 if ((output?.accountId ?? accountId) !== accountId) {
160 return { action: "replace" } as const;
161 }
162 // The share is a path parameter — an entry cannot move between
163 // shares in place. By diff time both sides are resolved strings.
164 const oldShareId = output?.shareId ?? olds?.shareId;
165 if (
166 typeof oldShareId === "string" &&
167 typeof news.shareId === "string" &&
168 oldShareId !== news.shareId
169 ) {
170 return { action: "replace" } as const;
171 }
172 const oldType = output?.resourceType ?? olds?.resourceType;
173 if (oldType !== undefined && oldType !== news.resourceType) {
174 return { action: "replace" } as const;
175 }
176 const oldResourceId = output?.resourceId ?? olds?.resourceId;
177 if (
178 typeof oldResourceId === "string" &&
179 typeof news.resourceId === "string" &&
180 oldResourceId !== news.resourceId
181 ) {
182 return { action: "replace" } as const;
183 }
184 const oldOwner = output?.resourceAccountId ?? olds?.resourceAccountId;
185 const newOwner = news.resourceAccountId ?? accountId;
186 if (
187 typeof oldOwner === "string" &&
188 typeof newOwner === "string" &&
189 oldOwner !== newOwner
190 ) {
191 return { action: "replace" } as const;
192 }
193 return undefined;
194 }),
195 read: Effect.fn(function* ({ olds, output }) {
196 const { accountId } = yield* yield* CloudflareEnvironment;
197 const acct = output?.accountId ?? accountId;
198 const shareId = output?.shareId ?? (olds?.shareId as string | undefined);
199 if (shareId === undefined) return undefined;
200
201 if (output?.shareResourceId) {
202 const observed = yield* getEntry(acct, shareId, output.shareResourceId);
203 return observed ? toAttributes(observed, acct, shareId) : undefined;
204 }
205 // Cold read — recover from lost state by matching the natural key
206 // (resourceType, resourceId) among the share's live entries.
207 const resourceType = olds?.resourceType as
208 | ShareableResourceType
209 | undefined;
210 const resourceId = olds?.resourceId as string | undefined;
211 if (resourceType === undefined || resourceId === undefined) {
212 return undefined;
213 }
214 const match = yield* findEntry(acct, shareId, resourceType, resourceId);
215 return match ? toAttributes(match, acct, shareId) : undefined;
216 }),
217 reconcile: Effect.fn(function* ({ news, output }) {
218 const { accountId } = yield* yield* CloudflareEnvironment;
219 const acct = output?.accountId ?? accountId;
220 const shareId = news.shareId as string;
221 const resourceId = news.resourceId as string;
222 const desiredMeta = news.meta ?? {};
223
224 // Observe — the id cached on `output` is a hint; fall through to a
225 // natural-key lookup so an AlreadyExists race converges instead of
226 // failing, then to create.
227 const observed =
228 (output?.shareResourceId
229 ? yield* getEntry(acct, shareId, output.shareResourceId)
230 : undefined) ??
231 (yield* findEntry(acct, shareId, news.resourceType, resourceId));
232
233 if (!observed) {
234 // Ensure — greenfield (or out-of-band delete).
235 const created = yield* resourceSharing.createResource({
236 accountId: acct,
237 shareId,
238 resourceType: news.resourceType,
239 resourceId,
240 resourceAccountId: (news.resourceAccountId as string) ?? acct,
241 meta: desiredMeta,
242 });
243 return toAttributes(created, acct, shareId);
244 }
245
246 // Sync — `meta` is the only mutable field; skip the PUT on a no-op.
247 if (JSON.stringify(observed.meta ?? {}) !== JSON.stringify(desiredMeta)) {
248 const updated = yield* resourceSharing.updateResource({
249 accountId: acct,
250 shareId,
251 shareResourceId: observed.id,
252 meta: desiredMeta,
253 });
254 return toAttributes(updated, acct, shareId);
255 }
256 return toAttributes(observed, acct, shareId);
257 }),
258 delete: Effect.fn(function* ({ output }) {
259 // Deletion is asynchronous (`status: deleting`); the DELETE call is
260 // idempotent against a missing entry. Deleting the last resource of
261 // a share is a genuine dependency violation and propagates.
262 yield* resourceSharing
263 .deleteResource({
264 accountId: output.accountId,
265 shareId: output.shareId,
266 shareResourceId: output.shareResourceId,
267 })
268 .pipe(Effect.catchTag("ShareResourceNotFound", () => Effect.void));
269 }),
270 list: Effect.fn(function* () {
271 const { accountId } = yield* yield* CloudflareEnvironment;
272 // Parent fan-out: a ShareResource is a sub-resource of a Share with
273 // no account-wide enumeration of its own. Enumerate every share we
274 // send (account-scoped), then list each share's resource entries
275 // with bounded concurrency, paginating both levels exhaustively.
276 const shares = yield* resourceSharing.listResourceSharings
277 .pages({ accountId, kind: "sent" })
278 .pipe(
279 Stream.runCollect,
280 Effect.map((chunk) =>
281 Array.from(chunk).flatMap((page) =>
282 (page.result ?? []).filter((s) => s.status !== "deleted"),
283 ),
284 ),
285 );
286 const rows = yield* Effect.forEach(
287 shares,
288 (share) =>
289 resourceSharing.listResources
290 .pages({ accountId, shareId: share.id })
291 .pipe(
292 Stream.runCollect,
293 Effect.map((chunk) =>
294 Array.from(chunk).flatMap((page) =>
295 (page.result ?? [])
296 .filter(
297 (r) => r.status !== "deleted" && r.status !== "deleting",
298 )
299 .map((entry) => toAttributes(entry, accountId, share.id)),
300 ),
301 ),
302 // A share removed out-of-band between enumeration and the
303 // per-share list surfaces as ShareNotFound — skip it.
304 Effect.catchTag("ShareNotFound", () =>
305 Effect.succeed([] as ShareResourceAttributes[]),
306 ),
307 ),
308 { concurrency: 10 },
309 );
310 return rows.flat();
311 }),
312 });
313
314type ObservedEntry = resourceSharing.GetResourceResponse;
315
316/**
317 * Read a share-resource entry by id, mapping "gone"
318 * (`ShareResourceNotFound`, HTTP 404) and the terminal `deleted` status to
319 * `undefined`.
320 */
321const getEntry = (
322 accountId: string,
323 shareId: string,
324 shareResourceId: string,
325) =>
326 resourceSharing.getResource({ accountId, shareId, shareResourceId }).pipe(
327 Effect.map((entry): ObservedEntry | undefined =>
328 entry.status === "deleted" ? undefined : entry,
329 ),
330 Effect.catchTag("ShareResourceNotFound", () => Effect.succeed(undefined)),
331 );
332
333/**
334 * Find a live entry by its natural key (resourceType, resourceId). A missing
335 * share surfaces as `ShareNotFound` — treat it as "no entry".
336 */
337const findEntry = (
338 accountId: string,
339 shareId: string,
340 resourceType: ShareableResourceType,
341 resourceId: string,
342) =>
343 resourceSharing
344 .listResources({ accountId, shareId, resourceType, perPage: 50 })
345 .pipe(
346 Effect.map((list) =>
347 list.result.find(
348 (r) =>
349 r.resourceId === resourceId &&
350 r.status !== "deleted" &&
351 r.status !== "deleting",
352 ),
353 ),
354 Effect.catchTag("ShareNotFound", () => Effect.succeed(undefined)),
355 );
356
357const toAttributes = (
358 entry:
359 | ObservedEntry
360 | resourceSharing.CreateResourceResponse
361 | resourceSharing.UpdateResourceResponse
362 | resourceSharing.ListResourcesResponse["result"][number],
363 accountId: string,
364 shareId: string,
365): ShareResourceAttributes => ({
366 shareResourceId: entry.id,
367 accountId,
368 shareId,
369 // Distilled widens generated string enums to open unions (`string & {}`).
370 resourceType: entry.resourceType as ShareableResourceType,
371 resourceId: entry.resourceId,
372 resourceAccountId: entry.resourceAccountId,
373 meta: entry.meta,
374 resourceVersion: entry.resourceVersion,
375 status: entry.status as ShareStatus,
376 created: entry.created,
377 modified: entry.modified,
378});