1import { Project } from "@/Neon/Project";
2import { Function, type FunctionProps } from "@/Neon/Function";
3import * as Provider from "@/Provider";
4import { providers } from "@/Neon/Providers";
5import * as Test from "@/Test/Alchemy";
6import * as NeonApi from "@distilled.cloud/neon";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as HttpClient from "effect/http/HttpClient";
10import {
11 functionRolloutSamples,
12 functionRolloutTimeout,
13 functionTextSamples,
14} from "./FunctionRollout.ts";
15
16const { test } = Test.make({ providers: providers() });
17
18test.provider(
19 "incomplete never-created Function rows have no recoverable physical identity",
20 (stack) =>
21 Effect.gen(function* () {
22 yield* stack.destroy();
23 const provider = yield* Provider.providerForMode(
24 yield* Provider.Provider<Function>("Neon.Function"),
25 "live",
26 );
27 const cases: FunctionProps[] = [
28 { branch: { projectId: "", branchId: "" }, main: "never-created.ts" },
29 {
30 branch: { projectId: "project", branchId: "" },
31 main: "never-created.ts",
32 },
33 {
34 branch: { projectId: "", branchId: "branch" },
35 main: "never-created.ts",
36 },
37 { project: { projectId: "" }, main: "never-created.ts" },
38 ];
39 for (const olds of cases) {
40 expect(
41 yield* provider.read!({
42 id: "Incomplete",
43 fqn: "Incomplete",
44 instanceId: "never-created",
45 olds,
46 output: undefined,
47 }),
48 ).toBeUndefined();
49 }
50 yield* stack.destroy();
51 }),
52 {
53 tags: ["provider:neon", "provider:neon:function", "live"],
54 timeout: 120_000,
55 },
56);
57
58// Live lifecycles below poll deployment rollout for minutes; skip under --fast.
59test.provider.skipIf(!!process.env.FAST)(
60 "native Function create, no-op, name update, environment removal and deletion",
61 (stack) =>
62 Effect.gen(function* () {
63 yield* stack.destroy();
64 const main = new URL("./fixtures/function-native.ts", import.meta.url)
65 .href;
66 const deploy = (env: Record<string, string>, name?: string) =>
67 stack.deploy(
68 Effect.gen(function* () {
69 const project = yield* Project("FunctionProject", {
70 region: "aws-us-east-2",
71 });
72 const api = yield* Function("Api", { project, main, env, name });
73 return { project, api };
74 }),
75 );
76 const first = yield* deploy({
77 FUNCTION_TEST_VALUE: "one",
78 FUNCTION_TEST_REMOVED: "remove-me",
79 });
80 expect(first.api.slug).toMatch(/^[a-z0-9]{1,20}$/);
81 expect(first.api.currentDeploymentId).toBe(first.api.activeDeploymentId);
82 const client = yield* HttpClient.HttpClient;
83 const response = yield* client.get(`${first.api.url}env`);
84 expect(yield* response.json).toMatchObject({
85 value: "one",
86 removed: "remove-me",
87 hasDatabase: true,
88 hasAccountKey: false,
89 });
90 const observed = yield* NeonApi.getProjectBranchFunction({
91 project_id: first.api.projectId,
92 branch_id: first.api.branchId,
93 slug: first.api.slug,
94 });
95 expect(observed.function.active_deployment?.id).toBe(
96 first.api.activeDeploymentId,
97 );
98 const noop = yield* deploy({
99 FUNCTION_TEST_VALUE: "one",
100 FUNCTION_TEST_REMOVED: "remove-me",
101 });
102 expect(noop.api.activeDeploymentId).toBe(first.api.activeDeploymentId);
103 const renamed = yield* deploy(
104 { FUNCTION_TEST_VALUE: "one", FUNCTION_TEST_REMOVED: "remove-me" },
105 "Friendly name",
106 );
107 expect(renamed.api.name).toBe("Friendly name");
108 expect(renamed.api.activeDeploymentId).toBe(first.api.activeDeploymentId);
109 const updated = yield* deploy({ FUNCTION_TEST_VALUE: "two" });
110 expect(updated.api.functionId).toBe(first.api.functionId);
111 expect(updated.api.name).toBe(first.api.slug);
112 expect(updated.api.activeDeploymentId).not.toBe(
113 first.api.activeDeploymentId,
114 );
115 expect(updated.api.url).toBe(first.api.url);
116 const changedEnv = yield* functionRolloutSamples(
117 client.get(`${updated.api.url}env`).pipe(
118 Effect.flatMap((response) => {
119 expect(response.status).toBe(200);
120 return response.json;
121 }),
122 ),
123 (body) =>
124 typeof body === "object" &&
125 body !== null &&
126 "value" in body &&
127 body.value === "two" &&
128 !("removed" in body),
129 );
130 yield* Effect.logInfo(
131 JSON.stringify({
132 functionEnvironmentUpdate: {
133 projectId: updated.api.projectId,
134 branchId: updated.api.branchId,
135 slug: updated.api.slug,
136 previousDeployment: first.api.activeDeploymentId,
137 activeDeployment: updated.api.activeDeploymentId,
138 observed: changedEnv,
139 },
140 }),
141 );
142 for (const body of changedEnv) {
143 expect(body).toMatchObject({ value: "two" });
144 expect(body).not.toHaveProperty("removed");
145 }
146 const state = yield* NeonApi.getProjectBranchFunction({
147 project_id: updated.api.projectId,
148 branch_id: updated.api.branchId,
149 slug: updated.api.slug,
150 });
151 expect(state.function.active_deployment?.environment).not.toContain(
152 "FUNCTION_TEST_REMOVED",
153 );
154 yield* stack.deploy(
155 Project("FunctionProject", { region: "aws-us-east-2" }),
156 );
157 const absent = yield* NeonApi.getProjectBranchFunction({
158 project_id: updated.api.projectId,
159 branch_id: updated.api.branchId,
160 slug: updated.api.slug,
161 }).pipe(
162 Effect.as(false),
163 Effect.catchTag("NotFound", () => Effect.succeed(true)),
164 );
165 expect(absent).toBe(true);
166 yield* stack.destroy();
167 }),
168 {
169 tags: [
170 "provider:neon",
171 "provider:neon:function",
172 "provider:neon:project",
173 "live",
174 ],
175 timeout: functionRolloutTimeout,
176 },
177);
178
179test.provider.skipIf(!!process.env.FAST)(
180 "code update after a config-only deployment serves the requested artifact",
181 (stack) =>
182 Effect.gen(function* () {
183 yield* stack.destroy();
184 const deploy = (main: string, value: string) =>
185 stack.deploy(
186 Effect.gen(function* () {
187 const project = yield* Project("CodeProject", {
188 region: "aws-us-east-2",
189 });
190 return yield* Function("Api", {
191 project,
192 main,
193 env: { FUNCTION_TEST_VALUE: value },
194 });
195 }),
196 );
197 const main = new URL("./fixtures/function-native.ts", import.meta.url)
198 .href;
199 const first = yield* deploy(main, "one");
200 const client = yield* HttpClient.HttpClient;
201 expect(yield* (yield* client.get(first.url)).text).toBe("native-v1");
202 const configured = yield* deploy(main, "two");
203 const code = yield* deploy(
204 new URL("./fixtures/function-bare.ts", import.meta.url).href,
205 "two",
206 );
207 expect(code.codeHash).not.toBe(configured.codeHash);
208 expect(code.activeDeploymentId).not.toBe(configured.activeDeploymentId);
209 const observed = yield* NeonApi.getProjectBranchFunction({
210 project_id: code.projectId,
211 branch_id: code.branchId,
212 slug: code.slug,
213 });
214 expect(observed.function.active_deployment?.id).toBe(
215 code.activeDeploymentId,
216 );
217 yield* Effect.logInfo(
218 JSON.stringify({
219 functionCodeUpdate: {
220 projectId: code.projectId,
221 branchId: code.branchId,
222 slug: code.slug,
223 previousDeployment: configured.activeDeploymentId,
224 activeDeployment: code.activeDeploymentId,
225 status: observed.function.active_deployment?.status,
226 previousCodeHash: configured.codeHash,
227 codeHash: code.codeHash,
228 },
229 }),
230 );
231 expect(code.functionId).toBe(first.functionId);
232 expect(code.url).toBe(first.url);
233 const samples = yield* functionTextSamples(
234 code.url,
235 (body) => body === "bare-v2",
236 );
237 yield* Effect.logInfo(JSON.stringify({ functionStableUrl: samples }));
238 const noop = yield* deploy(
239 new URL("./fixtures/function-bare.ts", import.meta.url).href,
240 "two",
241 );
242 expect(noop.activeDeploymentId).toBe(code.activeDeploymentId);
243 yield* stack.destroy();
244 expect(
245 yield* NeonApi.getProject({ project_id: code.projectId }).pipe(
246 Effect.as(false),
247 Effect.catchTag("NotFound", () => Effect.succeed(true)),
248 ),
249 ).toBe(true);
250 for (const text of samples) expect(text).toBe("bare-v2");
251 }),
252 {
253 tags: [
254 "provider:neon",
255 "provider:neon:function",
256 "provider:neon:project",
257 "live",
258 ],
259 timeout: functionRolloutTimeout,
260 },
261);