1import * as Alchemy from "@/index";
2import { Function } from "@/Neon/Function";
3import { FunctionLogs } from "@/Neon/FunctionProvider";
4import * as Schedule from "effect/Schedule";
5import { providers } from "@/Neon/Providers";
6import * as Test from "@/Test/Alchemy";
7import * as SDK from "@distilled.cloud/neon";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import * as HttpClientResponse from "effect/http/HttpClientResponse";
13import Constructor from "./fixtures/function-constructor.ts";
14import LayerLive, { LayerFunction } from "./fixtures/function-layer.ts";
15import { project } from "./fixtures/function-form-resources.ts";
16
17const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
18 providers: providers(),
19});
20const Stack = Alchemy.Stack(
21 "NeonFunctionForms",
22 { providers: providers(), state: Alchemy.localState() },
23 Effect.gen(function* () {
24 const scope = yield* project;
25 const constructor = yield* Constructor;
26 const layer = yield* LayerFunction;
27 const hono = yield* Function("Hono", {
28 project: scope,
29 main: new URL("./fixtures/function-hono.ts", import.meta.url).href,
30 });
31 const bare = yield* Function("Bare", {
32 project: scope,
33 main: new URL("./fixtures/function-bare.ts", import.meta.url).href,
34 });
35 return {
36 constructor: constructor.url,
37 layer: layer.url,
38 hono: hono.url,
39 bare: bare.url,
40 host: hono,
41 };
42 }).pipe(Effect.provide(LayerLive)),
43);
44const stack = beforeAll(destroy(Stack).pipe(Effect.andThen(deploy(Stack))));
45afterAll(destroy(Stack));
46
47test(
48 "Effect constructor and make Layer entrypoints invoke across a URL-only binding",
49 Effect.gen(function* () {
50 const urls = yield* stack;
51 const client = yield* HttpClient.HttpClient;
52 expect(yield* (yield* client.get(urls.constructor)).text).toBe(
53 "constructor",
54 );
55 expect(yield* (yield* client.get(urls.layer)).text).toBe(
56 "layer:constructor",
57 );
58 }),
59 {
60 tags: [
61 "provider:neon",
62 "provider:neon:function",
63 "provider:neon:project",
64 "live",
65 ],
66 timeout: 120_000,
67 },
68);
69
70test(
71 "bare and Hono native exports preserve application authentication",
72 Effect.gen(function* () {
73 const urls = yield* stack;
74 const client = yield* HttpClient.HttpClient;
75 expect(yield* (yield* client.get(urls.bare)).text).toBe("bare-v2");
76 expect(yield* (yield* client.get(urls.hono)).text).toBe("hono");
77 expect((yield* client.get(`${urls.hono}private`)).status).toBe(401);
78 const authorized = yield* client.execute(
79 HttpClientRequest.get(`${urls.hono}private`).pipe(
80 HttpClientRequest.setHeader("authorization", "Bearer test-caller"),
81 ),
82 );
83 expect(yield* authorized.text).toBe("authorized");
84 }),
85 {
86 tags: [
87 "provider:neon",
88 "provider:neon:function",
89 "provider:neon:project",
90 "live",
91 ],
92 timeout: 120_000,
93 },
94);
95
96test.provider(
97 "Function logs expose only the requested Function's application records",
98 () =>
99 Effect.gen(function* () {
100 const { host, bare } = yield* stack;
101 const service = `neon-function/${host.slug}`;
102 const record = (second: number): SDK.ProjectBranchLogRecord => ({
103 timestamp: `2026-09-17T00:00:0${second}Z`,
104 message: `fixture-${second}`,
105 source: "function",
106 service_name: service,
107 entity_id: "opaque-runtime-instance",
108 attributes: {},
109 });
110 const requests: Record<string, unknown>[] = [];
111 const fixture = (
112 respond: (page: number) => SDK.ProjectBranchLogsQueryResponse,
113 ) =>
114 HttpClient.make((request) =>
115 Effect.sync(() => {
116 if (request.body._tag !== "Uint8Array")
117 throw new Error("Expected JSON log query");
118 requests.push(
119 JSON.parse(new TextDecoder().decode(request.body.body)),
120 );
121 return HttpClientResponse.fromWeb(
122 request,
123 Response.json(respond(requests.length)),
124 );
125 }),
126 );
127 const selected = yield* FunctionLogs(host, { limit: 3 }).pipe(
128 Effect.provideService(
129 HttpClient.HttpClient,
130 fixture((page) =>
131 page === 1
132 ? {
133 logs: [
134 record(3),
135 {
136 ...record(3),
137 entity_id: host.functionId,
138 service_name: "neon-function/another",
139 },
140 { ...record(3), service_name: undefined },
141 ],
142 is_truncated: true,
143 next_cursor: "older",
144 }
145 : { logs: [record(2), record(1)], is_truncated: false },
146 ),
147 ),
148 Effect.provide(SDK.fromApiKey({ apiKey: "fixture-key" })),
149 );
150 expect(selected.map((line) => line.message)).toEqual([
151 "fixture-1",
152 "fixture-2",
153 "fixture-3",
154 ]);
155 expect(requests.length).toBe(2);
156 expect(requests[0]).toMatchObject({
157 source: "function",
158 service_name: service,
159 sort_order: "desc",
160 since: "1h",
161 limit: 3,
162 });
163 expect(typeof requests[0].end_time).toBe("string");
164 expect(requests[1]).toEqual({ ...requests[0], cursor: "older" });
165 for (const mode of ["repeated", "missing", "bounded"] as const) {
166 requests.length = 0;
167 const result = yield* FunctionLogs(host, { limit: 3 }).pipe(
168 Effect.provideService(
169 HttpClient.HttpClient,
170 fixture((page) => ({
171 logs: [],
172 is_truncated: true,
173 next_cursor:
174 mode === "missing"
175 ? undefined
176 : mode === "repeated"
177 ? "same"
178 : `page-${page}`,
179 })),
180 ),
181 Effect.provide(SDK.fromApiKey({ apiKey: "fixture-key" })),
182 Effect.as("unexpected-success"),
183 Effect.catchTag("FunctionLogQueryError", (error) =>
184 Effect.succeed(error.reason),
185 ),
186 );
187 expect(result).toBe(
188 mode === "bounded" ? "pagination-limit" : "invalid-cursor",
189 );
190 expect(requests.length).toBe(
191 mode === "bounded" ? 8 : mode === "missing" ? 1 : 2,
192 );
193 }
194 const client = yield* HttpClient.HttpClient;
195 const since = yield* Effect.sync(() => new Date());
196 expect(
197 yield* client
198 .get(bare)
199 .pipe(Effect.flatMap((response) => response.text)),
200 ).toBe("bare-v2");
201 expect(
202 yield* client
203 .get(host.url)
204 .pipe(Effect.flatMap((response) => response.text)),
205 ).toBe("hono");
206 const marker = "alchemy-neon-hono-log-probe";
207 // Log ingestion is eventually consistent; poll up to 2 minutes.
208 const lines = yield* FunctionLogs(host, { limit: 100, since }).pipe(
209 Effect.repeat({
210 schedule: Schedule.spaced("5 seconds"),
211 until: (lines) => lines.some((line) => line.message.includes(marker)),
212 }),
213 Effect.timeout("2 minutes"),
214 );
215 const observed = yield* SDK.queryProjectBranchLogs({
216 project_id: host.projectId,
217 branch_id: host.branchId,
218 source: "function",
219 service_name: service,
220 start_time: since.toISOString(),
221 sort_order: "desc",
222 limit: 1000,
223 });
224 yield* Effect.logInfo(
225 JSON.stringify({
226 functionLogMetadata: {
227 records: observed.logs.length,
228 markerRecords: observed.logs.filter((line) =>
229 line.message.includes(marker),
230 ).length,
231 serviceMatches: observed.logs.filter(
232 (line) => line.service_name === service,
233 ).length,
234 functionSources: observed.logs.filter(
235 (line) => line.source === "function",
236 ).length,
237 entityMatchesFunctionId: observed.logs.filter(
238 (line) => line.entity_id === host.functionId,
239 ).length,
240 entityMatchesSlug: observed.logs.filter(
241 (line) => line.entity_id === host.slug,
242 ).length,
243 entityPresent: observed.logs.filter(
244 (line) => line.entity_id !== undefined,
245 ).length,
246 truncated: observed.is_truncated,
247 },
248 }),
249 );
250 expect(lines.some((line) => line.message.includes(marker))).toBe(true);
251 const functions = yield* SDK.listProjectBranchFunctions({
252 project_id: host.projectId,
253 branch_id: host.branchId,
254 });
255 const sibling = functions.functions.find(
256 (fn) => fn.invocation_url === bare,
257 );
258 if (!sibling)
259 return yield* Effect.fail(
260 new Error("Missing sibling Function for log isolation check"),
261 );
262 // The sibling's records ingest independently; poll until they appear.
263 const unrelated = yield* FunctionLogs(
264 { ...host, functionId: sibling.id, slug: sibling.slug },
265 { limit: 100, since },
266 ).pipe(
267 Effect.repeat({
268 schedule: Schedule.spaced("5 seconds"),
269 until: (lines) => lines.length > 0,
270 }),
271 Effect.timeout("2 minutes"),
272 );
273 expect(unrelated.length).toBeGreaterThan(0);
274 expect(unrelated.some((line) => line.message.includes(marker))).toBe(
275 false,
276 );
277 }),
278 {
279 tags: [
280 "provider:neon",
281 "provider:neon:function",
282 "provider:neon:project",
283 "live",
284 ],
285 timeout: 300_000,
286 },
287);