skills/upstash-redis-kv/scripts/upstash-client.ts

747 lines
21 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { parseArgs } from "node:util";
import { Redis } from "@upstash/redis";
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
url: { type: "string" },
token: { type: "string" },
help: { type: "boolean", short: "h" },
// SET options
ex: { type: "string" },
px: { type: "string" },
exat: { type: "string" },
pxat: { type: "string" },
nx: { type: "boolean" },
xx: { type: "boolean" },
keepttl: { type: "boolean" },
get: { type: "boolean" },
// ZRANGE/ZADD options
withscores: { type: "boolean" },
byscore: { type: "boolean" },
bylex: { type: "boolean" },
rev: { type: "boolean" },
limit: { type: "string" },
// ZADD-specific options
gt: { type: "boolean" },
lt: { type: "boolean" },
ch: { type: "boolean" },
// JSON input for complex objects
json: { type: "boolean" },
},
allowPositionals: true,
});
function printHelp() {
console.log(`Usage: bun run upstash-client.ts [options] <command> [args...]
Connection Options:
--url <url> Upstash REST URL (or UPSTASH_REDIS_REST_URL env var)
--token <token> Upstash REST token (or UPSTASH_REDIS_REST_TOKEN env var)
-h, --help Show this help
SET Options:
--ex <seconds> Set expiration in seconds
--px <ms> Set expiration in milliseconds
--exat <ts> Set expiration as Unix timestamp (seconds)
--pxat <ts> Set expiration as Unix timestamp (milliseconds)
--nx Only set if key does not exist
--xx Only set if key already exists
--keepttl Retain existing TTL
--get Return old value
ZADD Options:
--nx Only add new elements
--xx Only update existing elements
--gt Only update when new score > current score
--lt Only update when new score < current score
--ch Return number of changed elements
ZRANGE Options:
--withscores Include scores in output
--byscore Interpret range as score range
--bylex Interpret range as lexicographical range
--rev Reverse order
--limit <off,count> Limit results (e.g., --limit 0,10)
General:
--json Parse next argument as JSON (for complex objects)
Commands:
STRING:
GET <key>
SET <key> <value> [--ex N] [--px N] [--nx] [--xx]
MGET <key1> [key2...]
MSET <key1> <val1> [key2 val2...]
INCR <key>
INCRBY <key> <increment>
DECR <key>
DECRBY <key> <decrement>
APPEND <key> <value>
STRLEN <key>
HASH:
HGET <key> <field>
HSET <key> <field1> <val1> [field2 val2...]
HMGET <key> <field1> [field2...]
HGETALL <key>
HDEL <key> <field1> [field2...]
HEXISTS <key> <field>
HKEYS <key>
HVALS <key>
HLEN <key>
HINCRBY <key> <field> <increment>
HSETNX <key> <field> <value>
LIST:
LPUSH <key> <val1> [val2...]
RPUSH <key> <val1> [val2...]
LPOP <key> [count]
RPOP <key> [count]
LRANGE <key> <start> <stop>
LLEN <key>
LINDEX <key> <index>
LSET <key> <index> <value>
LREM <key> <count> <value>
LTRIM <key> <start> <stop>
LINSERT <key> <BEFORE|AFTER> <pivot> <value>
SET:
SADD <key> <member1> [member2...]
SREM <key> <member1> [member2...]
SMEMBERS <key>
SISMEMBER <key> <member>
SCARD <key>
SPOP <key> [count]
SRANDMEMBER <key> [count]
SINTER <key1> [key2...]
SUNION <key1> [key2...]
SDIFF <key1> [key2...]
SORTED SET:
ZADD <key> <score1> <member1> [score2 member2...] [--nx] [--xx] [--gt] [--lt] [--ch]
ZREM <key> <member1> [member2...]
ZSCORE <key> <member>
ZRANK <key> <member>
ZREVRANK <key> <member>
ZRANGE <key> <start> <stop> [--withscores] [--byscore] [--bylex] [--rev]
ZRANGEBYSCORE <key> <min> <max> [--withscores] [--limit off,count]
ZCARD <key>
ZCOUNT <key> <min> <max>
ZINCRBY <key> <increment> <member>
ZPOPMIN <key> [count]
ZPOPMAX <key> [count]
KEY:
DEL <key1> [key2...]
EXISTS <key1> [key2...]
EXPIRE <key> <seconds>
EXPIREAT <key> <timestamp>
TTL <key>
PTTL <key>
PERSIST <key>
KEYS <pattern>
SCAN <cursor> [MATCH pattern] [COUNT count]
TYPE <key>
RENAME <key> <newkey>
RENAMENX <key> <newkey>
SERVER:
DBSIZE
FLUSHDB
PING
ECHO <message>
Examples:
# Basic string operations
bun run upstash-client.ts SET mykey "hello world"
bun run upstash-client.ts GET mykey
bun run upstash-client.ts SET session:123 "data" --ex 3600
# Hash operations (field/value pairs)
bun run upstash-client.ts HSET user:1 name "John" email "john@example.com" age 30
bun run upstash-client.ts HGET user:1 name
bun run upstash-client.ts HGETALL user:1
# List operations
bun run upstash-client.ts LPUSH tasks "task1" "task2" "task3"
bun run upstash-client.ts LRANGE tasks 0 -1
# Set operations
bun run upstash-client.ts SADD tags "javascript" "redis" "nodejs"
bun run upstash-client.ts SMEMBERS tags
# Sorted set operations (score member pairs)
bun run upstash-client.ts ZADD leaderboard 1000 "player1" 1500 "player2"
bun run upstash-client.ts ZRANGE leaderboard 0 -1 --withscores --rev
# Multiple keys
bun run upstash-client.ts MSET key1 "val1" key2 "val2" key3 "val3"
bun run upstash-client.ts MGET key1 key2 key3
# With explicit credentials
bun run upstash-client.ts --url https://... --token AX... GET mykey`);
}
if (values.help || positionals.length === 0) {
printHelp();
process.exit(values.help ? 0 : 1);
}
const url = values.url || process.env.UPSTASH_REDIS_REST_URL;
const token = values.token || process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) {
console.error("Error: Missing credentials");
console.error("Provide --url and --token, or set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN");
process.exit(1);
}
const redis = new Redis({ url, token });
const [rawCmd, ...args] = positionals;
const cmd = rawCmd.toUpperCase();
// Helper to parse a value (try JSON, fall back to string/number)
function parseValue(val: string): any {
if (values.json) {
try {
return JSON.parse(val);
} catch {
return val;
}
}
// Try to parse as number
const num = Number(val);
if (!isNaN(num) && val.trim() !== "") {
return num;
}
// Try to parse as JSON object/array
if ((val.startsWith("{") && val.endsWith("}")) || (val.startsWith("[") && val.endsWith("]"))) {
try {
return JSON.parse(val);
} catch {
return val;
}
}
return val;
}
// Helper to convert pairs to object: [k1, v1, k2, v2] -> {k1: v1, k2: v2}
function pairsToObject(pairs: string[]): Record<string, any> {
const obj: Record<string, any> = {};
for (let i = 0; i < pairs.length; i += 2) {
if (i + 1 < pairs.length) {
obj[pairs[i]] = parseValue(pairs[i + 1]);
}
}
return obj;
}
// Helper to convert score/member pairs: [s1, m1, s2, m2] -> [{score: s1, member: m1}, ...]
function scoreMemberPairs(pairs: string[]): Array<{ score: number; member: string }> {
const result: Array<{ score: number; member: string }> = [];
for (let i = 0; i < pairs.length; i += 2) {
if (i + 1 < pairs.length) {
result.push({ score: Number(pairs[i]), member: pairs[i + 1] });
}
}
return result;
}
// Build SET options
function buildSetOptions(): Record<string, any> | undefined {
const opts: Record<string, any> = {};
if (values.ex) opts.ex = Number(values.ex);
if (values.px) opts.px = Number(values.px);
if (values.exat) opts.exat = Number(values.exat);
if (values.pxat) opts.pxat = Number(values.pxat);
if (values.nx) opts.nx = true;
if (values.xx) opts.xx = true;
if (values.keepttl) opts.keepttl = true;
if (values.get) opts.get = true;
return Object.keys(opts).length > 0 ? opts : undefined;
}
// Build ZADD options
function buildZaddOptions(): Record<string, any> | undefined {
const opts: Record<string, any> = {};
if (values.nx) opts.nx = true;
if (values.xx) opts.xx = true;
if (values.gt) opts.gt = true;
if (values.lt) opts.lt = true;
if (values.ch) opts.ch = true;
return Object.keys(opts).length > 0 ? opts : undefined;
}
// Build ZRANGE options
function buildZrangeOptions(): Record<string, any> | undefined {
const opts: Record<string, any> = {};
if (values.withscores) opts.withScores = true;
if (values.byscore) opts.byScore = true;
if (values.bylex) opts.byLex = true;
if (values.rev) opts.rev = true;
if (values.limit) {
const [offset, count] = values.limit.split(",").map(Number);
opts.offset = offset;
opts.count = count;
}
return Object.keys(opts).length > 0 ? opts : undefined;
}
// Parse SCAN arguments: SCAN cursor [MATCH pattern] [COUNT count]
function parseScanArgs(args: string[]): { cursor: number; options?: { match?: string; count?: number } } {
const cursor = Number(args[0]) || 0;
const options: { match?: string; count?: number } = {};
for (let i = 1; i < args.length; i++) {
const arg = args[i].toUpperCase();
if (arg === "MATCH" && i + 1 < args.length) {
options.match = args[++i];
} else if (arg === "COUNT" && i + 1 < args.length) {
options.count = Number(args[++i]);
}
}
return { cursor, options: Object.keys(options).length > 0 ? options : undefined };
}
async function execute(): Promise<any> {
// STRING COMMANDS
switch (cmd) {
case "GET":
return redis.get(args[0]);
case "SET": {
const opts = buildSetOptions();
return opts
? redis.set(args[0], parseValue(args[1]), opts)
: redis.set(args[0], parseValue(args[1]));
}
case "SETNX":
return redis.setnx(args[0], parseValue(args[1]));
case "SETEX":
return redis.setex(args[0], Number(args[1]), parseValue(args[2]));
case "PSETEX":
return redis.psetex(args[0], Number(args[1]), parseValue(args[2]));
case "MGET":
return redis.mget(...args);
case "MSET":
return redis.mset(pairsToObject(args));
case "MSETNX":
return redis.msetnx(pairsToObject(args));
case "INCR":
return redis.incr(args[0]);
case "INCRBY":
return redis.incrby(args[0], Number(args[1]));
case "INCRBYFLOAT":
return redis.incrbyfloat(args[0], Number(args[1]));
case "DECR":
return redis.decr(args[0]);
case "DECRBY":
return redis.decrby(args[0], Number(args[1]));
case "APPEND":
return redis.append(args[0], args[1]);
case "STRLEN":
return redis.strlen(args[0]);
case "GETRANGE":
return redis.getrange(args[0], Number(args[1]), Number(args[2]));
case "SETRANGE":
return redis.setrange(args[0], Number(args[1]), args[2]);
// HASH COMMANDS
case "HGET":
return redis.hget(args[0], args[1]);
case "HSET":
return redis.hset(args[0], pairsToObject(args.slice(1)));
case "HSETNX":
return redis.hsetnx(args[0], args[1], parseValue(args[2]));
case "HMSET":
return redis.hset(args[0], pairsToObject(args.slice(1)));
case "HMGET":
return redis.hmget(args[0], ...args.slice(1));
case "HGETALL":
return redis.hgetall(args[0]);
case "HDEL":
return redis.hdel(args[0], ...args.slice(1));
case "HEXISTS":
return redis.hexists(args[0], args[1]);
case "HKEYS":
return redis.hkeys(args[0]);
case "HVALS":
return redis.hvals(args[0]);
case "HLEN":
return redis.hlen(args[0]);
case "HINCRBY":
return redis.hincrby(args[0], args[1], Number(args[2]));
case "HINCRBYFLOAT":
return redis.hincrbyfloat(args[0], args[1], Number(args[2]));
case "HSCAN": {
const cursor = Number(args[1]) || 0;
const scanOpts: { match?: string; count?: number } = {};
for (let i = 2; i < args.length; i++) {
if (args[i].toUpperCase() === "MATCH" && i + 1 < args.length) scanOpts.match = args[++i];
if (args[i].toUpperCase() === "COUNT" && i + 1 < args.length) scanOpts.count = Number(args[++i]);
}
return redis.hscan(args[0], cursor, Object.keys(scanOpts).length > 0 ? scanOpts : undefined);
}
// LIST COMMANDS
case "LPUSH":
return redis.lpush(args[0], ...args.slice(1).map(parseValue));
case "RPUSH":
return redis.rpush(args[0], ...args.slice(1).map(parseValue));
case "LPUSHX":
return redis.lpushx(args[0], ...args.slice(1).map(parseValue));
case "RPUSHX":
return redis.rpushx(args[0], ...args.slice(1).map(parseValue));
case "LPOP":
return args.length > 1 ? redis.lpop(args[0], Number(args[1])) : redis.lpop(args[0]);
case "RPOP":
return args.length > 1 ? redis.rpop(args[0], Number(args[1])) : redis.rpop(args[0]);
case "LRANGE":
return redis.lrange(args[0], Number(args[1]), Number(args[2]));
case "LLEN":
return redis.llen(args[0]);
case "LINDEX":
return redis.lindex(args[0], Number(args[1]));
case "LSET":
return redis.lset(args[0], Number(args[1]), parseValue(args[2]));
case "LREM":
return redis.lrem(args[0], Number(args[1]), parseValue(args[2]));
case "LTRIM":
return redis.ltrim(args[0], Number(args[1]), Number(args[2]));
case "LINSERT":
return redis.linsert(
args[0],
args[1].toLowerCase() as "before" | "after",
parseValue(args[2]),
parseValue(args[3])
);
case "LPOS":
return redis.lpos(args[0], parseValue(args[1]));
case "LMOVE":
return redis.lmove(
args[0],
args[1],
args[2].toUpperCase() as "LEFT" | "RIGHT",
args[3].toUpperCase() as "LEFT" | "RIGHT"
);
// SET COMMANDS
case "SADD":
return redis.sadd(args[0], ...args.slice(1).map(parseValue));
case "SREM":
return redis.srem(args[0], ...args.slice(1).map(parseValue));
case "SMEMBERS":
return redis.smembers(args[0]);
case "SISMEMBER":
return redis.sismember(args[0], parseValue(args[1]));
case "SMISMEMBER":
return redis.smismember(args[0], args.slice(1).map(parseValue));
case "SCARD":
return redis.scard(args[0]);
case "SPOP":
return args.length > 1 ? redis.spop(args[0], Number(args[1])) : redis.spop(args[0]);
case "SRANDMEMBER":
return args.length > 1 ? redis.srandmember(args[0], Number(args[1])) : redis.srandmember(args[0]);
case "SINTER":
return redis.sinter(...args);
case "SINTERSTORE":
return redis.sinterstore(args[0], ...args.slice(1));
case "SUNION":
return redis.sunion(...args);
case "SUNIONSTORE":
return redis.sunionstore(args[0], ...args.slice(1));
case "SDIFF":
return redis.sdiff(...args);
case "SDIFFSTORE":
return redis.sdiffstore(args[0], ...args.slice(1));
case "SMOVE":
return redis.smove(args[0], args[1], parseValue(args[2]));
case "SSCAN": {
const cursor = Number(args[1]) || 0;
const scanOpts: { match?: string; count?: number } = {};
for (let i = 2; i < args.length; i++) {
if (args[i].toUpperCase() === "MATCH" && i + 1 < args.length) scanOpts.match = args[++i];
if (args[i].toUpperCase() === "COUNT" && i + 1 < args.length) scanOpts.count = Number(args[++i]);
}
return redis.sscan(args[0], cursor, Object.keys(scanOpts).length > 0 ? scanOpts : undefined);
}
// SORTED SET COMMANDS
case "ZADD": {
const zaddOpts = buildZaddOptions();
const members = scoreMemberPairs(args.slice(1));
return zaddOpts
? redis.zadd(args[0], zaddOpts, ...members)
: redis.zadd(args[0], ...members);
}
case "ZREM":
return redis.zrem(args[0], ...args.slice(1));
case "ZSCORE":
return redis.zscore(args[0], args[1]);
case "ZMSCORE":
return redis.zmscore(args[0], ...args.slice(1));
case "ZRANK":
return redis.zrank(args[0], args[1]);
case "ZREVRANK":
return redis.zrevrank(args[0], args[1]);
case "ZRANGE": {
const opts = buildZrangeOptions();
return opts
? redis.zrange(args[0], args[1], args[2], opts)
: redis.zrange(args[0], args[1], args[2]);
}
case "ZRANGEBYSCORE": {
const opts: { withScores?: boolean; offset?: number; count?: number } = {};
if (values.withscores) opts.withScores = true;
if (values.limit) {
const [offset, count] = values.limit.split(",").map(Number);
opts.offset = offset;
opts.count = count;
}
return Object.keys(opts).length > 0
? redis.zrange(args[0], args[1], args[2], { byScore: true, ...opts })
: redis.zrange(args[0], args[1], args[2], { byScore: true });
}
case "ZREVRANGE": {
const opts = buildZrangeOptions();
return redis.zrange(args[0], args[1], args[2], { rev: true, ...opts });
}
case "ZREVRANGEBYSCORE": {
const opts: { withScores?: boolean; offset?: number; count?: number } = {};
if (values.withscores) opts.withScores = true;
if (values.limit) {
const [offset, count] = values.limit.split(",").map(Number);
opts.offset = offset;
opts.count = count;
}
return redis.zrange(args[0], args[2], args[1], { byScore: true, rev: true, ...opts });
}
case "ZCARD":
return redis.zcard(args[0]);
case "ZCOUNT":
return redis.zcount(args[0], args[1], args[2]);
case "ZINCRBY":
return redis.zincrby(args[0], Number(args[1]), args[2]);
case "ZPOPMIN":
return args.length > 1 ? redis.zpopmin(args[0], Number(args[1])) : redis.zpopmin(args[0]);
case "ZPOPMAX":
return args.length > 1 ? redis.zpopmax(args[0], Number(args[1])) : redis.zpopmax(args[0]);
case "ZREMRANGEBYRANK":
return redis.zremrangebyrank(args[0], Number(args[1]), Number(args[2]));
case "ZREMRANGEBYSCORE":
return redis.zremrangebyscore(args[0], args[1], args[2]);
case "ZINTERSTORE":
return redis.zinterstore(args[0], Number(args[1]), ...args.slice(2));
case "ZUNIONSTORE":
return redis.zunionstore(args[0], Number(args[1]), ...args.slice(2));
case "ZSCAN": {
const cursor = Number(args[1]) || 0;
const scanOpts: { match?: string; count?: number } = {};
for (let i = 2; i < args.length; i++) {
if (args[i].toUpperCase() === "MATCH" && i + 1 < args.length) scanOpts.match = args[++i];
if (args[i].toUpperCase() === "COUNT" && i + 1 < args.length) scanOpts.count = Number(args[++i]);
}
return redis.zscan(args[0], cursor, Object.keys(scanOpts).length > 0 ? scanOpts : undefined);
}
// KEY COMMANDS
case "DEL":
return redis.del(...args);
case "UNLINK":
return redis.unlink(...args);
case "EXISTS":
return redis.exists(...args);
case "EXPIRE":
return redis.expire(args[0], Number(args[1]));
case "EXPIREAT":
return redis.expireat(args[0], Number(args[1]));
case "PEXPIRE":
return redis.pexpire(args[0], Number(args[1]));
case "PEXPIREAT":
return redis.pexpireat(args[0], Number(args[1]));
case "TTL":
return redis.ttl(args[0]);
case "PTTL":
return redis.pttl(args[0]);
case "PERSIST":
return redis.persist(args[0]);
case "KEYS":
return redis.keys(args[0]);
case "SCAN": {
const { cursor, options } = parseScanArgs(args);
return options ? redis.scan(cursor, options) : redis.scan(cursor);
}
case "TYPE":
return redis.type(args[0]);
case "RENAME":
return redis.rename(args[0], args[1]);
case "RENAMENX":
return redis.renamenx(args[0], args[1]);
case "COPY":
return redis.copy(args[0], args[1]);
case "DUMP":
return redis.dump(args[0]);
case "OBJECT": {
const subcmd = args[0].toUpperCase();
if (subcmd === "ENCODING") return redis.objectEncoding(args[1]);
if (subcmd === "FREQ") return redis.objectFreq(args[1]);
if (subcmd === "IDLETIME") return redis.objectIdletime(args[1]);
if (subcmd === "REFCOUNT") return redis.objectRefcount(args[1]);
throw new Error(`Unknown OBJECT subcommand: ${subcmd}`);
}
case "RANDOMKEY":
return redis.randomkey();
case "TOUCH":
return redis.touch(...args);
// SERVER COMMANDS
case "DBSIZE":
return redis.dbsize();
case "FLUSHDB":
return redis.flushdb();
case "FLUSHALL":
return redis.flushall();
case "PING":
return args.length > 0 ? redis.ping(args[0]) : redis.ping();
case "ECHO":
return redis.echo(args[0]);
case "TIME":
return redis.time();
case "INFO":
return redis.info(args[0]);
// JSON COMMANDS (if using Upstash JSON)
case "JSON.SET":
return (redis as any).json.set(args[0], args[1], parseValue(args[2]));
case "JSON.GET":
return (redis as any).json.get(args[0], args.length > 1 ? args[1] : "$");
case "JSON.DEL":
return (redis as any).json.del(args[0], args.length > 1 ? args[1] : "$");
default:
throw new Error(`Unknown or unsupported command: ${cmd}`);
}
}
try {
const result = await execute();
if (result === null || result === undefined) {
console.log("(nil)");
} else if (typeof result === "object") {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(result);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}