1import { expect } from "alchemy-test";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import type * as HttpClientResponse from "effect/http/HttpClientResponse";
8import type { UserRow } from "./fixtures/routes.ts";
9
10export class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
11 status: number;
12 body: string;
13}> {}
14
15// Bounded spaced schedule — rides out fresh-workers.dev cold-start
16// propagation without blowing past the test timeout.
17export const ready = Schedule.max([
18 Schedule.spaced("2 seconds"),
19 Schedule.recurs(45),
20]);
21
22/** Retry an HTTP call until it returns 200. */
23export const untilOk = <E, R>(
24 eff: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
25) =>
26 eff.pipe(
27 Effect.flatMap((res) =>
28 res.status === 200
29 ? Effect.succeed(res)
30 : res.text.pipe(
31 Effect.flatMap((body) =>
32 Effect.fail(new WorkerNotReady({ status: res.status, body })),
33 ),
34 ),
35 ),
36 Effect.retry({
37 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
38 schedule: ready,
39 }),
40 );
41
42export const postJson = (url: string, body: unknown) =>
43 untilOk(
44 HttpClient.execute(
45 HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
46 ),
47 ).pipe(Effect.flatMap((res) => res.json));
48
49export const putJson = (url: string, body: unknown) =>
50 untilOk(
51 HttpClient.execute(
52 HttpClientRequest.put(url).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
53 ),
54 ).pipe(Effect.flatMap((res) => res.json));
55
56export const getJson = (url: string) =>
57 untilOk(HttpClient.get(url)).pipe(Effect.flatMap((res) => res.json));
58
59/**
60 * Drive the dialect-agnostic `SqlClient` surface (the shared routes in
61 * `fixtures/routes.ts`) against one deployed worker. Covers, in order:
62 *
63 * - `sql.unsafe` (raw DDL) — `/init`
64 * - the `sql(table)` identifier helper — `/reset` (and every later route)
65 * - single-row `sql.insert` nested in an outer template + `sql.and` over
66 * nested fragments — `/users` (the original proxyChain regression)
67 * - multi-row `sql.insert` + `sql.literal` — `/users/batch`
68 * - `sql.update(row, omit)` — `PUT /users`
69 * - `sql.in(column, values)` — `/users/in`
70 * - `sql.or` over a nested `sql.and` — `/users/filter`
71 * - prefix-form `sql.csv` mixing string and fragment clauses — `/users/ordered`
72 * - the generic `SqlClient` service from `SQL.D1Layer` / `SQL.PostgresLayer`
73 * — `/layer/users`
74 */
75export const exerciseSqlSurface = Effect.fn(function* (base: string) {
76 yield* postJson(`${base}/init`, {});
77 yield* postJson(`${base}/reset`, {});
78
79 // sql.insert(row) nested in the outer template, read back via sql.and.
80 const alice: UserRow = { id: 1, name: "alice", email: "alice@example.com" };
81 const created = (yield* postJson(`${base}/users`, alice)) as {
82 rows: UserRow[];
83 };
84 expect(created.rows).toEqual([alice]);
85
86 // sql.insert(rows) multi-row + sql.literal COUNT.
87 const bob: UserRow = { id: 2, name: "bob", email: "bob@example.com" };
88 const carol: UserRow = { id: 3, name: "carol", email: "carol@example.com" };
89 const batch = (yield* postJson(`${base}/users/batch`, [bob, carol])) as {
90 count: number;
91 };
92 expect(batch.count).toBe(3);
93
94 // sql.update(row, ["id"]).
95 const alicia: UserRow = {
96 id: 1,
97 name: "alicia",
98 email: "alicia@example.com",
99 };
100 const updated = (yield* putJson(`${base}/users`, alicia)) as {
101 rows: UserRow[];
102 };
103 expect(updated.rows).toEqual([alicia]);
104
105 // sql.in("id", [2, 3]).
106 const within = (yield* getJson(`${base}/users/in?ids=2,3`)) as {
107 rows: UserRow[];
108 };
109 expect(within.rows).toEqual([bob, carol]);
110
111 // sql.or([sql.and([...]), fragment]) — matches bob by name+email AND
112 // carol by id.
113 const filtered = (yield* getJson(
114 `${base}/users/filter?name=${bob.name}&email=${bob.email}&id=${carol.id}`,
115 )) as { rows: UserRow[] };
116 expect(filtered.rows).toEqual([bob, carol]);
117
118 // sql.csv("ORDER BY", [fragment, "raw string"]).
119 const ordered = (yield* getJson(`${base}/users/ordered`)) as {
120 rows: Array<{ id: number; name: string }>;
121 };
122 expect(ordered.rows.map((r) => r.name)).toEqual(["carol", "bob", "alicia"]);
123
124 // SQL.D1Layer / SQL.PostgresLayer — the generic `SqlClient` service reads
125 // the same table.
126 const viaLayer = (yield* getJson(`${base}/layer/users`)) as {
127 rows: UserRow[];
128 };
129 expect(viaLayer.rows).toEqual([alicia, bob, carol]);
130});