1import * as appsync from "@distilled.cloud/aws/appsync";
2import * as Effect from "effect/Effect";
3import { deepEqual, isResolved } from "../../Diff.ts";
4import type { Input } from "../../Input.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import { retryConcurrentModification } from "./common.ts";
9import type { AppSyncDataSource } from "./DataSource.ts";
10import type { GraphqlApi } from "./GraphqlApi.ts";
11
12/** The modern JavaScript resolver runtime. */
13export const APPSYNC_JS: appsync.AppSyncRuntime = {
14 name: "APPSYNC_JS",
15 runtimeVersion: "1.0.0",
16};
17
18export interface ResolverProps {
19 /**
20 * ID of the GraphQL API. Usually derived from `api.apiId` by the
21 * {@link Resolver} wrapper.
22 */
23 apiId: string;
24 /**
25 * The schema type the resolver is attached to (e.g. `Query`,
26 * `Mutation`, or an object type). Changing it triggers a replacement.
27 */
28 typeName: string;
29 /**
30 * The field on {@link ResolverProps.typeName} this resolver serves.
31 * Changing it triggers a replacement.
32 */
33 fieldName: string;
34 /**
35 * Name of the data source a `UNIT` resolver targets. Not used by
36 * `PIPELINE` resolvers.
37 */
38 dataSourceName?: string;
39 /**
40 * The resolver kind.
41 * @default "UNIT"
42 */
43 kind?: "UNIT" | "PIPELINE";
44 /**
45 * `APPSYNC_JS` resolver code exporting `request(ctx)` and
46 * `response(ctx)`. When set, the runtime defaults to APPSYNC_JS 1.0.0.
47 */
48 code?: string;
49 /** VTL request mapping template (legacy alternative to `code`). */
50 requestMappingTemplate?: string;
51 /** VTL response mapping template (legacy alternative to `code`). */
52 responseMappingTemplate?: string;
53 /**
54 * Pipeline function IDs executed in order — required for `PIPELINE`
55 * resolvers. Pass `fn.functionId` outputs from {@link Function}.
56 */
57 pipelineFunctionIds?: string[];
58 /** Maximum batch size for batched Lambda invocations (0–2000). */
59 maxBatchSize?: number;
60 /** Per-resolver caching config (requires an API cache). */
61 cachingConfig?: appsync.CachingConfig;
62}
63
64export interface AppSyncResolver extends Resource<
65 "AWS.AppSync.Resolver",
66 ResolverProps,
67 {
68 /** The API this resolver belongs to. */
69 apiId: string;
70 /** The schema type the resolver is attached to. */
71 typeName: string;
72 /** The field the resolver serves. */
73 fieldName: string;
74 /** The resolver ARN. */
75 resolverArn: string;
76 /** The data source a UNIT resolver targets. */
77 dataSourceName: string | undefined;
78 /** The resolver kind. */
79 kind: "UNIT" | "PIPELINE";
80 },
81 never,
82 Providers
83> {}
84
85/**
86 * An AppSync resolver — attaches request/response logic to a schema field.
87 *
88 * `UNIT` resolvers target a single data source; `PIPELINE` resolvers run a
89 * sequence of {@link Function}s. The modern default is `APPSYNC_JS` code
90 * (a module exporting `request(ctx)` / `response(ctx)`); VTL mapping
91 * templates remain supported.
92 * ### Unit Resolvers
93 * **Example:** JavaScript unit resolver over a Lambda data source
94 * ```typescript
95 * const resolver = yield* AppSync.Resolver("AddResolver", {
96 * api,
97 * typeName: "Query",
98 * fieldName: "add",
99 * dataSource: lambdaDS,
100 * code: `
101 * export function request(ctx) {
102 * return { operation: "Invoke", payload: { args: ctx.args } };
103 * }
104 * export function response(ctx) {
105 * return ctx.result;
106 * }
107 * `,
108 * });
109 * ```
110 *
111 * ### Pipeline Resolvers
112 * **Example:** Pipeline resolver running one function
113 * ```typescript
114 * const fn = yield* AppSync.Function("Step", {
115 * api,
116 * dataSource: lambdaDS,
117 * code: fnCode,
118 * });
119 * const resolver = yield* AppSync.Resolver("PipelineResolver", {
120 * api,
121 * typeName: "Query",
122 * fieldName: "double",
123 * kind: "PIPELINE",
124 * pipelineFunctionIds: [fn.functionId],
125 * code: `
126 * export function request(ctx) { return {}; }
127 * export function response(ctx) { return ctx.prev.result; }
128 * `,
129 * });
130 * ```
131 *
132 * @resource
133 */
134export const ResolverResource = Resource<AppSyncResolver>(
135 "AWS.AppSync.Resolver",
136);
137
138export interface ResolverInputProps extends Omit<
139 {
140 [K in keyof ResolverProps]?: Input<ResolverProps[K]>;
141 },
142 "apiId" | "typeName" | "fieldName" | "dataSourceName"
143> {
144 /**
145 * The `GraphqlApi` this resolver belongs to (preferred). Alternatively
146 * pass a raw `apiId`.
147 */
148 api?: GraphqlApi;
149 apiId?: Input<string>;
150 typeName: Input<string>;
151 fieldName: Input<string>;
152 /**
153 * The data source a UNIT resolver targets (preferred). Alternatively
154 * pass a raw `dataSourceName`.
155 */
156 dataSource?: AppSyncDataSource;
157 dataSourceName?: Input<string>;
158}
159
160/**
161 * User-facing wrapper for the Resolver resource. Accepts `api: GraphqlApi`
162 * and `dataSource: DataSource` as the idiomatic way to wire a resolver.
163 */
164export const Resolver = (id: string, props: ResolverInputProps) =>
165 Effect.gen(function* () {
166 const { api, dataSource, ...rest } = props;
167 const apiId = rest.apiId ?? api?.apiId;
168 if (!apiId) {
169 return yield* Effect.die(
170 "Resolver requires either `api` (preferred) or an explicit `apiId`.",
171 );
172 }
173 const dataSourceName = rest.dataSourceName ?? dataSource?.name;
174 return yield* ResolverResource(id, {
175 ...rest,
176 apiId,
177 dataSourceName,
178 } as any);
179 });
180
181export const ResolverProvider = () =>
182 Provider.effect(
183 ResolverResource,
184 Effect.gen(function* () {
185 const getResolverSafe = (
186 apiId: string,
187 typeName: string,
188 fieldName: string,
189 ) =>
190 appsync.getResolver({ apiId, typeName, fieldName }).pipe(
191 Effect.map((response) => response.resolver),
192 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
193 );
194
195 const desiredWire = (news: ResolverProps) => ({
196 dataSourceName: news.dataSourceName,
197 kind: news.kind ?? "UNIT",
198 code: news.code,
199 runtime: news.code !== undefined ? APPSYNC_JS : undefined,
200 requestMappingTemplate: news.requestMappingTemplate,
201 responseMappingTemplate: news.responseMappingTemplate,
202 pipelineConfig:
203 news.pipelineFunctionIds === undefined
204 ? undefined
205 : { functions: news.pipelineFunctionIds },
206 maxBatchSize: news.maxBatchSize,
207 cachingConfig: news.cachingConfig,
208 });
209
210 const surface = (resolver: {
211 dataSourceName?: string;
212 kind?: string;
213 code?: string;
214 runtime?: appsync.AppSyncRuntime;
215 requestMappingTemplate?: string;
216 responseMappingTemplate?: string;
217 pipelineConfig?: appsync.PipelineConfig;
218 maxBatchSize?: number;
219 cachingConfig?: appsync.CachingConfig;
220 }) =>
221 JSON.parse(
222 JSON.stringify({
223 dataSourceName: resolver.dataSourceName,
224 kind: resolver.kind ?? "UNIT",
225 code: resolver.code,
226 runtime: resolver.runtime,
227 requestMappingTemplate: resolver.requestMappingTemplate,
228 responseMappingTemplate: resolver.responseMappingTemplate,
229 pipelineFunctions: resolver.pipelineConfig?.functions ?? [],
230 maxBatchSize: resolver.maxBatchSize ?? 0,
231 cachingConfig: resolver.cachingConfig,
232 }),
233 );
234
235 const toAttributes = (
236 apiId: string,
237 resolver: appsync.Resolver,
238 ): AppSyncResolver["Attributes"] => ({
239 apiId,
240 typeName: resolver.typeName!,
241 fieldName: resolver.fieldName!,
242 resolverArn: resolver.resolverArn!,
243 dataSourceName: resolver.dataSourceName,
244 kind: (resolver.kind ?? "UNIT") as "UNIT" | "PIPELINE",
245 });
246
247 return ResolverResource.Provider.of({
248 stables: ["apiId", "typeName", "fieldName", "resolverArn"],
249
250 // Sub-resource keyed entirely by its GraphQL API (apiId/typeName) with no global
251 // enumeration API of its own — nuke reaches it through the parent's
252 // deletion, so enumeration returns empty per the ProviderService
253 // doctrine.
254 list: () => Effect.succeed([]),
255
256 read: Effect.fn(function* ({ olds, output }) {
257 const apiId = output?.apiId ?? olds?.apiId;
258 const typeName = output?.typeName ?? olds?.typeName;
259 const fieldName = output?.fieldName ?? olds?.fieldName;
260 if (
261 apiId === undefined ||
262 typeName === undefined ||
263 fieldName === undefined
264 ) {
265 return undefined;
266 }
267 const resolver = yield* getResolverSafe(apiId, typeName, fieldName);
268 if (resolver?.resolverArn == null) return undefined;
269 return toAttributes(apiId, resolver);
270 }),
271
272 diff: Effect.fn(function* ({ news, olds }) {
273 if (!isResolved(news)) return undefined;
274 if (
275 news.apiId !== olds.apiId ||
276 news.typeName !== olds.typeName ||
277 news.fieldName !== olds.fieldName
278 ) {
279 return { action: "replace" } as const;
280 }
281 // data source / code / kind / pipeline converge via update
282 }),
283
284 reconcile: Effect.fn(function* ({ news, output, session }) {
285 const apiId = output?.apiId ?? news.apiId;
286 const typeName = news.typeName;
287 const fieldName = news.fieldName;
288 const desired = desiredWire(news);
289
290 // 1. OBSERVE
291 let observed = yield* getResolverSafe(apiId, typeName, fieldName);
292
293 if (observed?.resolverArn == null) {
294 // 2. ENSURE
295 const created = yield* retryConcurrentModification(
296 appsync.createResolver({
297 apiId,
298 typeName,
299 fieldName,
300 ...desired,
301 }),
302 );
303 observed = created.resolver!;
304 yield* session.note(`Created resolver ${typeName}.${fieldName}`);
305 } else if (!deepEqual(surface(observed), surface(desired))) {
306 // 3. SYNC
307 const updated = yield* retryConcurrentModification(
308 appsync.updateResolver({
309 apiId,
310 typeName,
311 fieldName,
312 ...desired,
313 }),
314 );
315 observed = updated.resolver ?? observed;
316 yield* session.note(`Updated resolver ${typeName}.${fieldName}`);
317 }
318
319 yield* session.note(`${typeName}.${fieldName}`);
320 return toAttributes(apiId, observed);
321 }),
322
323 delete: Effect.fn(function* ({ output }) {
324 yield* retryConcurrentModification(
325 appsync
326 .deleteResolver({
327 apiId: output.apiId,
328 typeName: output.typeName,
329 fieldName: output.fieldName,
330 })
331 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
332 );
333 }),
334 });
335 }),
336 );