1import * as kv from "@distilled.cloud/cloudflare/kv";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as ProviderLayer from "../../Local/ProviderLayer.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { isResourceOfType, Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import { localAccountId } from "../LocalAccount.ts";
12import { generateLocalId } from "../LocalRuntime.ts";
13import type { Providers } from "../Providers.ts";
14
15export const isNamespace = (value: unknown): value is Namespace =>
16 isResourceOfType(value, "Cloudflare.KV.Namespace");
17
18export type NamespaceProps = {
19 /**
20 * A human-readable string name for the namespace.
21 * If omitted, a unique name will be generated.
22 * @default ${app}-${stage}-${id}
23 */
24 title?: string;
25};
26
27export type Namespace = Resource<
28 "Cloudflare.KV.Namespace",
29 NamespaceProps,
30 {
31 title: string;
32 namespaceId: string;
33 supportsUrlEncoding: boolean | undefined;
34 accountId: string;
35 },
36 never,
37 Providers
38>;
39
40/**
41 * A Cloudflare Workers KV namespace for key-value storage at the edge.
42 *
43 * KV provides eventually-consistent, low-latency reads with global
44 * replication. Create a namespace as a resource, then bind it to a Worker
45 * to get/put values at runtime.
46 * ### Creating a Namespace
47 * **Example:** Basic KV namespace
48 * ```typescript
49 * const kv = yield* Cloudflare.KV.Namespace("MyKV");
50 * ```
51 *
52 * ### Binding to a Worker
53 * **Example:** Using KV inside a Worker
54 * ```typescript
55 * const kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
56 *
57 * // Read a value
58 * const value = yield* kv.get("my-key");
59 *
60 * // Write a value
61 * yield* kv.put("my-key", "hello world");
62 * ```
63 *
64 * Provide `Cloudflare.KV.ReadWriteNamespaceBinding` (native Worker
65 * binding) or `Cloudflare.KV.ReadWriteNamespaceHttp` (scoped HTTP
66 * token) in the worker's runtime layer. Use `Cloudflare.KV.ReadNamespace`
67 * / `Cloudflare.KV.WriteNamespace` for least-privilege read- or
68 * write-only access.
69 *
70 * @resource
71 * @product KV
72 * @category Storage & Databases
73 */
74export const Namespace = Resource<Namespace>("Cloudflare.KV.Namespace", {
75 aliases: ["Cloudflare.KVNamespace"],
76});
77
78export const ProviderLive = () =>
79 Provider.succeed(Namespace, {
80 stables: ["namespaceId", "accountId"],
81 diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) {
82 const { accountId } = yield* yield* CloudflareEnvironment;
83 if (!isResolved(news)) return undefined;
84 if ((output?.accountId ?? accountId) !== accountId) {
85 return { action: "replace" } as const;
86 }
87 const oldTitle = output?.title ?? (yield* createTitle(id, olds.title));
88 // Auto-generated titles are engine-owned: the deployed title stays
89 // authoritative even if the generator would title this id differently
90 // today. Only an explicit user-provided title can force a rename.
91 const title = news.title ?? oldTitle;
92 if (title !== oldTitle) {
93 return { action: "update" } as const;
94 }
95 }),
96 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
97 const { accountId } = yield* yield* CloudflareEnvironment;
98 const title = yield* createTitle(id, news.title);
99 const acct = output?.accountId ?? accountId;
100
101 // Observe — re-fetch the cached namespace; fall back to a title
102 // scan so we recover from out-of-band deletes or partial state
103 // persistence failures.
104 let observed:
105 | {
106 id: string;
107 title: string;
108 supportsUrlEncoding?: boolean | null | undefined;
109 }
110 | undefined;
111 if (output?.namespaceId) {
112 observed = yield* kv
113 .getNamespace({
114 accountId: acct,
115 namespaceId: output.namespaceId,
116 })
117 .pipe(
118 Effect.catchTag("NamespaceNotFound", () =>
119 Effect.succeed(undefined),
120 ),
121 );
122 }
123
124 // Ensure — create if missing. Cloudflare returns
125 // `NamespaceTitleAlreadyExists` on a concurrent create; tolerate
126 // by adopting the namespace with the same title.
127 if (!observed) {
128 observed = yield* kv
129 .createNamespace({
130 accountId: acct,
131 title,
132 })
133 .pipe(
134 Effect.catchTag("NamespaceTitleAlreadyExists", () =>
135 Effect.gen(function* () {
136 const match = yield* findNamespaceByTitle(title);
137 if (match) {
138 return match;
139 }
140 return yield* Effect.die(
141 `Namespace with title "${title}" already exists but could not be found`,
142 );
143 }),
144 ),
145 );
146 }
147
148 // Sync — KV's only mutable property is the title. Rename only
149 // when the observed title drifts from desired so we avoid
150 // unnecessary API calls on every reconcile.
151 let namespaceId = observed.id;
152 let resolvedTitle = observed.title;
153 let supportsUrlEncoding = observed.supportsUrlEncoding ?? undefined;
154 if (observed.title !== title) {
155 const renamed = yield* kv.updateNamespace({
156 accountId: acct,
157 namespaceId: observed.id,
158 title,
159 });
160 namespaceId = renamed.id;
161 resolvedTitle = renamed.title;
162 supportsUrlEncoding = renamed.supportsUrlEncoding ?? undefined;
163 }
164
165 return {
166 title: resolvedTitle,
167 namespaceId,
168 supportsUrlEncoding,
169 accountId: acct,
170 };
171 }),
172 delete: Effect.fn(function* ({ output }) {
173 yield* kv
174 .deleteNamespace({
175 accountId: output.accountId,
176 namespaceId: output.namespaceId,
177 })
178 .pipe(Effect.catchTag("NamespaceNotFound", () => Effect.void));
179 }),
180 list: Effect.fn(function* () {
181 const { accountId } = yield* yield* CloudflareEnvironment;
182 return yield* kv.listNamespaces.pages({ accountId }).pipe(
183 Stream.runCollect,
184 Effect.map((chunk) =>
185 Array.from(chunk).flatMap((page) =>
186 (page.result ?? []).map((ns) => ({
187 title: ns.title,
188 namespaceId: ns.id,
189 supportsUrlEncoding: ns.supportsUrlEncoding ?? undefined,
190 accountId,
191 })),
192 ),
193 ),
194 );
195 }),
196 read: Effect.fn(function* ({ id, olds, output }) {
197 const { accountId } = yield* yield* CloudflareEnvironment;
198 if (output?.namespaceId) {
199 return yield* kv
200 .getNamespace({
201 accountId: output.accountId,
202 namespaceId: output.namespaceId,
203 })
204 .pipe(
205 Effect.map((namespace) => ({
206 title: namespace.title,
207 namespaceId: namespace.id,
208 supportsUrlEncoding: namespace.supportsUrlEncoding ?? undefined,
209 accountId: output.accountId,
210 })),
211 Effect.catchTag("NamespaceNotFound", () =>
212 Effect.succeed(undefined),
213 ),
214 );
215 }
216 const title = yield* createTitle(id, olds?.title);
217 const match = yield* findNamespaceByTitle(title);
218 if (match) {
219 return {
220 title: match.title,
221 namespaceId: match.id,
222 supportsUrlEncoding: match.supportsUrlEncoding ?? undefined,
223 accountId,
224 };
225 }
226 return undefined;
227 }),
228 });
229
230/**
231 * Local (dev) provider — the namespace is purely virtual: a `dev:` id keyed
232 * into the local workerd KV simulator. `toRuntimeBinding` lowers a
233 * `kv_namespace` binding whose id is `dev:`-prefixed onto the local KV
234 * service; data persists under `.alchemy/local/kv`.
235 */
236export const ProviderLocal = () =>
237 Provider.succeed(Namespace, {
238 stables: ["accountId"],
239 diff: Effect.fn(function* ({ news = {}, output }) {
240 const accountId = yield* localAccountId;
241 if (!output?.namespaceId) return { action: "update" } as const;
242 if (!isResolved(news)) return undefined;
243 if (output.accountId !== accountId) {
244 return { action: "replace" } as const;
245 }
246 // Fall through to the engine's default prop diff (title renames
247 // update in place).
248 }),
249 read: Effect.fn(function* ({ output }) {
250 // Purely virtual — the persisted state row is the source of truth.
251 return output ?? undefined;
252 }),
253 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
254 const accountId = yield* localAccountId;
255 return {
256 title: yield* createTitle(id, news.title),
257 namespaceId: output?.namespaceId ?? generateLocalId(),
258 supportsUrlEncoding: true,
259 accountId: output?.accountId ?? accountId,
260 };
261 }),
262 delete: Effect.fn(function* () {
263 // The simulator's on-disk data is keyed by the dev id; dropping the
264 // state row is enough — orphaned blobs are reclaimed with `.alchemy`.
265 }),
266 });
267
268export const NamespaceProvider = () =>
269 ProviderLayer.dual(Namespace, {
270 local: () => ProviderLocal(),
271 live: () => ProviderLive(),
272 });
273
274const createTitle = (id: string, title: string | undefined) =>
275 Effect.gen(function* () {
276 return title ?? (yield* createPhysicalName({ id }));
277 });
278
279// Cloudflare's `listNamespaces` accepts no title/prefix filter, so
280// adoption-by-name has to scan every page. Use the paginated
281// `.items` stream off the un-yielded operation method (yielding
282// `kv.listNamespaces` collapses it to a single-page call).
283const findNamespaceByTitle = Effect.fn(function* (title: string) {
284 const { accountId } = yield* yield* CloudflareEnvironment;
285 return yield* kv.listNamespaces.items({ accountId }).pipe(
286 Stream.filter((ns) => ns.title === title),
287 Stream.runHead,
288 Effect.map(Option.getOrUndefined),
289 );
290});