1import * as aoss from "@distilled.cloud/aws/opensearchserverless";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 recordToTagList,
13 retryWhileConflict,
14 tagsToRecord,
15} from "./internal.ts";
16
17export interface CollectionGroupCapacityLimits {
18 /**
19 * Maximum indexing capacity, in OpenSearch Compute Units (OCUs), shared by
20 * the group's collections.
21 */
22 maxIndexingCapacityInOCU?: number;
23 /**
24 * Maximum search capacity, in OCUs, shared by the group's collections.
25 */
26 maxSearchCapacityInOCU?: number;
27 /**
28 * Minimum indexing capacity, in OCUs, reserved for the group.
29 */
30 minIndexingCapacityInOCU?: number;
31 /**
32 * Minimum search capacity, in OCUs, reserved for the group.
33 */
34 minSearchCapacityInOCU?: number;
35}
36
37export interface CollectionGroupProps {
38 /**
39 * Name of the collection group (3-32 characters, lowercase; must start with
40 * a lowercase letter). Changing the name replaces the group.
41 * @default a generated physical name
42 */
43 groupName?: string;
44 /**
45 * Whether collections in the group deploy redundant standby replicas. Set
46 * at creation time; changing it replaces the group.
47 * @default "ENABLED"
48 */
49 standbyReplicas?: "ENABLED" | "DISABLED";
50 /**
51 * A human-readable description of the collection group.
52 */
53 description?: string;
54 /**
55 * OCU capacity limits shared by the collections in the group.
56 */
57 capacityLimits?: CollectionGroupCapacityLimits;
58 /**
59 * Tags to apply to the collection group. Merged with the internal Alchemy
60 * tags.
61 */
62 tags?: Record<string, string>;
63}
64
65export interface CollectionGroup extends Resource<
66 "AWS.OpenSearchServerless.CollectionGroup",
67 CollectionGroupProps,
68 {
69 /**
70 * Unique identifier of the collection group.
71 */
72 collectionGroupId: string;
73 /**
74 * Name of the collection group.
75 */
76 collectionGroupName: string;
77 /**
78 * ARN of the collection group.
79 */
80 collectionGroupArn: string;
81 /**
82 * Whether standby replicas are enabled for the group's collections.
83 */
84 standbyReplicas?: string;
85 /**
86 * Number of collections currently in the group.
87 */
88 numberOfCollections?: number;
89 /**
90 * The capacity generation of the group.
91 */
92 generation?: string;
93 },
94 {},
95 Providers
96> {}
97
98/**
99 * An Amazon OpenSearch Serverless collection group. Collection groups manage
100 * OpenSearch Compute Units (OCUs) at a group level — multiple collections
101 * share the group's capacity limits instead of each collection scaling
102 * independently.
103 *
104 * ### Creating Collection Groups
105 * **Example:** Capacity-Bounded Collection Group
106 * ```typescript
107 * import * as AWS from "alchemy/AWS";
108 *
109 * const group = yield* AWS.OpenSearchServerless.CollectionGroup("Group", {
110 * groupName: "analytics",
111 * standbyReplicas: "DISABLED",
112 * capacityLimits: {
113 * maxIndexingCapacityInOCU: 4,
114 * maxSearchCapacityInOCU: 4,
115 * },
116 * });
117 * ```
118 *
119 * @resource
120 */
121export const CollectionGroup = Resource<CollectionGroup>(
122 "AWS.OpenSearchServerless.CollectionGroup",
123);
124
125export const CollectionGroupProvider = () =>
126 Provider.effect(
127 CollectionGroup,
128 Effect.gen(function* () {
129 const createName = Effect.fn(function* (
130 id: string,
131 props: { groupName?: string | undefined },
132 ) {
133 return (
134 props.groupName ??
135 (yield* createPhysicalName({ id, maxLength: 32, lowercase: true }))
136 );
137 });
138
139 const toAttributes = (detail: aoss.CollectionGroupDetail) => ({
140 collectionGroupId: detail.id!,
141 collectionGroupName: detail.name!,
142 collectionGroupArn: detail.arn!,
143 standbyReplicas: detail.standbyReplicas,
144 numberOfCollections: detail.numberOfCollections,
145 generation: detail.generation,
146 });
147
148 // batchGetCollectionGroup reports a missing group in
149 // collectionGroupErrorDetails instead of failing.
150 const observeByName = Effect.fn(function* (name: string) {
151 const response = yield* aoss.batchGetCollectionGroup({
152 names: [name],
153 });
154 return response.collectionGroupDetails?.[0];
155 });
156
157 const capacityDrift = (
158 desired: CollectionGroupCapacityLimits | undefined,
159 observed: aoss.CollectionGroupCapacityLimits | undefined,
160 ): boolean =>
161 desired !== undefined &&
162 (desired.maxIndexingCapacityInOCU !==
163 observed?.maxIndexingCapacityInOCU ||
164 desired.maxSearchCapacityInOCU !== observed?.maxSearchCapacityInOCU ||
165 desired.minIndexingCapacityInOCU !==
166 observed?.minIndexingCapacityInOCU ||
167 desired.minSearchCapacityInOCU !== observed?.minSearchCapacityInOCU);
168
169 // batchGetCollectionGroup does not return the group's tags — read them
170 // via listTagsForResource (the authoritative tag store).
171 const observeTags = Effect.fn(function* (arn: string) {
172 return yield* aoss
173 .listTagsForResource({ resourceArn: arn })
174 .pipe(Effect.map((r) => tagsToRecord(r.tags)));
175 });
176
177 const syncTags = Effect.fn(function* (
178 arn: string,
179 observed: Record<string, string>,
180 desired: Record<string, string>,
181 ) {
182 const { upsert, removed } = diffTags(observed, desired);
183 if (upsert.length > 0) {
184 yield* aoss.tagResource({
185 resourceArn: arn,
186 tags: recordToTagList(
187 Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
188 ),
189 });
190 }
191 if (removed.length > 0) {
192 yield* aoss.untagResource({ resourceArn: arn, tagKeys: removed });
193 }
194 });
195
196 return CollectionGroup.Provider.of({
197 stables: [
198 "collectionGroupId",
199 "collectionGroupName",
200 "collectionGroupArn",
201 ],
202
203 list: () =>
204 Effect.gen(function* () {
205 const pages = yield* aoss.listCollectionGroups
206 .pages({})
207 .pipe(Stream.runCollect);
208 return Array.from(pages)
209 .flatMap((page) => page.collectionGroupSummaries ?? [])
210 .filter(
211 (s) =>
212 s.id !== undefined &&
213 s.name !== undefined &&
214 s.arn !== undefined,
215 )
216 .map((s) => ({
217 collectionGroupId: s.id!,
218 collectionGroupName: s.name!,
219 collectionGroupArn: s.arn!,
220 generation: s.generation,
221 }));
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const name =
226 output?.collectionGroupName ?? (yield* createName(id, olds ?? {}));
227 const detail = yield* observeByName(name);
228 if (detail?.id === undefined || detail.arn === undefined) {
229 return undefined;
230 }
231 const attrs = toAttributes(detail);
232 const tags = yield* observeTags(detail.arn).pipe(
233 Effect.catch(() => Effect.succeed({})),
234 );
235 return (yield* hasAlchemyTags(
236 id,
237 Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
238 ))
239 ? attrs
240 : Unowned(attrs);
241 }),
242
243 diff: Effect.fn(function* ({ id, news, olds }) {
244 if (!isResolved(news)) return undefined;
245 const oldName = yield* createName(id, olds);
246 const newName = yield* createName(id, news);
247 if (oldName !== newName) {
248 return { action: "replace" } as const;
249 }
250 if (
251 olds.standbyReplicas !== undefined &&
252 news.standbyReplicas !== undefined &&
253 olds.standbyReplicas !== news.standbyReplicas
254 ) {
255 return { action: "replace" } as const;
256 }
257 // description/capacityLimits/tags fall through to update
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output, session }) {
261 const name =
262 output?.collectionGroupName ?? (yield* createName(id, news));
263 const internalTags = yield* createInternalTags(id);
264 const desiredTags = { ...news.tags, ...internalTags };
265
266 // 1. OBSERVE — cloud state is authoritative
267 let detail = yield* observeByName(name);
268
269 // 2. ENSURE — create if missing; tolerate a concurrent create race
270 if (detail === undefined) {
271 const created = yield* aoss
272 .createCollectionGroup({
273 name,
274 standbyReplicas: news.standbyReplicas ?? "ENABLED",
275 description: news.description,
276 capacityLimits: news.capacityLimits,
277 tags: recordToTagList(desiredTags),
278 })
279 .pipe(
280 Effect.map((r) => r.createCollectionGroupDetail),
281 Effect.catchTag("ConflictException", () =>
282 Effect.succeed(undefined),
283 ),
284 );
285 detail =
286 created !== undefined
287 ? { ...created }
288 : yield* observeByName(name);
289 } else {
290 // 3. SYNC — description/capacityLimits when observed drifts
291 const descriptionDrift =
292 news.description !== undefined &&
293 news.description !== detail.description;
294 const limitsDrift = capacityDrift(
295 news.capacityLimits,
296 detail.capacityLimits,
297 );
298 if (descriptionDrift || limitsDrift) {
299 yield* aoss.updateCollectionGroup({
300 id: detail.id!,
301 description: descriptionDrift ? news.description : undefined,
302 capacityLimits: limitsDrift ? news.capacityLimits : undefined,
303 });
304 }
305 }
306
307 if (detail?.id === undefined || detail.arn === undefined) {
308 return yield* Effect.fail(
309 new aoss.ResourceNotFoundException({
310 message: `collection group ${name} not visible after reconcile`,
311 }),
312 );
313 }
314
315 // 3b. SYNC TAGS — diff against observed cloud tags (read via
316 // listTagsForResource on every path: adoption may bring foreign
317 // tags, and a create race may have dropped ours).
318 yield* syncTags(
319 detail.arn,
320 yield* observeTags(detail.arn),
321 desiredTags,
322 );
323
324 yield* session.note(detail.id);
325 return toAttributes(detail);
326 }),
327
328 delete: Effect.fn(function* ({ output }) {
329 // A group with collections still in it (or tearing down) surfaces
330 // ConflictException — retry through the teardown window.
331 yield* retryWhileConflict(
332 aoss.deleteCollectionGroup({ id: output.collectionGroupId }),
333 ).pipe(
334 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
335 );
336 }),
337 });
338 }),
339 );