1import * as Effect from "effect/Effect";
2import type { RuntimeContext } from "../RuntimeContext.ts";
3import type { CommandError, UrlMissing } from "./Errors.ts";
4import { command, commandPipeline } from "./Protocol.ts";
5import type { Arg, Reply } from "./Resp.ts";
6
7/**
8 * A Redis URL that resolves from the Function/Service environment at
9 * runtime (`REDIS_URL`).
10 */
11export type Url = Effect.Effect<string, UrlMissing, RuntimeContext>;
12
13export type RuntimeError = CommandError | UrlMissing;
14
15export interface SetOptions {
16 readonly ex?: number;
17 readonly px?: number;
18 readonly exAt?: number;
19 readonly pxAt?: number;
20 readonly nx?: boolean;
21 readonly xx?: boolean;
22 readonly keepTtl?: boolean;
23}
24
25/**
26 * Read-only Redis client. Any Redis command is available via {@link Client.send}
27 * on the read/write client.
28 */
29export interface ReadClient {
30 get(key: string): Effect.Effect<string | null, RuntimeError, RuntimeContext>;
31 mget(
32 ...keys: string[]
33 ): Effect.Effect<Array<string | null>, RuntimeError, RuntimeContext>;
34 ping(message?: string): Effect.Effect<string, RuntimeError, RuntimeContext>;
35 echo(message: string): Effect.Effect<string, RuntimeError, RuntimeContext>;
36 exists(
37 ...keys: string[]
38 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
39 ttl(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
40 pttl(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
41 type(key: string): Effect.Effect<string, RuntimeError, RuntimeContext>;
42 hget(
43 key: string,
44 field: string,
45 ): Effect.Effect<string | null, RuntimeError, RuntimeContext>;
46 hmget(
47 key: string,
48 ...fields: string[]
49 ): Effect.Effect<Array<string | null>, RuntimeError, RuntimeContext>;
50 hgetall(
51 key: string,
52 ): Effect.Effect<Record<string, string>, RuntimeError, RuntimeContext>;
53 hexists(
54 key: string,
55 field: string,
56 ): Effect.Effect<boolean, RuntimeError, RuntimeContext>;
57 hkeys(key: string): Effect.Effect<string[], RuntimeError, RuntimeContext>;
58 hvals(key: string): Effect.Effect<string[], RuntimeError, RuntimeContext>;
59 hlen(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
60 llen(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
61 lrange(
62 key: string,
63 start: number,
64 stop: number,
65 ): Effect.Effect<string[], RuntimeError, RuntimeContext>;
66 lindex(
67 key: string,
68 index: number,
69 ): Effect.Effect<string | null, RuntimeError, RuntimeContext>;
70 scard(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
71 sismember(
72 key: string,
73 member: string,
74 ): Effect.Effect<boolean, RuntimeError, RuntimeContext>;
75 smembers(key: string): Effect.Effect<string[], RuntimeError, RuntimeContext>;
76 zscore(
77 key: string,
78 member: string,
79 ): Effect.Effect<string | null, RuntimeError, RuntimeContext>;
80 zcard(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
81 zrange(
82 key: string,
83 start: number,
84 stop: number,
85 ): Effect.Effect<string[], RuntimeError, RuntimeContext>;
86}
87
88/**
89 * Write Redis client.
90 */
91export interface WriteClient {
92 set(
93 key: string,
94 value: string | Uint8Array,
95 options?: SetOptions,
96 ): Effect.Effect<void, RuntimeError, RuntimeContext>;
97 mset(
98 values: Record<string, string | Uint8Array>,
99 ): Effect.Effect<void, RuntimeError, RuntimeContext>;
100 del(...keys: string[]): Effect.Effect<number, RuntimeError, RuntimeContext>;
101 unlink(
102 ...keys: string[]
103 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
104 expire(
105 key: string,
106 seconds: number,
107 ): Effect.Effect<boolean, RuntimeError, RuntimeContext>;
108 persist(key: string): Effect.Effect<boolean, RuntimeError, RuntimeContext>;
109 incr(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
110 incrBy(
111 key: string,
112 amount: number,
113 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
114 decr(key: string): Effect.Effect<number, RuntimeError, RuntimeContext>;
115 hset(
116 key: string,
117 field: string | Record<string, string | Uint8Array>,
118 value?: string | Uint8Array,
119 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
120 hdel(
121 key: string,
122 ...fields: string[]
123 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
124 hincrBy(
125 key: string,
126 field: string,
127 amount: number,
128 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
129 lpush(
130 key: string,
131 ...values: Array<string | Uint8Array>
132 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
133 rpush(
134 key: string,
135 ...values: Array<string | Uint8Array>
136 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
137 lpop(key: string): Effect.Effect<string | null, RuntimeError, RuntimeContext>;
138 rpop(key: string): Effect.Effect<string | null, RuntimeError, RuntimeContext>;
139 sadd(
140 key: string,
141 ...members: Array<string | Uint8Array>
142 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
143 srem(
144 key: string,
145 ...members: Array<string | Uint8Array>
146 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
147 zadd(
148 key: string,
149 score: number,
150 member: string | Uint8Array,
151 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
152 zrem(
153 key: string,
154 ...members: Array<string | Uint8Array>
155 ): Effect.Effect<number, RuntimeError, RuntimeContext>;
156}
157
158/**
159 * Read + write Redis client. {@link send} is the total command surface —
160 * every Redis command is an array of bulk strings on the wire.
161 */
162export interface Client extends ReadClient, WriteClient {
163 send(
164 name: string,
165 args?: readonly Arg[],
166 ): Effect.Effect<Reply, RuntimeError, RuntimeContext>;
167 pipeline(
168 commands: ReadonlyArray<readonly [string, ...Arg[]]>,
169 ): Effect.Effect<readonly Reply[], RuntimeError, RuntimeContext>;
170}
171
172export interface ReadWriteClient extends Client {}
173
174const asString = (value: unknown): string => {
175 if (typeof value === "string") return value;
176 if (value == null) return "";
177 return String(value);
178};
179
180const asNullableString = (value: unknown): string | null => {
181 if (value == null) return null;
182 return asString(value);
183};
184
185const asNumber = (value: unknown): number => {
186 if (typeof value === "number" && Number.isFinite(value)) return value;
187 if (typeof value === "bigint") return Number(value);
188 const parsed = Number(asString(value));
189 return Number.isFinite(parsed) ? parsed : 0;
190};
191
192const asBoolean = (value: unknown): boolean => asNumber(value) !== 0;
193
194const asStringArray = (value: unknown): string[] => {
195 if (!Array.isArray(value)) return [];
196 return value.map(asString);
197};
198
199const asNullableStringArray = (value: unknown): Array<string | null> => {
200 if (!Array.isArray(value)) return [];
201 return value.map(asNullableString);
202};
203
204const asHash = (value: unknown): Record<string, string> => {
205 if (value !== null && typeof value === "object" && !Array.isArray(value)) {
206 const out: Record<string, string> = {};
207 for (const [key, item] of Object.entries(value)) {
208 out[key] = asString(item);
209 }
210 return out;
211 }
212 if (!Array.isArray(value)) return {};
213 const out: Record<string, string> = {};
214 for (let i = 0; i + 1 < value.length; i += 2) {
215 out[asString(value[i])] = asString(value[i + 1]);
216 }
217 return out;
218};
219
220const setArgs = (
221 key: string,
222 value: string | Uint8Array,
223 options?: SetOptions,
224): Arg[] => {
225 const args: Arg[] = [key, value];
226 if (options?.nx) args.push("NX");
227 if (options?.xx) args.push("XX");
228 if (options?.ex !== undefined) args.push("EX", options.ex);
229 if (options?.px !== undefined) args.push("PX", options.px);
230 if (options?.exAt !== undefined) args.push("EXAT", options.exAt);
231 if (options?.pxAt !== undefined) args.push("PXAT", options.pxAt);
232 if (options?.keepTtl) args.push("KEEPTTL");
233 return args;
234};
235
236/**
237 * Read-only client over a runtime Redis URL.
238 */
239export const makeRead = (url: Url): ReadClient => ({
240 get: (key) => command(url, "GET", [key]).pipe(Effect.map(asNullableString)),
241 mget: (...keys) =>
242 command(url, "MGET", keys).pipe(Effect.map(asNullableStringArray)),
243 ping: (message) =>
244 command(url, "PING", message === undefined ? [] : [message]).pipe(
245 Effect.map(asString),
246 ),
247 echo: (message) => command(url, "ECHO", [message]).pipe(Effect.map(asString)),
248 exists: (...keys) => command(url, "EXISTS", keys).pipe(Effect.map(asNumber)),
249 ttl: (key) => command(url, "TTL", [key]).pipe(Effect.map(asNumber)),
250 pttl: (key) => command(url, "PTTL", [key]).pipe(Effect.map(asNumber)),
251 type: (key) => command(url, "TYPE", [key]).pipe(Effect.map(asString)),
252 hget: (key, field) =>
253 command(url, "HGET", [key, field]).pipe(Effect.map(asNullableString)),
254 hmget: (key, ...fields) =>
255 command(url, "HMGET", [key, ...fields]).pipe(
256 Effect.map(asNullableStringArray),
257 ),
258 hgetall: (key) => command(url, "HGETALL", [key]).pipe(Effect.map(asHash)),
259 hexists: (key, field) =>
260 command(url, "HEXISTS", [key, field]).pipe(Effect.map(asBoolean)),
261 hkeys: (key) => command(url, "HKEYS", [key]).pipe(Effect.map(asStringArray)),
262 hvals: (key) => command(url, "HVALS", [key]).pipe(Effect.map(asStringArray)),
263 hlen: (key) => command(url, "HLEN", [key]).pipe(Effect.map(asNumber)),
264 llen: (key) => command(url, "LLEN", [key]).pipe(Effect.map(asNumber)),
265 lrange: (key, start, stop) =>
266 command(url, "LRANGE", [key, start, stop]).pipe(Effect.map(asStringArray)),
267 lindex: (key, index) =>
268 command(url, "LINDEX", [key, index]).pipe(Effect.map(asNullableString)),
269 scard: (key) => command(url, "SCARD", [key]).pipe(Effect.map(asNumber)),
270 sismember: (key, member) =>
271 command(url, "SISMEMBER", [key, member]).pipe(Effect.map(asBoolean)),
272 smembers: (key) =>
273 command(url, "SMEMBERS", [key]).pipe(Effect.map(asStringArray)),
274 zscore: (key, member) =>
275 command(url, "ZSCORE", [key, member]).pipe(Effect.map(asNullableString)),
276 zcard: (key) => command(url, "ZCARD", [key]).pipe(Effect.map(asNumber)),
277 zrange: (key, start, stop) =>
278 command(url, "ZRANGE", [key, start, stop]).pipe(Effect.map(asStringArray)),
279});
280
281/**
282 * Write client over a runtime Redis URL.
283 */
284export const makeWrite = (url: Url): WriteClient => ({
285 set: (key, value, options) =>
286 command(url, "SET", setArgs(key, value, options)).pipe(Effect.asVoid),
287 mset: (values) => {
288 const args: Arg[] = [];
289 for (const [key, value] of Object.entries(values)) {
290 args.push(key, value);
291 }
292 return command(url, "MSET", args).pipe(Effect.asVoid);
293 },
294 del: (...keys) => command(url, "DEL", keys).pipe(Effect.map(asNumber)),
295 unlink: (...keys) => command(url, "UNLINK", keys).pipe(Effect.map(asNumber)),
296 expire: (key, seconds) =>
297 command(url, "EXPIRE", [key, seconds]).pipe(Effect.map(asBoolean)),
298 persist: (key) => command(url, "PERSIST", [key]).pipe(Effect.map(asBoolean)),
299 incr: (key) => command(url, "INCR", [key]).pipe(Effect.map(asNumber)),
300 incrBy: (key, amount) =>
301 command(url, "INCRBY", [key, amount]).pipe(Effect.map(asNumber)),
302 decr: (key) => command(url, "DECR", [key]).pipe(Effect.map(asNumber)),
303 hset: (key, field, value) => {
304 const args: Arg[] = [key];
305 if (typeof field === "string") {
306 args.push(field, value ?? "");
307 } else {
308 for (const [name, item] of Object.entries(field)) {
309 args.push(name, item);
310 }
311 }
312 return command(url, "HSET", args).pipe(Effect.map(asNumber));
313 },
314 hdel: (key, ...fields) =>
315 command(url, "HDEL", [key, ...fields]).pipe(Effect.map(asNumber)),
316 hincrBy: (key, field, amount) =>
317 command(url, "HINCRBY", [key, field, amount]).pipe(Effect.map(asNumber)),
318 lpush: (key, ...values) =>
319 command(url, "LPUSH", [key, ...values]).pipe(Effect.map(asNumber)),
320 rpush: (key, ...values) =>
321 command(url, "RPUSH", [key, ...values]).pipe(Effect.map(asNumber)),
322 lpop: (key) => command(url, "LPOP", [key]).pipe(Effect.map(asNullableString)),
323 rpop: (key) => command(url, "RPOP", [key]).pipe(Effect.map(asNullableString)),
324 sadd: (key, ...members) =>
325 command(url, "SADD", [key, ...members]).pipe(Effect.map(asNumber)),
326 srem: (key, ...members) =>
327 command(url, "SREM", [key, ...members]).pipe(Effect.map(asNumber)),
328 zadd: (key, score, member) =>
329 command(url, "ZADD", [key, score, member]).pipe(Effect.map(asNumber)),
330 zrem: (key, ...members) =>
331 command(url, "ZREM", [key, ...members]).pipe(Effect.map(asNumber)),
332});
333
334/**
335 * Full Redis client over a runtime Redis URL.
336 *
337 * Fly and Railway `*RedisHttp` layers pass this (or {@link makeRead} /
338 * {@link makeWrite}) as `makeClient`. Tests can also drive RESP
339 * directly via `command` / `run` / `connect`.
340 *
341 * ```typescript
342 * import * as Redis from "alchemy/Redis";
343 *
344 * const cache = Redis.make(url);
345 * yield* cache.set("marker", "hello");
346 * const value = yield* cache.get("marker");
347 * const echoed = yield* cache.send("ECHO", ["hi"]);
348 * ```
349 */
350export const make = (url: Url): Client => ({
351 ...makeRead(url),
352 ...makeWrite(url),
353 send: (name, args = []) => command(url, name, args),
354 pipeline: (commands) => commandPipeline(url, commands),
355});
356
357/**
358 * Read + write client over a runtime Redis URL. Alias of {@link make}.
359 */
360export const makeReadWrite = (url: Url): ReadWriteClient => make(url);