1import * as appsync from "@distilled.cloud/aws/appsync";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { deepEqual, isResolved } from "../../Diff.ts";
5import type { Input } from "../../Input.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { retryConcurrentModification, sanitizeAppSyncName } from "./common.ts";
11import type { AppSyncDataSource } from "./DataSource.ts";
12import type { GraphqlApi } from "./GraphqlApi.ts";
13import { APPSYNC_JS } from "./Resolver.ts";
14
15export interface FunctionProps {
16 /**
17 * ID of the GraphQL API. Usually derived from `api.apiId` by the
18 * {@link Function} wrapper.
19 */
20 apiId: string;
21 /**
22 * Name of the function (`[_A-Za-z][_0-9A-Za-z]*` — no dashes). If
23 * omitted, a deterministic name is generated (dashes sanitized to
24 * underscores). Names are mutable.
25 */
26 name?: string;
27 /** Description of the function. */
28 description?: string;
29 /** Name of the data source this function targets. */
30 dataSourceName: string;
31 /**
32 * `APPSYNC_JS` function code exporting `request(ctx)` and
33 * `response(ctx)`. When set, the runtime defaults to APPSYNC_JS 1.0.0.
34 */
35 code?: string;
36 /** VTL request mapping template (legacy alternative to `code`). */
37 requestMappingTemplate?: string;
38 /** VTL response mapping template (legacy alternative to `code`). */
39 responseMappingTemplate?: string;
40 /**
41 * The VTL function version — only used with mapping templates.
42 * @default "2018-05-29" (when templates are used)
43 */
44 functionVersion?: string;
45 /** Maximum batch size for batched Lambda invocations (0–2000). */
46 maxBatchSize?: number;
47}
48
49export interface AppSyncFunction extends Resource<
50 "AWS.AppSync.Function",
51 FunctionProps,
52 {
53 /** The API this function belongs to. */
54 apiId: string;
55 /** The immutable function ID (referenced by pipeline resolvers). */
56 functionId: string;
57 /** The function ARN. */
58 functionArn: string;
59 /** The function name. */
60 name: string;
61 /** The data source this function targets. */
62 dataSourceName: string;
63 },
64 never,
65 Providers
66> {}
67
68/**
69 * An AppSync pipeline function — a reusable step composed by `PIPELINE`
70 * resolvers.
71 * ### Creating Pipeline Functions
72 * **Example:** JavaScript pipeline function over a Lambda data source
73 * ```typescript
74 * const step = yield* AppSync.Function("InvokeStep", {
75 * api,
76 * dataSource: lambdaDS,
77 * code: `
78 * export function request(ctx) {
79 * return { operation: "Invoke", payload: { args: ctx.args } };
80 * }
81 * export function response(ctx) {
82 * return ctx.result;
83 * }
84 * `,
85 * });
86 * // reference from a PIPELINE resolver:
87 * // pipelineFunctionIds: [step.functionId]
88 * ```
89 *
90 * @resource
91 */
92export const FunctionResource = Resource<AppSyncFunction>(
93 "AWS.AppSync.Function",
94);
95
96export interface FunctionInputProps extends Omit<
97 {
98 [K in keyof FunctionProps]?: Input<FunctionProps[K]>;
99 },
100 "apiId" | "dataSourceName"
101> {
102 /**
103 * The `GraphqlApi` this function belongs to (preferred). Alternatively
104 * pass a raw `apiId`.
105 */
106 api?: GraphqlApi;
107 apiId?: Input<string>;
108 /**
109 * The data source this function targets (preferred). Alternatively pass
110 * a raw `dataSourceName`.
111 */
112 dataSource?: AppSyncDataSource;
113 dataSourceName?: Input<string>;
114}
115
116/**
117 * User-facing wrapper for the pipeline Function resource. Accepts
118 * `api: GraphqlApi` and `dataSource: DataSource` directly.
119 */
120export const Function = (id: string, props: FunctionInputProps) =>
121 Effect.gen(function* () {
122 const { api, dataSource, ...rest } = props;
123 const apiId = rest.apiId ?? api?.apiId;
124 if (!apiId) {
125 return yield* Effect.die(
126 "AppSync.Function requires either `api` (preferred) or an explicit `apiId`.",
127 );
128 }
129 const dataSourceName = rest.dataSourceName ?? dataSource?.name;
130 if (!dataSourceName) {
131 return yield* Effect.die(
132 "AppSync.Function requires either `dataSource` (preferred) or an explicit `dataSourceName`.",
133 );
134 }
135 return yield* FunctionResource(id, {
136 ...rest,
137 apiId,
138 dataSourceName,
139 } as any);
140 });
141
142export const FunctionProvider = () =>
143 Provider.effect(
144 FunctionResource,
145 Effect.gen(function* () {
146 const createName = Effect.fn(function* (
147 id: string,
148 props: Pick<FunctionProps, "name">,
149 ) {
150 return (
151 props.name ??
152 sanitizeAppSyncName(yield* createPhysicalName({ id, maxLength: 64 }))
153 );
154 });
155
156 const getFunctionSafe = (apiId: string, functionId: string) =>
157 appsync.getFunction({ apiId, functionId }).pipe(
158 Effect.map((response) => response.functionConfiguration),
159 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
160 );
161
162 /** Find a function by name (fallback when no functionId is cached). */
163 const findFunctionByName = Effect.fn(function* (
164 apiId: string,
165 name: string,
166 ) {
167 const pages = yield* appsync.listFunctions.pages({ apiId }).pipe(
168 Stream.runCollect,
169 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
170 );
171 return Array.from(pages)
172 .flatMap((page) => page.functions ?? [])
173 .find((fn) => fn.name === name);
174 });
175
176 const desiredWire = (news: FunctionProps, name: string) => ({
177 name,
178 description: news.description,
179 dataSourceName: news.dataSourceName,
180 code: news.code,
181 runtime: news.code !== undefined ? APPSYNC_JS : undefined,
182 requestMappingTemplate: news.requestMappingTemplate,
183 responseMappingTemplate: news.responseMappingTemplate,
184 functionVersion:
185 news.functionVersion ??
186 (news.code === undefined ? "2018-05-29" : undefined),
187 maxBatchSize: news.maxBatchSize,
188 });
189
190 const surface = (fn: {
191 name?: string;
192 description?: string;
193 dataSourceName?: string;
194 code?: string;
195 runtime?: appsync.AppSyncRuntime;
196 requestMappingTemplate?: string;
197 responseMappingTemplate?: string;
198 maxBatchSize?: number;
199 }) =>
200 JSON.parse(
201 JSON.stringify({
202 name: fn.name,
203 description: fn.description,
204 dataSourceName: fn.dataSourceName,
205 code: fn.code,
206 runtime: fn.runtime,
207 requestMappingTemplate: fn.requestMappingTemplate,
208 responseMappingTemplate: fn.responseMappingTemplate,
209 maxBatchSize: fn.maxBatchSize ?? 0,
210 }),
211 );
212
213 const toAttributes = (
214 apiId: string,
215 fn: appsync.FunctionConfiguration,
216 ): AppSyncFunction["Attributes"] => ({
217 apiId,
218 functionId: fn.functionId!,
219 functionArn: fn.functionArn!,
220 name: fn.name!,
221 dataSourceName: fn.dataSourceName!,
222 });
223
224 return FunctionResource.Provider.of({
225 stables: ["apiId", "functionId", "functionArn"],
226
227 // Sub-resource keyed entirely by its GraphQL API (apiId) with no global
228 // enumeration API of its own — nuke reaches it through the parent's
229 // deletion, so enumeration returns empty per the ProviderService
230 // doctrine.
231 list: () => Effect.succeed([]),
232
233 read: Effect.fn(function* ({ id, olds, output }) {
234 const apiId = output?.apiId ?? olds?.apiId;
235 if (apiId === undefined) return undefined;
236 const fn =
237 output?.functionId !== undefined
238 ? yield* getFunctionSafe(apiId, output.functionId)
239 : yield* findFunctionByName(
240 apiId,
241 yield* createName(id, olds ?? {}),
242 );
243 if (fn?.functionId == null) return undefined;
244 return toAttributes(apiId, fn);
245 }),
246
247 diff: Effect.fn(function* ({ news, olds }) {
248 if (!isResolved(news)) return undefined;
249 if (news.apiId !== olds.apiId) {
250 return { action: "replace" } as const;
251 }
252 // name/code/data source converge via updateFunction
253 }),
254
255 reconcile: Effect.fn(function* ({ id, news, output, session }) {
256 const apiId = output?.apiId ?? news.apiId;
257 const name = news.name ?? (yield* createName(id, news));
258 const desired = desiredWire(news, name);
259
260 // 1. OBSERVE — functionId cache first, then name scan.
261 let observed =
262 output?.functionId !== undefined
263 ? yield* getFunctionSafe(apiId, output.functionId)
264 : yield* findFunctionByName(apiId, name);
265
266 if (observed?.functionId == null) {
267 // 2. ENSURE
268 const created = yield* retryConcurrentModification(
269 appsync.createFunction({ apiId, ...desired }),
270 );
271 observed = created.functionConfiguration!;
272 yield* session.note(`Created pipeline function ${name}`);
273 } else if (!deepEqual(surface(observed), surface(desired))) {
274 // 3. SYNC
275 const updated = yield* retryConcurrentModification(
276 appsync.updateFunction({
277 apiId,
278 functionId: observed.functionId,
279 ...desired,
280 }),
281 );
282 observed = updated.functionConfiguration ?? observed;
283 yield* session.note(`Updated pipeline function ${name}`);
284 }
285
286 yield* session.note(name);
287 return toAttributes(apiId, observed);
288 }),
289
290 delete: Effect.fn(function* ({ output }) {
291 yield* retryConcurrentModification(
292 appsync
293 .deleteFunction({
294 apiId: output.apiId,
295 functionId: output.functionId,
296 })
297 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
298 );
299 }),
300 });
301 }),
302 );