1import * as quicksight from "@distilled.cloud/aws/quicksight";
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, hasAlchemyTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 readQuickSightTags,
14 syncQuickSightTags,
15 toWireTags,
16 waitForSettled,
17} from "./internal.ts";
18
19/**
20 * Properties for an Amazon QuickSight data source — a connection to an
21 * external data store (Athena, S3, Redshift, an RDS engine, etc.) that
22 * datasets read from.
23 */
24export interface DataSourceProps {
25 /**
26 * Unique id of the data source within the account. Stable — changing it
27 * replaces the data source. If omitted, a unique id is generated.
28 */
29 dataSourceId?: string;
30
31 /**
32 * Display name of the data source.
33 */
34 name: string;
35
36 /**
37 * Type of the underlying data store (e.g. `ATHENA`, `S3`, `REDSHIFT`,
38 * `POSTGRESQL`). Immutable — changing the type replaces the data source.
39 */
40 type: quicksight.DataSourceType;
41
42 /**
43 * Connection parameters specific to the data source `type` (e.g.
44 * `AthenaParameters.WorkGroup`, `S3Parameters.ManifestFileLocation`).
45 */
46 dataSourceParameters?: quicksight.DataSourceParameters;
47
48 /**
49 * Credentials QuickSight uses to connect. Only required for stores that
50 * authenticate with a username/password or a Secrets Manager secret.
51 * Not required for `ATHENA`/`S3` sources that rely on the QuickSight IAM
52 * role.
53 */
54 credentials?: quicksight.DataSourceCredentials;
55
56 /**
57 * Resource-level permissions granting QuickSight principals access to the
58 * data source.
59 */
60 permissions?: quicksight.ResourcePermission[];
61
62 /**
63 * VPC connection used to reach a data store inside a VPC.
64 */
65 vpcConnectionProperties?: quicksight.VpcConnectionProperties;
66
67 /**
68 * SSL options for the connection.
69 */
70 sslProperties?: quicksight.SslProperties;
71
72 /**
73 * Tags to apply to the data source.
74 */
75 tags?: Record<string, string>;
76}
77
78export interface DataSource extends Resource<
79 "AWS.QuickSight.DataSource",
80 DataSourceProps,
81 {
82 /** Unique id of the data source within the account. */
83 dataSourceId: string;
84 /** ARN of the data source. */
85 arn: string;
86 /** Display name of the data source. */
87 name: string;
88 /** Type of the underlying data store. */
89 type: quicksight.DataSourceType;
90 /** Current lifecycle status (e.g. `CREATION_SUCCESSFUL`). */
91 status: string;
92 },
93 never,
94 Providers
95> {}
96
97/**
98 * An Amazon QuickSight data source — a connection to an external data store
99 * that datasets read from.
100 *
101 * QuickSight requires an active account subscription in the region. Without
102 * one, create operations fail with the typed `QuickSightSubscriptionRequired`
103 * error.
104 *
105 * ### Creating a Data Source
106 * **Example:** Athena Data Source
107 * ```typescript
108 * const source = yield* DataSource("analytics", {
109 * name: "Athena Analytics",
110 * type: "ATHENA",
111 * dataSourceParameters: { AthenaParameters: { WorkGroup: "primary" } },
112 * });
113 * ```
114 *
115 * **Example:** S3 Manifest Data Source
116 * ```typescript
117 * const source = yield* DataSource("s3-source", {
118 * name: "S3 Sales Data",
119 * type: "S3",
120 * dataSourceParameters: {
121 * S3Parameters: {
122 * ManifestFileLocation: { Bucket: "my-bucket", Key: "manifest.json" },
123 * },
124 * },
125 * });
126 * ```
127 *
128 * @resource
129 */
130export const DataSource = Resource<DataSource>("AWS.QuickSight.DataSource");
131
132export const DataSourceProvider = () =>
133 Provider.effect(
134 DataSource,
135 Effect.gen(function* () {
136 const toId = (id: string, props: Partial<DataSourceProps>) =>
137 props.dataSourceId
138 ? Effect.succeed(props.dataSourceId)
139 : createPhysicalName({ id, maxLength: 64 });
140
141 const readSource = Effect.fn(function* (
142 accountId: string,
143 dataSourceId: string,
144 ) {
145 const response = yield* quicksight
146 .describeDataSource({
147 AwsAccountId: accountId,
148 DataSourceId: dataSourceId,
149 })
150 .pipe(
151 Effect.catchTag("ResourceNotFoundException", () =>
152 Effect.succeed(undefined),
153 ),
154 );
155 const source = response?.DataSource;
156 if (source === undefined || source.Status === "DELETED") {
157 return undefined;
158 }
159 return source;
160 });
161
162 const toAttrs = (source: quicksight.DataSource) => ({
163 dataSourceId: source.DataSourceId,
164 arn: source.Arn,
165 name: source.Name,
166 type: source.Type,
167 status: source.Status,
168 });
169
170 return DataSource.Provider.of({
171 stables: ["dataSourceId", "arn"],
172
173 diff: Effect.fn(function* ({ id, olds = {}, news }) {
174 if (!isResolved(news)) return undefined;
175 if ((yield* toId(id, olds)) !== (yield* toId(id, news))) {
176 return { action: "replace" } as const;
177 }
178 if ((olds.type ?? undefined) !== (news.type ?? undefined)) {
179 return { action: "replace" } as const;
180 }
181 }),
182
183 read: Effect.fn(function* ({ id, olds = {}, output }) {
184 const { accountId } = yield* AWSEnvironment.current;
185 const dataSourceId = output?.dataSourceId ?? (yield* toId(id, olds));
186 const source = yield* readSource(accountId, dataSourceId);
187 if (source === undefined) return undefined;
188 const attrs = toAttrs(source);
189 const tags = yield* readQuickSightTags(attrs.arn);
190 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
191 }),
192
193 reconcile: Effect.fn(function* ({ id, news, output, session }) {
194 const { accountId } = yield* AWSEnvironment.current;
195 const dataSourceId = output?.dataSourceId ?? (yield* toId(id, news));
196 const internalTags = yield* createInternalTags(id);
197 const desiredTags = { ...internalTags, ...news.tags };
198
199 // 1. Observe — cloud state is authoritative.
200 let observed = yield* readSource(accountId, dataSourceId);
201
202 // 2. Ensure — create if missing; tolerate an AlreadyExists race.
203 if (observed === undefined) {
204 yield* quicksight
205 .createDataSource({
206 AwsAccountId: accountId,
207 DataSourceId: dataSourceId,
208 Name: news.name,
209 Type: news.type,
210 DataSourceParameters: news.dataSourceParameters,
211 Credentials: news.credentials,
212 Permissions: news.permissions,
213 VpcConnectionProperties: news.vpcConnectionProperties,
214 SslProperties: news.sslProperties,
215 Tags: toWireTags(desiredTags),
216 })
217 .pipe(
218 Effect.catchTag("ResourceExistsException", () => Effect.void),
219 );
220 observed = yield* waitForSettled(
221 dataSourceId,
222 readSource(accountId, dataSourceId).pipe(
223 Effect.map((s) =>
224 s === undefined ? undefined : { ...s, status: s.Status },
225 ),
226 ),
227 );
228 if (observed === undefined) {
229 return yield* Effect.fail(
230 new Error(
231 `QuickSight data source '${dataSourceId}' disappeared while creating`,
232 ),
233 );
234 }
235 } else {
236 // 3. Sync — apply mutable settings via an idempotent update.
237 yield* quicksight.updateDataSource({
238 AwsAccountId: accountId,
239 DataSourceId: dataSourceId,
240 Name: news.name,
241 DataSourceParameters: news.dataSourceParameters,
242 Credentials: news.credentials,
243 VpcConnectionProperties: news.vpcConnectionProperties,
244 SslProperties: news.sslProperties,
245 });
246 observed = yield* waitForSettled(
247 dataSourceId,
248 readSource(accountId, dataSourceId).pipe(
249 Effect.map((s) =>
250 s === undefined ? undefined : { ...s, status: s.Status },
251 ),
252 ),
253 );
254 if (observed === undefined) {
255 return yield* Effect.fail(
256 new Error(
257 `QuickSight data source '${dataSourceId}' disappeared while updating`,
258 ),
259 );
260 }
261 }
262
263 // 3b. Sync tags — diff against OBSERVED cloud tags.
264 yield* syncQuickSightTags(observed.Arn, desiredTags);
265
266 yield* session.note(dataSourceId);
267 return toAttrs(observed);
268 }),
269
270 delete: Effect.fn(function* ({ output }) {
271 const { accountId } = yield* AWSEnvironment.current;
272 yield* quicksight
273 .deleteDataSource({
274 AwsAccountId: accountId,
275 DataSourceId: output.dataSourceId,
276 })
277 .pipe(
278 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
279 );
280 }),
281
282 list: () =>
283 Effect.gen(function* () {
284 const { accountId } = yield* AWSEnvironment.current;
285 return yield* quicksight.listDataSources
286 .pages({ AwsAccountId: accountId })
287 .pipe(
288 Stream.runCollect,
289 Effect.map((chunk) =>
290 Array.from(chunk)
291 .flatMap((page) => page.DataSources ?? [])
292 .flatMap((s) =>
293 s.DataSourceId !== undefined &&
294 s.Arn !== undefined &&
295 s.Status !== "DELETED"
296 ? [
297 {
298 dataSourceId: s.DataSourceId,
299 arn: s.Arn,
300 name: s.Name,
301 type: s.Type,
302 status: s.Status,
303 },
304 ]
305 : [],
306 ),
307 ),
308 );
309 }),
310 });
311 }),
312 );