1import * as appsync from "@distilled.cloud/aws/appsync";
2import * as iam from "@distilled.cloud/aws/iam";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags } from "../../Tags.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { PolicyStatement } from "../IAM/Policy.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 retryConcurrentModification,
16 retryWhileRolePropagates,
17 sanitizeAppSyncName,
18} from "./common.ts";
19import type { GraphqlApi } from "./GraphqlApi.ts";
20
21/** The data source types supported by the resource. */
22export type DataSourceType =
23 | "NONE"
24 | "AWS_LAMBDA"
25 | "AMAZON_DYNAMODB"
26 | "HTTP"
27 | "AMAZON_EVENTBRIDGE";
28
29export interface LambdaDataSourceConfig {
30 /** ARN of the Lambda function this data source invokes. */
31 lambdaFunctionArn: string;
32}
33
34export interface DynamodbDataSourceConfig {
35 /** Name of the DynamoDB table. */
36 tableName: string;
37 /**
38 * Region the table lives in.
39 * @default the ambient AWS region
40 */
41 awsRegion?: string;
42 /** Use the caller's credentials instead of the service role. */
43 useCallerCredentials?: boolean;
44 /** Whether conflict detection (versioning) is enabled. */
45 versioned?: boolean;
46}
47
48export interface HttpDataSourceConfig {
49 /** The HTTP endpoint, e.g. `https://api.example.com`. */
50 endpoint: string;
51}
52
53export interface DataSourceProps {
54 /**
55 * ID of the GraphQL API this data source belongs to. Usually derived
56 * from `api.apiId` by the {@link DataSource} wrapper.
57 */
58 apiId: string;
59 /**
60 * Name of the data source (`[_A-Za-z][_0-9A-Za-z]*` — no dashes). If
61 * omitted, a deterministic name is generated from the app, stage, and
62 * logical ID (dashes sanitized to underscores). Changing the name
63 * triggers a replacement.
64 */
65 name?: string;
66 /** Description of the data source. */
67 description?: string;
68 /**
69 * The data source type. `NONE` (local compute), `AWS_LAMBDA`,
70 * `AMAZON_DYNAMODB`, `HTTP`, or `AMAZON_EVENTBRIDGE`.
71 */
72 type: DataSourceType;
73 /**
74 * The ARN of an existing IAM role AppSync assumes to access the target.
75 * When omitted (and the type needs one), a service role is created
76 * automatically with `appsync.amazonaws.com` trust and least-privilege
77 * access to the configured target.
78 */
79 serviceRoleArn?: string;
80 /**
81 * Additional IAM policy statements attached to the auto-created service
82 * role (ignored when {@link DataSourceProps.serviceRoleArn} is provided).
83 */
84 policyStatements?: PolicyStatement[];
85 /** Lambda target — required when `type` is `AWS_LAMBDA`. */
86 lambdaConfig?: LambdaDataSourceConfig;
87 /** DynamoDB target — required when `type` is `AMAZON_DYNAMODB`. */
88 dynamodbConfig?: DynamodbDataSourceConfig;
89 /** HTTP target — required when `type` is `HTTP`. */
90 httpConfig?: HttpDataSourceConfig;
91}
92
93export interface AppSyncDataSource extends Resource<
94 "AWS.AppSync.DataSource",
95 DataSourceProps,
96 {
97 /** The API this data source belongs to. */
98 apiId: string;
99 /** The data source name (referenced by resolvers and functions). */
100 name: string;
101 /** The data source ARN. */
102 dataSourceArn: string;
103 /** The data source type. */
104 type: DataSourceType;
105 /** The service role AppSync assumes, if any. */
106 serviceRoleArn: string | undefined;
107 /**
108 * Name of the auto-created service role. `undefined` when an explicit
109 * {@link DataSourceProps.serviceRoleArn} is used or no role is needed.
110 */
111 roleName: string | undefined;
112 },
113 never,
114 Providers
115> {}
116
117/**
118 * An AppSync data source — the target a resolver reads from or writes to.
119 *
120 * For `AWS_LAMBDA` and `AMAZON_DYNAMODB` targets a least-privilege service
121 * role is created automatically (unless an explicit `serviceRoleArn` is
122 * given): `lambda:InvokeFunction` on the function, or the DynamoDB
123 * read/write actions on the table and its indexes.
124 * ### Creating Data Sources
125 * **Example:** Lambda data source (auto-created invoke role)
126 * ```typescript
127 * const ds = yield* AppSync.DataSource("LambdaDS", {
128 * api,
129 * type: "AWS_LAMBDA",
130 * lambdaConfig: { lambdaFunctionArn: fn.functionArn },
131 * });
132 * ```
133 *
134 * **Example:** NONE data source (local compute)
135 * ```typescript
136 * const local = yield* AppSync.DataSource("Local", {
137 * api,
138 * type: "NONE",
139 * });
140 * ```
141 *
142 * **Example:** DynamoDB data source
143 * ```typescript
144 * const ds = yield* AppSync.DataSource("TableDS", {
145 * api,
146 * type: "AMAZON_DYNAMODB",
147 * dynamodbConfig: { tableName: table.tableName },
148 * });
149 * ```
150 *
151 * @resource
152 */
153export const DataSourceResource = Resource<AppSyncDataSource>(
154 "AWS.AppSync.DataSource",
155);
156
157export interface DataSourceInputProps extends Omit<
158 {
159 [K in keyof DataSourceProps]?: Input<DataSourceProps[K]>;
160 },
161 "apiId" | "type"
162> {
163 /**
164 * The `GraphqlApi` this data source belongs to (preferred). Alternatively
165 * pass a raw `apiId`.
166 */
167 api?: GraphqlApi;
168 apiId?: Input<string>;
169 type: Input<DataSourceType>;
170}
171
172/**
173 * User-facing wrapper for the DataSource resource. Accepts `api: GraphqlApi`
174 * as the idiomatic way to attach a data source to an API.
175 */
176export const DataSource = (id: string, props: DataSourceInputProps) =>
177 Effect.gen(function* () {
178 const { api, ...rest } = props;
179 const apiId = rest.apiId ?? api?.apiId;
180 if (!apiId) {
181 return yield* Effect.die(
182 "DataSource requires either `api` (preferred) or an explicit `apiId`.",
183 );
184 }
185 return yield* DataSourceResource(id, { ...rest, apiId } as any);
186 });
187
188export const DataSourceProvider = () =>
189 Provider.effect(
190 DataSourceResource,
191 Effect.gen(function* () {
192 const createName = Effect.fn(function* (
193 id: string,
194 props: Pick<DataSourceProps, "name">,
195 ) {
196 return (
197 props.name ??
198 sanitizeAppSyncName(yield* createPhysicalName({ id, maxLength: 64 }))
199 );
200 });
201
202 const getDataSourceSafe = (apiId: string, name: string) =>
203 appsync.getDataSource({ apiId, name }).pipe(
204 Effect.map((response) => response.dataSource),
205 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
206 );
207
208 /**
209 * Least-privilege statements for the auto-created service role,
210 * derived from the configured target.
211 */
212 const buildRolePolicyStatements = (
213 news: DataSourceProps,
214 region: string,
215 accountId: string,
216 ): PolicyStatement[] => {
217 const statements: PolicyStatement[] = [];
218 if (news.type === "AWS_LAMBDA" && news.lambdaConfig !== undefined) {
219 const arn = news.lambdaConfig.lambdaFunctionArn;
220 statements.push({
221 Effect: "Allow",
222 Action: ["lambda:InvokeFunction"],
223 Resource: /:function:[^:]+:/.test(arn) ? [arn] : [arn, `${arn}:*`],
224 });
225 }
226 if (
227 news.type === "AMAZON_DYNAMODB" &&
228 news.dynamodbConfig !== undefined
229 ) {
230 const tableRegion = news.dynamodbConfig.awsRegion ?? region;
231 const tableArn = `arn:aws:dynamodb:${tableRegion}:${accountId}:table/${news.dynamodbConfig.tableName}`;
232 statements.push({
233 Effect: "Allow",
234 Action: [
235 "dynamodb:BatchGetItem",
236 "dynamodb:BatchWriteItem",
237 "dynamodb:ConditionCheckItem",
238 "dynamodb:DeleteItem",
239 "dynamodb:GetItem",
240 "dynamodb:PutItem",
241 "dynamodb:Query",
242 "dynamodb:Scan",
243 "dynamodb:UpdateItem",
244 ],
245 Resource: [tableArn, `${tableArn}/index/*`],
246 });
247 }
248 statements.push(...(news.policyStatements ?? []));
249 return statements;
250 };
251
252 const needsServiceRole = (news: DataSourceProps): boolean =>
253 news.type === "AWS_LAMBDA" ||
254 news.type === "AMAZON_DYNAMODB" ||
255 news.type === "AMAZON_EVENTBRIDGE";
256
257 /**
258 * Ensure the auto-created service role exists with the
259 * `appsync.amazonaws.com` trust policy and the desired inline
260 * policy. Idempotent across reconciles.
261 */
262 const ensureServiceRole = Effect.fn(function* ({
263 id,
264 roleName,
265 statements,
266 }: {
267 id: string;
268 roleName: string;
269 statements: PolicyStatement[];
270 }) {
271 const tags = yield* createInternalTags(id);
272 const role = yield* iam
273 .createRole({
274 RoleName: roleName,
275 AssumeRolePolicyDocument: JSON.stringify({
276 Version: "2012-10-17",
277 Statement: [
278 {
279 Effect: "Allow",
280 Principal: { Service: "appsync.amazonaws.com" },
281 Action: "sts:AssumeRole",
282 },
283 ],
284 }),
285 Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
286 })
287 .pipe(
288 Effect.catchTag("EntityAlreadyExistsException", () =>
289 iam.getRole({ RoleName: roleName }),
290 ),
291 );
292
293 if (statements.length > 0) {
294 yield* iam.putRolePolicy({
295 RoleName: roleName,
296 PolicyName: `${roleName}-policy`,
297 PolicyDocument: JSON.stringify({
298 Version: "2012-10-17",
299 Statement: statements,
300 }),
301 });
302 } else {
303 yield* iam
304 .deleteRolePolicy({
305 RoleName: roleName,
306 PolicyName: `${roleName}-policy`,
307 })
308 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
309 }
310
311 return role.Role.Arn;
312 });
313
314 /** Delete the auto-created service role and its inline policies. */
315 const deleteServiceRole = Effect.fn(function* (roleName: string) {
316 yield* iam.listRolePolicies.items({ RoleName: roleName }).pipe(
317 Stream.mapEffect((policyName) =>
318 iam
319 .deleteRolePolicy({
320 RoleName: roleName,
321 PolicyName: policyName,
322 })
323 .pipe(
324 Effect.catchTag("NoSuchEntityException", () => Effect.void),
325 ),
326 ),
327 Stream.runDrain,
328 Effect.catchTag("NoSuchEntityException", () => Effect.void),
329 );
330 yield* iam
331 .deleteRole({ RoleName: roleName })
332 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
333 });
334
335 const toWireConfigs = (news: DataSourceProps, region: string) => ({
336 lambdaConfig: news.lambdaConfig,
337 dynamodbConfig:
338 news.dynamodbConfig === undefined
339 ? undefined
340 : {
341 tableName: news.dynamodbConfig.tableName,
342 awsRegion: news.dynamodbConfig.awsRegion ?? region,
343 useCallerCredentials: news.dynamodbConfig.useCallerCredentials,
344 versioned: news.dynamodbConfig.versioned,
345 },
346 httpConfig: news.httpConfig,
347 });
348
349 const toAttributes = (
350 apiId: string,
351 ds: appsync.DataSource,
352 roleName: string | undefined,
353 ): AppSyncDataSource["Attributes"] => ({
354 apiId,
355 name: ds.name!,
356 dataSourceArn: ds.dataSourceArn!,
357 type: ds.type as DataSourceType,
358 serviceRoleArn: ds.serviceRoleArn,
359 roleName,
360 });
361
362 return DataSourceResource.Provider.of({
363 stables: ["apiId", "name", "dataSourceArn"],
364
365 // Sub-resource keyed entirely by its GraphQL API (apiId) with no global
366 // enumeration API of its own — nuke reaches it through the parent's
367 // deletion, so enumeration returns empty per the ProviderService
368 // doctrine.
369 list: () => Effect.succeed([]),
370
371 read: Effect.fn(function* ({ id, olds, output }) {
372 const apiId = output?.apiId ?? olds?.apiId;
373 if (apiId === undefined) return undefined;
374 const name = output?.name ?? (yield* createName(id, olds ?? {}));
375 const ds = yield* getDataSourceSafe(apiId, name);
376 if (ds?.name == null) return undefined;
377 return toAttributes(apiId, ds, output?.roleName);
378 }),
379
380 diff: Effect.fn(function* ({ id, news, olds }) {
381 if (!isResolved(news)) return undefined;
382 const oldName = yield* createName(id, olds ?? {});
383 const newName = yield* createName(id, news ?? {});
384 if (news.apiId !== olds.apiId || oldName !== newName) {
385 return { action: "replace" } as const;
386 }
387 // type/config/role converge via updateDataSource
388 }),
389
390 reconcile: Effect.fn(function* ({ id, news, output, session }) {
391 const { region, accountId } = yield* AWSEnvironment.current;
392 const apiId = output?.apiId ?? news.apiId;
393 const name = output?.name ?? (yield* createName(id, news));
394 const configs = toWireConfigs(news, region);
395
396 // Ensure the service role first — the data source cannot point
397 // at a Lambda/DynamoDB target without one.
398 let serviceRoleArn = news.serviceRoleArn;
399 let roleName: string | undefined;
400 if (serviceRoleArn === undefined && needsServiceRole(news)) {
401 roleName = yield* createPhysicalName({ id, maxLength: 64 });
402 serviceRoleArn = yield* ensureServiceRole({
403 id,
404 roleName,
405 statements: buildRolePolicyStatements(news, region, accountId),
406 });
407 }
408
409 // 1. OBSERVE
410 let observed = yield* getDataSourceSafe(apiId, name);
411
412 // 2. ENSURE — a fresh role can take a few seconds to become
413 // assumable by AppSync.
414 if (observed?.name == null) {
415 const created = yield* retryWhileRolePropagates(
416 retryConcurrentModification(
417 appsync.createDataSource({
418 apiId,
419 name,
420 description: news.description,
421 type: news.type,
422 serviceRoleArn,
423 ...configs,
424 }),
425 ),
426 );
427 observed = created.dataSource!;
428 yield* session.note(`Created data source ${name}`);
429 } else {
430 // 3. SYNC — single update when the observed surface drifted.
431 const surface = (ds: {
432 description?: string;
433 type?: string;
434 serviceRoleArn?: string;
435 lambdaConfig?: appsync.LambdaDataSourceConfig;
436 dynamodbConfig?: appsync.DynamodbDataSourceConfig;
437 httpConfig?: appsync.HttpDataSourceConfig;
438 }) =>
439 JSON.parse(
440 JSON.stringify({
441 description: ds.description,
442 type: ds.type,
443 serviceRoleArn: ds.serviceRoleArn,
444 lambdaConfig: ds.lambdaConfig,
445 // AWS normalizes optional booleans into the response;
446 // mirror the defaults so no-ops stay no-ops.
447 dynamodbConfig:
448 ds.dynamodbConfig === undefined
449 ? undefined
450 : {
451 tableName: ds.dynamodbConfig.tableName,
452 awsRegion: ds.dynamodbConfig.awsRegion,
453 useCallerCredentials:
454 ds.dynamodbConfig.useCallerCredentials ?? false,
455 versioned: ds.dynamodbConfig.versioned ?? false,
456 },
457 httpConfig: ds.httpConfig,
458 }),
459 );
460 const desired = surface({
461 description: news.description,
462 type: news.type,
463 serviceRoleArn,
464 ...configs,
465 });
466 if (!deepEqual(surface(observed), desired)) {
467 const updated = yield* retryWhileRolePropagates(
468 retryConcurrentModification(
469 appsync.updateDataSource({
470 apiId,
471 name,
472 description: news.description,
473 type: news.type,
474 serviceRoleArn,
475 ...configs,
476 }),
477 ),
478 );
479 observed = updated.dataSource ?? observed;
480 yield* session.note(`Updated data source ${name}`);
481 }
482 }
483
484 yield* session.note(name);
485 return toAttributes(apiId, observed, roleName);
486 }),
487
488 delete: Effect.fn(function* ({ output }) {
489 yield* retryConcurrentModification(
490 appsync
491 .deleteDataSource({ apiId: output.apiId, name: output.name })
492 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
493 );
494 if (output.roleName !== undefined) {
495 yield* deleteServiceRole(output.roleName);
496 }
497 }),
498 });
499 }),
500 );