1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../AdoptPolicy.ts";
6import { isResolved } from "../Diff.ts";
7import type { Input } from "../Input.ts";
8import { createPhysicalName } from "../PhysicalName.ts";
9import * as Provider from "../Provider.ts";
10import { Resource } from "../Resource.ts";
11import type { Providers } from "./Providers.ts";
12
13export interface FunctionTriggerCommonProps {
14 /** Branch-local target Function. */ function: {
15 projectId: string;
16 branchId: string;
17 slug: string;
18 };
19 /** Unique name among visible triggers, including inherited ones. Generated when omitted. */ name?: string;
20 /** POST delivery path, without a query string. @default "/" */ path?: string;
21 /** Enable delivery. Inherited triggers remain disabled unless explicitly enabled. */ enabled?: boolean;
22}
23export type FunctionTriggerProps = FunctionTriggerCommonProps &
24 (
25 | {
26 /** UTC schedule. */ type: "schedule";
27 /** Numeric five-field cron configuration. */ schedule: {
28 cron: string;
29 };
30 storageObjectCreated?: never;
31 }
32 | {
33 /** Successful upload event. */ type: "storage_object_created";
34 /** Same-branch bucket and optional byte-exact object-key prefix. */ storageObjectCreated: {
35 bucket: { projectId: string; branchId: string; bucketName: string };
36 prefix?: string;
37 };
38 schedule?: never;
39 }
40 );
41export interface FunctionTriggerAttributes {
42 /** Owning project. */ projectId: string;
43 /** Owning branch. */ branchId: string;
44 /** Project-wide trigger identifier. */ triggerId: string;
45 /** Target Function slug. */ slug: string;
46 /** Trigger name. */ name: string;
47 /** Event type; changing it replaces the trigger. */ type:
48 | "schedule"
49 | "storage_object_created";
50 /** Configured delivery route. */ path: string;
51 /** Whether future delivery is enabled. */ enabled: boolean;
52 /** Monotonic configuration version, not the event schema version. */ version: number;
53 /** True when the configuration comes from an ancestor. */ inherited: boolean;
54 /** Next cron time; absent for object events or disabled schedules. */ nextRunAt:
55 | string
56 | null
57 | undefined;
58}
59export interface FunctionTrigger extends Resource<
60 "Neon.FunctionTrigger",
61 FunctionTriggerProps,
62 FunctionTriggerAttributes,
63 never,
64 Providers
65> {}
66
67/**
68 * Deliver scheduled or object-created events as HTTP POSTs to a Function.
69 * For Effect handlers, prefer BucketEventSource or CronEventSource: they create
70 * this resource and register its handler route together. Use FunctionTrigger
71 * directly for an existing native HTTP handler.
72 * Check the edge-attested header and validate the payload using
73 * `decodeFunctionTriggerEvent`. Invocation IDs support application idempotency;
74 * Alchemy does not promise exactly-once delivery or undocumented retries.
75 *
76 * ### Schedule a Function
77 * **Example:** Nightly POST
78 * ```typescript
79 * const nightly = yield* Neon.FunctionTrigger("Nightly", {
80 * function: api, type: "schedule", schedule: { cron: "0 2 * * *" }, path: "/jobs/nightly",
81 * });
82 * ```
83 *
84 * ### Watch Uploads
85 * **Example:** Process incoming objects
86 * ```typescript
87 * yield* Neon.FunctionTrigger("Uploads", {
88 * function: api, type: "storage_object_created",
89 * storageObjectCreated: { bucket: uploads, prefix: "incoming/" }, path: "/jobs/upload",
90 * });
91 * ```
92 *
93 * @resource
94 * @product Function
95 */
96export const FunctionTrigger = Resource<FunctionTrigger>(
97 "Neon.FunctionTrigger",
98);
99export class FunctionTriggerConfigurationError extends Data.TaggedError(
100 "FunctionTriggerConfigurationError",
101)<{ message: string }> {}
102const scopeOf = (fn: { projectId: string; branchId: string }) => ({
103 project_id: fn.projectId,
104 branch_id: fn.branchId,
105});
106const observe = Effect.fn(function* (
107 scope: { project_id: string; branch_id: string },
108 identity: { id?: string; name?: string },
109) {
110 const { triggers } = yield* Neon.listProjectBranchTriggers(scope);
111 return triggers.find((trigger) =>
112 identity.id
113 ? trigger.trigger_id === identity.id
114 : trigger.name === identity.name,
115 );
116});
117const attrs = (
118 fn: { projectId: string; branchId: string },
119 trigger: Neon.Trigger,
120): FunctionTriggerAttributes => ({
121 projectId: fn.projectId,
122 branchId: fn.branchId,
123 triggerId: trigger.trigger_id,
124 slug: trigger.function_slug,
125 name: trigger.name,
126 type: trigger.type,
127 path: trigger.function_path,
128 enabled: trigger.enabled,
129 version: trigger.version,
130 inherited: trigger.inherited,
131 nextRunAt: trigger.type === "schedule" ? trigger.next_run_at : undefined,
132});
133const desiredConfig = Effect.fn(function* (
134 news: Input.Resolve<FunctionTriggerProps>,
135 name: string,
136 inherited: boolean,
137) {
138 const path = news.path ?? "/";
139 if (
140 !path.startsWith("/") ||
141 path.startsWith("//") ||
142 path.includes("?") ||
143 path.includes("#")
144 )
145 return yield* new FunctionTriggerConfigurationError({
146 message:
147 "Trigger path must be an absolute path without query or fragment",
148 });
149 const common = {
150 name,
151 function_slug: news.function.slug,
152 function_path: path,
153 enabled: news.enabled ?? !inherited,
154 };
155 if (news.type === "schedule")
156 return { ...common, type: "schedule" as const, schedule: news.schedule };
157 const bucket = news.storageObjectCreated.bucket;
158 if (
159 bucket.projectId !== news.function.projectId ||
160 bucket.branchId !== news.function.branchId
161 )
162 return yield* new FunctionTriggerConfigurationError({
163 message: "Function and trigger bucket must belong to the same branch",
164 });
165 return {
166 ...common,
167 type: "storage_object_created" as const,
168 storage_object_created: {
169 bucket_name: bucket.bucketName,
170 ...(news.storageObjectCreated.prefix
171 ? { prefix: news.storageObjectCreated.prefix }
172 : {}),
173 },
174 };
175});
176export const FunctionTriggerProvider = () =>
177 Provider.succeed(FunctionTrigger, {
178 stables: ["projectId", "branchId", "triggerId"],
179 list: Effect.fn(function* () {
180 const result: FunctionTriggerAttributes[] = [];
181 for (const project of yield* Neon.listProjects
182 .items({})
183 .pipe(Stream.runCollect)) {
184 for (const branch of yield* Neon.listProjectBranches
185 .items({ project_id: project.id })
186 .pipe(Stream.runCollect)) {
187 const { triggers } = yield* Neon.listProjectBranchTriggers({
188 project_id: project.id,
189 branch_id: branch.id,
190 });
191 result.push(
192 ...triggers.map((trigger) =>
193 attrs({ projectId: project.id, branchId: branch.id }, trigger),
194 ),
195 );
196 }
197 }
198 return result;
199 }),
200 diff: Effect.fn(function* ({ news, output }) {
201 if (!isResolved(news) || !output) return;
202 if (
203 news.type !== output.type ||
204 news.function.projectId !== output.projectId ||
205 news.function.branchId !== output.branchId
206 )
207 return { action: "replace", deleteFirst: true };
208 }),
209 read: Effect.fn(function* ({ id, olds, output }) {
210 if (!output && !olds?.function) return undefined;
211 const fn = output ?? olds!.function;
212 const name =
213 output?.name ??
214 olds?.name ??
215 (yield* createPhysicalName({ id, maxLength: 256 }));
216 const found = yield* observe(scopeOf(fn), {
217 id: output?.triggerId,
218 name,
219 });
220 if (!found) return undefined;
221 const result = attrs(fn, found);
222 return output ? result : Unowned(result);
223 }),
224 reconcile: Effect.fn(function* ({ id, news, output }) {
225 const scope = scopeOf(news.function);
226 const name =
227 news.name ??
228 output?.name ??
229 (yield* createPhysicalName({ id, maxLength: 256 }));
230 let current = yield* observe(scope, { id: output?.triggerId, name });
231 const desired = yield* desiredConfig(
232 news,
233 name,
234 current?.inherited ?? false,
235 );
236 if (!current)
237 current = yield* Neon.createProjectBranchTrigger({
238 ...scope,
239 body: desired,
240 }).pipe(
241 Effect.map((response) => response.trigger),
242 Effect.catchTag("Conflict", (error) =>
243 observe(scope, { name }).pipe(
244 Effect.flatMap((trigger) =>
245 trigger ? Effect.succeed(trigger) : Effect.fail(error),
246 ),
247 ),
248 ),
249 );
250 const observed = {
251 name: current.name,
252 function_slug: current.function_slug,
253 function_path: current.function_path,
254 enabled: current.enabled,
255 ...(current.type === "schedule"
256 ? { type: current.type, schedule: current.schedule }
257 : {
258 type: current.type,
259 storage_object_created: current.storage_object_created,
260 }),
261 };
262 if (JSON.stringify(observed) !== JSON.stringify(desired))
263 current = (yield* Neon.updateProjectBranchTrigger({
264 ...scope,
265 trigger_id: current.trigger_id,
266 body: desired,
267 })).trigger;
268 return attrs(news.function, current);
269 }),
270 delete: Effect.fn(function* ({ output }) {
271 const scope = scopeOf(output);
272 if (!(yield* observe(scope, { id: output.triggerId }))) return;
273 yield* Neon.deleteProjectBranchTrigger({
274 ...scope,
275 trigger_id: output.triggerId,
276 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
277 }),
278 });