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