diff --git a/.changeset/nip98-redis-nx.md b/.changeset/nip98-redis-nx.md new file mode 100644 index 00000000..1b0b2d1b --- /dev/null +++ b/.changeset/nip98-redis-nx.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +feat(redis): add setKeyIfNotExists for one-time claims diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index c42bc0a8..77ab9d74 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -24,6 +24,7 @@ export interface ICacheAdapter { getKey(key: string): Promise hasKey(key: string): Promise setKey(key: string, value: string, expirySeconds?: number): Promise + setKeyIfNotExists(key: string, value: string, expirySeconds?: number): Promise addToSortedSet(key: string, set: Record | Record[]): Promise removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise getRangeFromSortedSet(key: string, start: number, stop: number): Promise @@ -33,6 +34,5 @@ export interface ICacheAdapter { getHKey(key: string, field: string): Promise setHKey(key: string, fields: Record): Promise - eval(script: string, keys: string[], args: string[]): Promise } diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index b7a247f8..820de102 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -1,11 +1,10 @@ +import { ICacheAdapter } from '../@types/adapters' import { CacheClient } from '../@types/cache' import { createLogger } from '../factories/logger-factory' -import { ICacheAdapter } from '../@types/adapters' const logger = createLogger('redis-adapter') export class RedisAdapter implements ICacheAdapter { - private connection: Promise private scriptShas: Map = new Map() @@ -70,6 +69,15 @@ export class RedisAdapter implements ICacheAdapter { return 'OK' === (await this.client.set(key, value)) } + public async setKeyIfNotExists(key: string, value: string, expirySeconds?: number): Promise { + await this.connection + logger('set nx %s key', key) + if (typeof expirySeconds === 'number') { + return 'OK' === (await this.client.set(key, value, { EX: expirySeconds, NX: true })) + } + return 'OK' === (await this.client.set(key, value, { NX: true })) + } + public async removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise { await this.connection logger('remove %d..%d range from sorted set %s', min, max, key) @@ -96,7 +104,6 @@ export class RedisAdapter implements ICacheAdapter { return this.client.zAdd(key, members) } - public async deleteKey(key: string): Promise { await this.connection logger('delete %s key', key) @@ -106,13 +113,13 @@ export class RedisAdapter implements ICacheAdapter { public async getHKey(key: string, field: string): Promise { await this.connection logger('get %s field for key %s', field, key) - return await this.client.hGet(key, field) ?? '' + return (await this.client.hGet(key, field)) ?? '' } public async setHKey(key: string, fields: Record): Promise { await this.connection logger('set %s key', key) - return await this.client.hSet(key, fields) >= 0 + return (await this.client.hSet(key, fields)) >= 0 } public async eval(script: string, keys: string[], args: string[]): Promise { @@ -123,6 +130,4 @@ export class RedisAdapter implements ICacheAdapter { } return await this.client.evalSha(this.scriptShas.get(script)!, { keys, arguments: args }) } - - } diff --git a/test/unit/adapters/redis-adapter.spec.ts b/test/unit/adapters/redis-adapter.spec.ts index 9774dc23..0aeffd67 100644 --- a/test/unit/adapters/redis-adapter.spec.ts +++ b/test/unit/adapters/redis-adapter.spec.ts @@ -131,6 +131,25 @@ describe('RedisAdapter', () => { }) }) + describe('setKeyIfNotExists', () => { + it('returns true when NX set creates the key', async () => { + client.set.resolves('OK') + + const result = await adapter.setKeyIfNotExists('key', 'value', 60) + + expect(client.set).to.have.been.calledOnceWithExactly('key', 'value', { EX: 60, NX: true }) + expect(result).to.be.true + }) + + it('returns false when the key already exists', async () => { + client.set.resolves(null) + + const result = await adapter.setKeyIfNotExists('key', 'value', 60) + + expect(result).to.be.false + }) + }) + describe('removeRangeByScoreFromSortedSet', () => { it('calls client.zRemRangeByScore with correct arguments', async () => { client.zRemRangeByScore.resolves(3)