diff --git a/pkg/auto-pipeline.test.ts b/pkg/auto-pipeline.test.ts index 4ec68c07..222b2fb0 100644 --- a/pkg/auto-pipeline.test.ts +++ b/pkg/auto-pipeline.test.ts @@ -34,7 +34,9 @@ describe("Auto pipeline", () => { redis.decrby(newKey(), 1), redis.del(newKey()), redis.echo("hello"), + redis.evalRo("return ARGV[1]", [], ["Hello"]), redis.eval("return ARGV[1]", [], ["Hello"]), + redis.evalshaRo(scriptHash, [], ["Hello"]), redis.evalsha(scriptHash, [], ["Hello"]), redis.exists(newKey()), redis.expire(newKey(), 5), @@ -149,7 +151,7 @@ describe("Auto pipeline", () => { redis.json.arrappend(persistentKey3, "$.log", '"three"'), ]); expect(result).toBeTruthy(); - expect(result.length).toBe(122); // returns + expect(result.length).toBe(124); // returns // @ts-expect-error pipelineCounter is not in type but accessible120 results expect(redis.pipelineCounter).toBe(1); }); diff --git a/pkg/commands/evalRo.test.ts b/pkg/commands/evalRo.test.ts new file mode 100644 index 00000000..0b162a2d --- /dev/null +++ b/pkg/commands/evalRo.test.ts @@ -0,0 +1,41 @@ +import { EvalROCommand } from "./evalRo"; + +import { keygen, newHttpClient, randomID } from "../test-utils"; + +import { afterAll, describe, expect, test } from "bun:test"; +import { SetCommand } from "./set"; +const client = newHttpClient(); + +const { newKey, cleanup } = keygen(); +afterAll(cleanup); + +describe("without keys", () => { + test("returns something", async () => { + const value = randomID(); + const res = await new EvalROCommand(["return ARGV[1]", [], [value]]).exec(client); + expect(res).toEqual(value); + }); +}); + +describe("with keys", () => { + test("returns something", async () => { + const value = randomID(); + const key = newKey(); + await new SetCommand([key, value]).exec(client); + const res = await new EvalROCommand([`return redis.call("GET", KEYS[1])`, [key], []]).exec( + client + ); + expect(res).toEqual(value); + }); +}); + +describe("with keys and write commands", () => { + test("throws", async () => { + const value = randomID(); + const key = newKey(); + await new SetCommand([key, value]).exec(client); + expect(async () => { + await new EvalROCommand([`return redis.call("DEL", KEYS[1])`, [key], []]).exec(client); + }).toThrow(); + }); +}); diff --git a/pkg/commands/evalRo.ts b/pkg/commands/evalRo.ts new file mode 100644 index 00000000..7520a2fd --- /dev/null +++ b/pkg/commands/evalRo.ts @@ -0,0 +1,14 @@ +import type { CommandOptions } from "./command"; +import { Command } from "./command"; + +/** + * @see https://redis.io/commands/eval_ro + */ +export class EvalROCommand extends Command { + constructor( + [script, keys, args]: [script: string, keys: string[], args: TArgs], + opts?: CommandOptions + ) { + super(["eval_ro", script, keys.length, ...keys, ...(args ?? [])], opts); + } +} diff --git a/pkg/commands/evalshaRo.test.ts b/pkg/commands/evalshaRo.test.ts new file mode 100644 index 00000000..d60a53f5 --- /dev/null +++ b/pkg/commands/evalshaRo.test.ts @@ -0,0 +1,43 @@ +import { keygen, newHttpClient, randomID } from "../test-utils"; + +import { afterAll, describe, expect, test } from "bun:test"; +import { EvalshaROCommand } from "./evalshaRo"; +import { ScriptLoadCommand } from "./script_load"; +import { SetCommand } from "./set"; + +const client = newHttpClient(); + +const { newKey, cleanup } = keygen(); +afterAll(cleanup); + +describe("without keys", () => { + test("returns something", async () => { + const value = randomID(); + const sha1 = await new ScriptLoadCommand([`return {ARGV[1], "${value}"}`]).exec(client); + const res = await new EvalshaROCommand([sha1, [], [value]]).exec(client); + expect(res).toEqual([value, value]); + }); +}); + +describe("with keys", () => { + test("returns something", async () => { + const value = randomID(); + const key = newKey(); + await new SetCommand([key, value]).exec(client); + const sha1 = await new ScriptLoadCommand([`return redis.call("GET", KEYS[1])`]).exec(client); + const res = await new EvalshaROCommand([sha1, [key], []]).exec(client); + expect(res).toEqual(value); + }); +}); + +describe("with keys and write commands", () => { + test("throws", async () => { + const value = randomID(); + const key = newKey(); + await new SetCommand([key, value]).exec(client); + const sha1 = await new ScriptLoadCommand([`return redis.call("DEL", KEYS[1])`]).exec(client); + expect(async () => { + await new EvalshaROCommand([sha1, [key], []]).exec(client); + }).toThrow(); + }); +}); diff --git a/pkg/commands/evalshaRo.ts b/pkg/commands/evalshaRo.ts new file mode 100644 index 00000000..85f98587 --- /dev/null +++ b/pkg/commands/evalshaRo.ts @@ -0,0 +1,14 @@ +import type { CommandOptions } from "./command"; +import { Command } from "./command"; + +/** + * @see https://redis.io/commands/evalsha_ro + */ +export class EvalshaROCommand extends Command { + constructor( + [sha, keys, args]: [sha: string, keys: string[], args?: TArgs], + opts?: CommandOptions + ) { + super(["evalsha_ro", sha, keys.length, ...keys, ...(args ?? [])], opts); + } +} diff --git a/pkg/commands/mod.ts b/pkg/commands/mod.ts index 65b71cb0..430b2ba1 100644 --- a/pkg/commands/mod.ts +++ b/pkg/commands/mod.ts @@ -10,7 +10,9 @@ export * from "./decr"; export * from "./decrby"; export * from "./del"; export * from "./echo"; +export * from "./evalRo"; export * from "./eval"; +export * from "./evalshaRo"; export * from "./evalsha"; export * from "./exec"; export * from "./exists"; diff --git a/pkg/commands/types.ts b/pkg/commands/types.ts index 330da960..cc02ec46 100644 --- a/pkg/commands/types.ts +++ b/pkg/commands/types.ts @@ -8,7 +8,9 @@ export { type DecrCommand } from "./decr"; export { type DecrByCommand } from "./decrby"; export { type DelCommand } from "./del"; export { type EchoCommand } from "./echo"; +export { type EvalROCommand } from "./evalRo"; export { type EvalCommand } from "./eval"; +export { type EvalshaROCommand } from "./evalshaRo"; export { type EvalshaCommand } from "./evalsha"; export { type ExistsCommand } from "./exists"; export { type ExpireCommand } from "./expire"; diff --git a/pkg/pipeline.test.ts b/pkg/pipeline.test.ts index 44f0cbc6..fca18987 100644 --- a/pkg/pipeline.test.ts +++ b/pkg/pipeline.test.ts @@ -136,7 +136,9 @@ describe("use all the things", () => { .decrby(newKey(), 1) .del(newKey()) .echo("hello") + .evalRo("return ARGV[1]", [], ["Hello"]) .eval("return ARGV[1]", [], ["Hello"]) + .evalshaRo(scriptHash, [], ["Hello"]) .evalsha(scriptHash, [], ["Hello"]) .exists(newKey()) .expire(newKey(), 5) @@ -250,7 +252,7 @@ describe("use all the things", () => { .json.set(newKey(), "$", { hello: "world" }); const res = await p.exec(); - expect(res.length).toEqual(122); + expect(res.length).toEqual(124); }); }); describe("keep errors", () => { diff --git a/pkg/pipeline.ts b/pkg/pipeline.ts index 90ab2f02..20657f5a 100644 --- a/pkg/pipeline.ts +++ b/pkg/pipeline.ts @@ -18,7 +18,9 @@ import { DecrCommand, DelCommand, EchoCommand, + EvalROCommand, EvalCommand, + EvalshaROCommand, EvalshaCommand, ExistsCommand, ExpireAtCommand, @@ -449,6 +451,13 @@ export class Pipeline[] = []> { echo = (...args: CommandArgs) => this.chain(new EchoCommand(args, this.commandOptions)); + /** + * @see https://redis.io/commands/eval_ro + */ + evalRo = ( + ...args: [script: string, keys: string[], args: TArgs] + ) => this.chain(new EvalROCommand(args, this.commandOptions)); + /** * @see https://redis.io/commands/eval */ @@ -456,6 +465,13 @@ export class Pipeline[] = []> { ...args: [script: string, keys: string[], args: TArgs] ) => this.chain(new EvalCommand(args, this.commandOptions)); + /** + * @see https://redis.io/commands/evalsha_ro + */ + evalshaRo = ( + ...args: [sha1: string, keys: string[], args: TArgs] + ) => this.chain(new EvalshaROCommand(args, this.commandOptions)); + /** * @see https://redis.io/commands/evalsha */ diff --git a/pkg/redis.ts b/pkg/redis.ts index 86545515..b6a35268 100644 --- a/pkg/redis.ts +++ b/pkg/redis.ts @@ -18,7 +18,9 @@ import { DecrCommand, DelCommand, EchoCommand, + EvalROCommand, EvalCommand, + EvalshaROCommand, EvalshaCommand, ExecCommand, ExistsCommand, @@ -183,6 +185,7 @@ import { ZMScoreCommand } from "./commands/zmscore"; import type { Requester, UpstashRequest, UpstashResponse } from "./http"; import { Pipeline } from "./pipeline"; import { Script } from "./script"; +import { ScriptRO } from "./scriptRo"; import type { CommandArgs, RedisOptions, Telemetry } from "./types"; // See https://github.com/upstash/upstash-redis/issues/342 @@ -393,9 +396,41 @@ export class Redis { } }; - createScript(script: string): Script { - return new Script(this, script); + /** + * Creates a new script. + * + * Scripts offer the ability to optimistically try to execute a script without having to send the + * entire script to the server. If the script is loaded on the server, it tries again by sending + * the entire script. Afterwards, the script is cached on the server. + * + * @param script - The script to create + * @param opts - Optional options to pass to the script `{ readonly?: boolean }` + * @returns A new script + * + * @example + * ```ts + * const redis = new Redis({...}) + * + * const script = redis.createScript("return ARGV[1];") + * const arg1 = await script.eval([], ["Hello World"]) + * expect(arg1, "Hello World") + * ``` + * @example + * ```ts + * const redis = new Redis({...}) + * + * const script = redis.createScript("return ARGV[1];", { readonly: true }) + * const arg1 = await script.evalRo([], ["Hello World"]) + * expect(arg1, "Hello World") + * ``` + */ + createScript(script: string): Script; + createScript(script: string, opts: { readonly?: false }): Script; + createScript(script: string, opts: { readonly: true }): ScriptRO; + createScript(script: string, opts?: { readonly?: boolean }): Script | ScriptRO { + return opts?.readonly ? new ScriptRO(this, script) : new Script(this, script); } + /** * Create a new pipeline that allows you to send requests in bulk. * @@ -520,6 +555,13 @@ export class Redis { echo = (...args: CommandArgs) => new EchoCommand(args, this.opts).exec(this.client); + /** + * @see https://redis.io/commands/eval_ro + */ + evalRo = ( + ...args: [script: string, keys: string[], args: TArgs] + ) => new EvalROCommand(args, this.opts).exec(this.client); + /** * @see https://redis.io/commands/eval */ @@ -527,6 +569,13 @@ export class Redis { ...args: [script: string, keys: string[], args: TArgs] ) => new EvalCommand(args, this.opts).exec(this.client); + /** + * @see https://redis.io/commands/evalsha_ro + */ + evalshaRo = ( + ...args: [sha1: string, keys: string[], args: TArgs] + ) => new EvalshaROCommand(args, this.opts).exec(this.client); + /** * @see https://redis.io/commands/evalsha */ diff --git a/pkg/scriptRo.test.ts b/pkg/scriptRo.test.ts new file mode 100644 index 00000000..9b8b4ffb --- /dev/null +++ b/pkg/scriptRo.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Redis } from "./redis"; +import { keygen, newHttpClient, randomID } from "./test-utils"; +const client = newHttpClient(); + +const { newKey, cleanup } = keygen(); +afterEach(cleanup); + +describe("create a new readonly script", () => { + test( + "creates a new readonly script", + async () => { + const redis = new Redis(client); + const value = randomID(); + const key = newKey(); + await redis.set(key, value); + const script = redis.createScript("return redis.call('GET', KEYS[1]);", { readonly: true }); + + const res = await script.evalRo([key], []); + expect(res).toEqual(value); + }, + { timeout: 15_000 } + ); + + test( + "throws when write commands are used", + async () => { + const redis = new Redis(client); + const value = randomID(); + const key = newKey(); + await redis.set(key, value); + const script = redis.createScript("return redis.call('DEL', KEYS[1]);", { readonly: true }); + + expect(async () => { + await script.evalRo([key], []); + }).toThrow(); + }, + { timeout: 15_000 } + ); +}); diff --git a/pkg/scriptRo.ts b/pkg/scriptRo.ts new file mode 100644 index 00000000..2b8207fe --- /dev/null +++ b/pkg/scriptRo.ts @@ -0,0 +1,68 @@ +import Hex from "crypto-js/enc-hex.js"; +import sha1 from "crypto-js/sha1.js"; +import type { Redis } from "./redis"; + +/** + * Creates a new script. + * + * Scripts offer the ability to optimistically try to execute a script without having to send the + * entire script to the server. If the script is loaded on the server, it tries again by sending + * the entire script. Afterwards, the script is cached on the server. + * + * @example + * ```ts + * const redis = new Redis({...}) + * + * const script = redis.createScript("return ARGV[1];", { readOnly: true }) + * const arg1 = await script.evalRo([], ["Hello World"]) + * expect(arg1, "Hello World") + * ``` + */ +export class ScriptRO { + public readonly script: string; + public readonly sha1: string; + private readonly redis: Redis; + + constructor(redis: Redis, script: string) { + this.redis = redis; + this.sha1 = this.digest(script); + this.script = script; + } + + /** + * Send an `EVAL_RO` command to redis. + */ + public async evalRo(keys: string[], args: string[]): Promise { + return await this.redis.evalRo(this.script, keys, args); + } + + /** + * Calculates the sha1 hash of the script and then calls `EVALSHA_RO`. + */ + public async evalshaRo(keys: string[], args: string[]): Promise { + return await this.redis.evalshaRo(this.sha1, keys, args); + } + + /** + * Optimistically try to run `EVALSHA_RO` first. + * If the script is not loaded in redis, it will fall back and try again with `EVAL_RO`. + * + * Following calls will be able to use the cached script + */ + public async exec(keys: string[], args: string[]): Promise { + const res = await this.redis.evalshaRo(this.sha1, keys, args).catch(async (error) => { + if (error instanceof Error && error.message.toLowerCase().includes("noscript")) { + return await this.redis.evalRo(this.script, keys, args); + } + throw error; + }); + return res as TResult; + } + + /** + * Compute the sha1 hash of the script and return its hex representation. + */ + private digest(s: string): string { + return Hex.stringify(sha1(s)); + } +}