1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as KV from "@/Cloudflare/KV/index";
4import * as Provider from "@/Provider";
5import { State } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import * as kv from "@distilled.cloud/cloudflare/kv";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21test.provider(
22 "create and delete namespace with default props",
23 (stack) =>
24 Effect.gen(function* () {
25 const { accountId } = yield* yield* CloudflareEnvironment;
26
27 yield* stack.destroy();
28
29 const namespace = yield* stack.deploy(
30 Effect.gen(function* () {
31 return yield* KV.Namespace("DefaultNamespace");
32 }),
33 );
34
35 expect(namespace.title).toBeDefined();
36 expect(namespace.namespaceId).toBeDefined();
37
38 const actualNamespace = yield* kv.getNamespace({
39 accountId,
40 namespaceId: namespace.namespaceId,
41 });
42 expect(actualNamespace.id).toEqual(namespace.namespaceId);
43
44 yield* stack.destroy();
45
46 yield* waitForNamespaceToBeDeleted(namespace.namespaceId, accountId);
47 }).pipe(logLevel),
48 { tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] },
49);
50
51test.provider(
52 "create, update, delete namespace",
53 (stack) =>
54 Effect.gen(function* () {
55 const { accountId } = yield* yield* CloudflareEnvironment;
56
57 yield* stack.destroy();
58
59 const namespace = yield* stack.deploy(
60 Effect.gen(function* () {
61 return yield* KV.Namespace("TestNamespace");
62 }),
63 );
64
65 const actualNamespace = yield* kv.getNamespace({
66 accountId,
67 namespaceId: namespace.namespaceId,
68 });
69 expect(actualNamespace.id).toEqual(namespace.namespaceId);
70 expect(actualNamespace.title).toEqual(namespace.title);
71
72 const updatedNamespace = yield* stack.deploy(
73 Effect.gen(function* () {
74 return yield* KV.Namespace("TestNamespace", {
75 title: namespace.title + "-updated",
76 });
77 }),
78 );
79
80 const actualUpdatedNamespace = yield* kv.getNamespace({
81 accountId,
82 namespaceId: updatedNamespace.namespaceId,
83 });
84 expect(actualUpdatedNamespace.title).toEqual(
85 namespace.title + "-updated",
86 );
87 expect(actualUpdatedNamespace.id).toEqual(updatedNamespace.namespaceId);
88
89 yield* stack.destroy();
90
91 yield* waitForNamespaceToBeDeleted(namespace.namespaceId, accountId);
92 }).pipe(logLevel),
93 { tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] },
94);
95
96// Canonical `list()` test (account-scoped collection): deploy a real
97// namespace, resolve the provider from context via `findProviderByType`,
98// call `list()`, and assert the deployed namespace appears in the
99// exhaustively-paginated result.
100test.provider(
101 "list enumerates the deployed namespace",
102 (stack) =>
103 Effect.gen(function* () {
104 yield* stack.destroy();
105
106 const namespace = yield* stack.deploy(
107 Effect.gen(function* () {
108 return yield* KV.Namespace("ListNamespace");
109 }),
110 );
111
112 const provider = yield* Provider.findProvider(KV.Namespace);
113 const all = yield* provider.list();
114
115 expect(all.some((ns) => ns.namespaceId === namespace.namespaceId)).toBe(
116 true,
117 );
118
119 yield* stack.destroy();
120 }).pipe(logLevel),
121 { tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] },
122);
123
124// Engine-level adoption: KV namespaces have no ownership signal (Cloudflare
125// doesn't expose tags on KV), so a name match in `read` is treated as silent
126// adoption. The test wipes local state mid-run while leaving the namespace
127// on Cloudflare — this simulates a fresh state store seeing an existing
128// resource with the same physical name.
129test.provider(
130 "existing namespace (matching title) is silently adopted without --adopt",
131 (stack) =>
132 Effect.gen(function* () {
133 const { accountId } = yield* yield* CloudflareEnvironment;
134
135 yield* stack.destroy();
136
137 // Phase 1: deploy normally so a real KV namespace exists on
138 // Cloudflare. No explicit `title` — the engine generates a
139 // random-suffixed physical name (collision-free across concurrent
140 // runs); the deploy output hands back the real title, which pins the
141 // namespace's identity for the adoption phase below.
142 const initial = yield* stack.deploy(
143 Effect.gen(function* () {
144 return yield* KV.Namespace("AdoptableNamespace");
145 }),
146 );
147 const title = initial.title;
148 const initialId = initial.namespaceId;
149 expect(initialId).toBeDefined();
150
151 // Phase 2: wipe local state — the namespace stays on Cloudflare.
152 yield* Effect.gen(function* () {
153 const state = yield* yield* State;
154 yield* state.delete({
155 stack: stack.name,
156 stage: stack.stage,
157 fqn: "AdoptableNamespace",
158 });
159 }).pipe(Effect.provide(stack.state));
160
161 // Phase 3: redeploy without `adopt(true)`. The engine calls
162 // `provider.read`, which lists namespaces, matches by title, and
163 // returns plain attrs — silent adoption.
164 const adopted = yield* stack.deploy(
165 Effect.gen(function* () {
166 return yield* KV.Namespace("AdoptableNamespace", { title });
167 }),
168 );
169
170 // Same physical namespace — adoption, not re-creation.
171 expect(adopted.namespaceId).toEqual(initialId);
172 expect(adopted.title).toEqual(title);
173
174 const persisted = yield* Effect.gen(function* () {
175 const state = yield* yield* State;
176 return yield* state.get({
177 stack: stack.name,
178 stage: stack.stage,
179 fqn: "AdoptableNamespace",
180 });
181 }).pipe(Effect.provide(stack.state));
182
183 expect((persisted as any)?.attr).toMatchObject({
184 namespaceId: initialId,
185 title,
186 });
187
188 yield* stack.destroy();
189 yield* waitForNamespaceToBeDeleted(initialId, accountId);
190 }).pipe(logLevel),
191 { tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] },
192);
193
194const waitForNamespaceToBeDeleted = Effect.fn(function* (
195 namespaceId: string,
196 accountId: string,
197) {
198 yield* kv
199 .getNamespace({
200 accountId,
201 namespaceId,
202 })
203 .pipe(
204 Effect.flatMap(() => Effect.fail(new NamespaceStillExists())),
205 Effect.retry({
206 while: (e): e is NamespaceStillExists =>
207 e instanceof NamespaceStillExists,
208 schedule: Schedule.exponential(100),
209 }),
210 Effect.catchTag("NamespaceNotFound", () => Effect.void),
211 );
212});
213
214class NamespaceStillExists extends Data.TaggedError("NamespaceStillExists") {}