1import * as kv from "@distilled.cloud/cloudflare/kv";
2import type * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import {
7 makeHttpKVNamespaceBinding,
8 makeKVAuth,
9 makeKVHttpScope,
10 toKVNamespaceError,
11 type KVAuth,
12} from "./NamespaceHttp.ts";
13import { ReadNamespace, type ReadNamespaceClient } from "./ReadNamespace.ts";
14
15/**
16 * HTTP-backed implementation of the {@link ReadNamespace} binding.
17 *
18 * It creates a scoped {@link AccountApiToken} with the `Workers KV Storage
19 * Read` permission and reads values via the Cloudflare KV HTTP API.
20 */
21export const ReadNamespaceHttp = Layer.effect(
22 ReadNamespace,
23 Effect.suspend(() =>
24 makeHttpKVNamespaceBinding({
25 permissionGroups: ["Workers KV Storage Read"],
26 makeClient: (token, namespaceId) =>
27 makeReadKVHttpClient(makeKVAuth(token), namespaceId),
28 }),
29 ),
30);
31
32export const makeReadKVHttpClient = (
33 auth: KVAuth,
34 namespaceId: Effect.Effect<string>,
35): ReadNamespaceClient => {
36 const { authorize } = auth;
37 const scope = makeKVHttpScope(auth, namespaceId);
38
39 const getOne = (key: string, type: string) =>
40 scope.pipe(
41 Effect.flatMap(({ accountId, namespaceId }) =>
42 authorize(
43 kv.getNamespaceValue({ accountId, namespaceId, keyName: key }),
44 ).pipe(
45 Effect.flatMap((res) => materializeBody(res.body, type)),
46 Effect.catchTag("KeyNotFound", () => Effect.succeed(null)),
47 ),
48 ),
49 Effect.mapError(toKVNamespaceError),
50 );
51
52 const getMany = (keys: string[], type: string) =>
53 scope.pipe(
54 Effect.flatMap(({ accountId, namespaceId }) =>
55 authorize(
56 kv.bulkGetNamespaceKeys({
57 accountId,
58 namespaceId,
59 keys,
60 type: type === "json" ? "json" : "text",
61 }),
62 ),
63 ),
64 Effect.mapError(toKVNamespaceError),
65 Effect.map((res) => {
66 const values = (res.values ?? {}) as Record<string, unknown>;
67 const map = new Map<string, unknown>();
68 for (const key of keys) {
69 map.set(key, key in values ? decodeValue(values[key], type) : null);
70 }
71 return map;
72 }),
73 );
74
75 const getWithMetadataOne = (key: string, type: string) =>
76 scope.pipe(
77 Effect.flatMap(({ accountId, namespaceId }) =>
78 Effect.all({
79 value: authorize(
80 kv.getNamespaceValue({ accountId, namespaceId, keyName: key }),
81 ).pipe(
82 Effect.flatMap((res) => materializeBody(res.body, type)),
83 Effect.catchTag("KeyNotFound", () => Effect.succeed(null)),
84 ),
85 metadata: authorize(
86 kv.getNamespaceMetadata({ accountId, namespaceId, keyName: key }),
87 ).pipe(
88 Effect.catchTag(["KeyNotFound", "NamespaceNotFound"], () =>
89 Effect.succeed(null),
90 ),
91 ),
92 }),
93 ),
94 Effect.mapError(toKVNamespaceError),
95 Effect.map(({ value, metadata }) => ({
96 value,
97 metadata: metadata ?? null,
98 cacheStatus: null,
99 })),
100 );
101
102 return {
103 raw: Effect.die(
104 new (class extends Error {})(
105 "KV HTTP client does not expose a native Namespace binding; use get/list/getWithMetadata.",
106 ),
107 ) as any,
108 get: ((key: string | string[], typeOrOptions?: unknown) =>
109 Array.isArray(key)
110 ? getMany(key, readType(typeOrOptions))
111 : getOne(key, readType(typeOrOptions))) as any,
112 getWithMetadata: ((key: string | string[], typeOrOptions?: unknown) => {
113 const type = readType(typeOrOptions);
114 if (Array.isArray(key)) {
115 return getMany(key, type).pipe(
116 Effect.map((values) => {
117 const map = new Map<string, unknown>();
118 for (const [k, value] of values) {
119 map.set(k, { value, metadata: null, cacheStatus: null });
120 }
121 return map;
122 }),
123 );
124 }
125 return getWithMetadataOne(key, type);
126 }) as any,
127 list: ((options?: KVNamespaceListOptions) =>
128 scope.pipe(
129 Effect.flatMap(({ accountId, namespaceId }) =>
130 authorize(
131 kv.listNamespaceKeys({
132 accountId,
133 namespaceId,
134 prefix: options?.prefix ?? undefined,
135 limit: options?.limit ?? undefined,
136 cursor: options?.cursor ?? undefined,
137 }),
138 ),
139 ),
140 Effect.mapError(toKVNamespaceError),
141 Effect.map((res) => {
142 const keys = res.result.map((k) => ({
143 name: k.name,
144 expiration: k.expiration ?? undefined,
145 metadata: k.metadata ?? undefined,
146 }));
147 const cursor = res.resultInfo?.cursor ?? undefined;
148 return (
149 cursor
150 ? { keys, list_complete: false, cursor, cacheStatus: null }
151 : { keys, list_complete: true, cacheStatus: null }
152 ) as unknown;
153 }),
154 )) as any,
155 };
156};
157
158/**
159 * Materialize the raw value byte stream according to the requested type
160 * ("text" | "json" | "arrayBuffer" | "stream").
161 */
162const materializeBody = (
163 body: kv.GetNamespaceValueResponse["body"],
164 type: string,
165): Effect.Effect<unknown, Cause.UnknownError> =>
166 type === "stream"
167 ? Effect.sync(() => Stream.toReadableStream(body))
168 : Effect.tryPromise(() => {
169 const response = new Response(
170 Stream.toReadableStream(body) as BodyInit,
171 );
172 return type === "arrayBuffer"
173 ? response.arrayBuffer()
174 : type === "json"
175 ? response.json()
176 : response.text();
177 });
178
179/** Resolve the requested decode type from the overloaded second argument. */
180const readType = (typeOrOptions: unknown): string =>
181 typeof typeOrOptions === "string"
182 ? typeOrOptions
183 : ((typeOrOptions as { type?: string } | undefined)?.type ?? "text");
184
185/** Decode a raw KV value according to the requested type. */
186const decodeValue = (value: unknown, type: string): unknown => {
187 if (value === null || value === undefined) return null;
188 if (type === "json") {
189 return typeof value === "string" ? JSON.parse(value) : value;
190 }
191 return typeof value === "string" ? value : String(value);
192};