1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7
8const { test } = Test.make({ providers: Cloudflare.providers() });
9
10const logLevel = Effect.provideService(
11 MinimumLogLevel,
12 process.env.DEBUG ? "Debug" : "Info",
13);
14
15interface User {
16 id: string;
17 name: string;
18}
19
20// Binding a D1 database inside an Action via `QueryDatabaseLocal` — the local
21// (current-credentials) implementation of the `QueryDatabase` binding. Exercises
22// both the binding client (exec/prepare/run/all) and the accessor mechanism
23// (`yield* database.databaseId` resolved at apply time).
24test.provider(
25 "QueryDatabaseLocal: seed and query a database from an Action",
26 (stack) =>
27 Effect.gen(function* () {
28 yield* stack.destroy();
29
30 const out = yield* stack.deploy(
31 Effect.gen(function* () {
32 const database = yield* Cloudflare.D1.Database("SeedDatabase");
33
34 const Seed = Action(
35 "Seed",
36 Effect.gen(function* () {
37 const db = yield* Cloudflare.D1.QueryDatabase(database);
38 // Accessor — resolved at apply time against the tracker.
39 const databaseId = yield* database.databaseId;
40
41 return Effect.fn(function* () {
42 yield* db.exec(
43 "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)",
44 );
45 yield* db.prepare("DELETE FROM users").run();
46 yield* db
47 .prepare("INSERT INTO users (id, name) VALUES (?, ?)")
48 .bind("1", "Ada")
49 .run();
50 yield* db
51 .prepare("INSERT INTO users (id, name) VALUES (?, ?)")
52 .bind("2", "Grace")
53 .run();
54
55 const rows = yield* db
56 .prepare("SELECT id, name FROM users ORDER BY id")
57 .all<User>();
58
59 const first = yield* db
60 .prepare("SELECT name FROM users WHERE id = ?")
61 .bind("1")
62 .first<string>("name");
63
64 return {
65 databaseId: yield* databaseId,
66 users: rows.results,
67 first,
68 };
69 });
70 }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)),
71 );
72
73 return yield* Seed({});
74 }),
75 );
76
77 expect(out.databaseId).toBeTruthy();
78 expect(out.users).toEqual([
79 { id: "1", name: "Ada" },
80 { id: "2", name: "Grace" },
81 ]);
82 expect(out.first).toBe("Ada");
83
84 yield* stack.destroy();
85 }).pipe(logLevel),
86 {
87 tags: ["provider:cloudflare", "provider:cloudflare:d1", "live"],
88 timeout: 120_000,
89 },
90);
91
92// Native D1 bind values (number/null/boolean/binary) must round-trip, not just
93// strings: the HTTP API binds verbatim, so the Local shim mirrors native D1
94// (booleans -> 1/0, binary -> BLOB) and Distilled models params as unknown[].
95test.provider(
96 "QueryDatabaseLocal: binds non-string parameters (single + batch)",
97 (stack) =>
98 Effect.gen(function* () {
99 yield* stack.destroy();
100
101 const out = yield* stack.deploy(
102 Effect.gen(function* () {
103 const database = yield* Cloudflare.D1.Database("TypedBindDatabase");
104
105 const Query = Action(
106 "QueryTypedBind",
107 Effect.gen(function* () {
108 const db = yield* Cloudflare.D1.QueryDatabase(database);
109 return Effect.fn(function* () {
110 // Single statement: number, null, boolean (-> 1/0) via SELECT ?.
111 const num = yield* db
112 .prepare("SELECT ? AS value")
113 .bind(42)
114 .first<number>("value");
115 const nul = yield* db
116 .prepare("SELECT ? AS value")
117 .bind(null)
118 .first<null>("value");
119 const boolTrue = yield* db
120 .prepare("SELECT ? AS value")
121 .bind(true)
122 .first<number>("value");
123 const boolFalse = yield* db
124 .prepare("SELECT ? AS value")
125 .bind(false)
126 .first<number>("value");
127
128 // Batch: mixed-type params across statements round-trip too.
129 yield* db.exec(
130 "CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, qty INTEGER, note TEXT)",
131 );
132 yield* db.prepare("DELETE FROM items").run();
133 yield* db.batch([
134 db
135 .prepare(
136 "INSERT INTO items (id, qty, note) VALUES (?, ?, ?)",
137 )
138 .bind(1, 10, null),
139 db
140 .prepare(
141 "INSERT INTO items (id, qty, note) VALUES (?, ?, ?)",
142 )
143 .bind(2, 20, "second"),
144 ]);
145 const rows = yield* db
146 .prepare("SELECT id, qty, note FROM items ORDER BY id")
147 .all<{ id: number; qty: number; note: string | null }>();
148
149 // Binary (BLOB): bind a Uint8Array, read the byte length back.
150 const blobLen = yield* db
151 .prepare("SELECT length(?) AS len")
152 .bind(new Uint8Array([1, 2, 3, 4]))
153 .first<number>("len");
154
155 return {
156 num,
157 nul,
158 boolTrue,
159 boolFalse,
160 rows: rows.results,
161 blobLen,
162 };
163 });
164 }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)),
165 );
166
167 return yield* Query({});
168 }),
169 );
170
171 expect(out.num).toBe(42);
172 expect(out.nul).toBeNull();
173 expect(out.boolTrue).toBe(1);
174 expect(out.boolFalse).toBe(0);
175 expect(out.rows).toEqual([
176 { id: 1, qty: 10, note: null },
177 { id: 2, qty: 20, note: "second" },
178 ]);
179 expect(out.blobLen).toBe(4);
180
181 yield* stack.destroy();
182 }).pipe(logLevel),
183 {
184 tags: ["provider:cloudflare", "provider:cloudflare:d1", "live"],
185 timeout: 120_000,
186 },
187);