# jg-redis

> A read-through cache that keeps working when Redis fails, and fail-closed one-time claims and single-use values, on node-redis.

- **Version:** `0.2.0`
- **Runtimes:** Node.js
- **Install:** `npm install jg-redis`
- **On npm:** [jg-redis](https://www.npmjs.com/package/jg-redis)
- **Source:** [packages/jg-redis](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-redis)
- **Recipes:** [jg-redis recipes](https://jg-packages.jesusgraterol.dev/packages/jg-redis/recipes/index.md)

## Usage

Connect one client per Redis server, and close it at shutdown:

```ts
import { connectRedis } from 'jg-redis';

const redis = await connectRedis({
  socket: { host: 'cache.internal', port: 6379 },
  password: 'secret',
  onError: (error) => console.error('Redis', error),
});

await redis.close(); // waits for pending replies, and never flushes any data
```

The client sends its errors, such as a dropped connection, to `onError`, `console.error` by default, instead of crashing the process. A command sent while it's disconnected is rejected instead of queued and replayed later, and connecting gives up after 10 seconds unless `socket.connectTimeout` says otherwise. `redis.client` is the node-redis client, for every other command.

### A read-through cache

```ts
import { connectRedis, createCache } from 'jg-redis';
import { z } from 'zod';

declare const redis: Awaited<ReturnType<typeof connectRedis>>;
declare const loadProfile: (id: string) => Promise<{ name: string } | null>;

const cache = createCache(redis);

const profile = await cache.getOrLoad('user:42:profile', {
  schema: z.object({ name: z.string() }).nullable(),
  ttlSeconds: 300,
  load: () => loadProfile('42'),
});

await cache.invalidate('user:42:profile'); // after the profile changes
```

- Concurrent misses for a key in the process share one load.
- A cached `null` is a hit, not a miss.
- A cached value its schema rejects, such as one from an older shape, is deleted and loaded again.
- Every value is stored with its TTL in the same command, and a value over 1 MiB isn't stored.
- When Redis fails to read, the value is loaded instead; when a write fails, it's skipped. Both go to `onError`, so Redis being down slows the app without breaking it.
- `invalidate` keeps a load already in flight in the process from storing its older value. Across processes, an older load can still store its value after the invalidation, until its TTL ends, so keep TTLs short for data that changes.

### One-time claims and single-use values

`jg-redis/coordination` is for security: replay protection for one-time codes, and values one request takes, such as a sign-in challenge. It fails closed, throwing `COORDINATION_FAILED` when Redis fails, instead of letting the request through.

```ts
import { connectRedis } from 'jg-redis';
import { claimOnce, storeSingleUseValue, takeSingleUseValue } from 'jg-redis/coordination';
import { z } from 'zod';

declare const redis: Awaited<ReturnType<typeof connectRedis>>;

// true for the first use of the code, false for a replay
const isFirstUse = await claimOnce(redis, 'user:42:used-otp', '123456', 90);

await storeSingleUseValue(redis, 'user-auth:challenge:9f86d081', { userId: '42' }, 300);
const challenge = await takeSingleUseValue(
  redis,
  'user-auth:challenge:9f86d081',
  z.object({ userId: z.string() }),
); // null once taken or expired
```

`claimOnce` stores only the token's SHA-256 hash. Name a single-use value's key after a hash of the token that finds it, never the token. A value that fails its schema throws `COORDINATION_VALUE_INVALID`, without a cause or the value, which might hold a secret.

These keys must never be evicted early, so run the server with `maxmemory-policy noeviction`, or keep them on a separate instance from the cache. Under `noeviction`, a full server rejects writes: the cache skips them, and coordination throws.

### A blocklist

`createBlocklist` keeps a list of blocked values, such as IP addresses, in one Redis SET, for a check on every request. Values are compared exactly, so normalize them the same way when adding and checking:

```ts
import { createBlocklist } from 'jg-redis';

const ipBlocklist = createBlocklist(redis, { key: 'ip-blocklist' });

await ipBlocklist.add(['203.0.113.7']); // and remove([...]), 1,000 values per command
const isBlocked = await ipBlocklist.has('203.0.113.7');
await ipBlocklist.replaceAll(blockedIps); // atomic: readers see the old list or the new one
```

`replaceAll` fills a staging SET, `${key}:replace:<uuid>`, and renames it over the list, so a large list is never seen half replaced; an empty list deletes the key. Every command failure throws `BLOCKLIST_FAILED`, with the cause, so a guard can fail closed and refuse the request.

The list has no expiry, but a flush or an eviction empties it, and then it lets everyone through. Keep the authoritative list in a database, restore it with `replaceAll` at boot and on a schedule, and run the server with `maxmemory-policy noeviction`. On Redis Cluster, give the key a hash tag, such as `{ip-blocklist}`, so the staging key lands in the same slot and the rename works.

### Errors

| Code                         | When                                                                              |
| ---------------------------- | --------------------------------------------------------------------------------- |
| `INVALID_TTL`                | A time-to-live isn't a whole number of seconds, from 1                            |
| `COORDINATION_FAILED`        | Redis couldn't complete a claim, a store, or a take                               |
| `COORDINATION_VALUE_INVALID` | A single-use value isn't valid JSON or doesn't match its schema                   |
| `BLOCKLIST_FAILED`           | Redis couldn't complete a blocklist command                                       |
| `BLOCKLIST_VALUE_INVALID`    | A blocklist value isn't a string of 1 to 512 characters, or a list isn't an array |
| `INVALID_BLOCKLIST_KEY`      | A blocklist's key is empty                                                        |

## <a id="api-reference"></a>jg-redis API reference

Every export of `jg-redis@0.2.0`, 15 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.

### <a id="api-entry-main"></a>`jg-redis`

- Functions: [`connectRedis`](#api-connectRedis), [`createBlocklist`](#api-createBlocklist), [`createCache`](#api-createCache)
- Constants: [`ERRORS`](#api-ERRORS)
- Types: [`IBlocklist`](#api-IBlocklist), [`IBlocklistOptions`](#api-IBlocklistOptions), [`ICache`](#api-ICache), [`IGetOrLoadOptions`](#api-IGetOrLoadOptions), [`IRedis`](#api-IRedis), [`IRedisClient`](#api-IRedisClient), [`IRedisOptions`](#api-IRedisOptions), [`ISchema`](#api-ISchema)

#### <a id="api-functions"></a>Functions in `jg-redis`

##### <a id="api-connectRedis"></a>`connectRedis`

*Function*

Connects a Redis client that's safe to share: its errors go to onError instead of crashing the process, a command sent while disconnected is rejected instead of queued and replayed later, and connecting gives up after 10 seconds unless the socket options set another timeout. Closing it waits for pending replies and never flushes any data.

```ts
connectRedis(options: IRedisOptions): Promise<IRedis>
```

**Parameters**

- `options` `IRedisOptions`: node-redis's client options, plus onError.

**Returns** `Promise<IRedis>`: A promise that resolves to the connected client.

**Example**

```ts
import { connectRedis } from 'jg-redis';

const redis = await connectRedis({
  socket: { host: 'localhost', port: 6379 },
  password: 'secret',
  onError: (error) => console.error('Redis', error),
});

await redis.client.set('greeting', 'hello', { EX: 60 });
await redis.close();
```

##### <a id="api-createBlocklist"></a>`createBlocklist`

*Function*

Creates a list of blocked values, such as IP addresses, in one Redis SET. Values are compared exactly, so normalize them the same way when adding and checking. The list never expires, so a flush or an eviction empties it: keep the authoritative list in a database and restore it with replaceAll, at boot or on a schedule. Every command failure throws BLOCKLIST_FAILED, with the cause, so a caller can fail closed.

```ts
createBlocklist(redis: IRedis, options: IBlocklistOptions): IBlocklist
```

**Parameters**

- `redis` `IRedis`: The client.
- `options` `IBlocklistOptions`: The key of the SET.

**Returns** `IBlocklist`: The list.

**Throws**

- `INVALID_BLOCKLIST_KEY`: The blocklist key must be a non-empty string.

**Example**

```ts
import { connectRedis, createBlocklist } from 'jg-redis';

declare const redis: Awaited<ReturnType<typeof connectRedis>>;

const ipBlocklist = createBlocklist(redis, { key: 'ip-blocklist' });
await ipBlocklist.add(['203.0.113.7']);
await ipBlocklist.has('203.0.113.7'); // true
await ipBlocklist.replaceAll(['198.51.100.1', '198.51.100.2']); // 203.0.113.7 is gone
```

##### <a id="api-createCache"></a>`createCache`

*Function*

Creates a read-through cache on a Redis client, for values that are slow to load and fine to serve a little stale. A failed read loads the value instead, and a failed or oversized write is skipped, so Redis being down slows the app without breaking it; both failures go to the client's onError. Concurrent misses for one key in this process share one load.

```ts
createCache(redis: IRedis): ICache
```

**Parameters**

- `redis` `IRedis`: The client, from connectRedis.

**Returns** `ICache`: The cache.

**Example**

```ts
import { connectRedis, createCache } from 'jg-redis';
import { z } from 'zod';

declare const redis: Awaited<ReturnType<typeof connectRedis>>;
declare const loadProfile: (id: string) => Promise<{ name: string }>;

const cache = createCache(redis);
const profile = await cache.getOrLoad('user:42:profile', {
  schema: z.object({ name: z.string() }),
  ttlSeconds: 300,
  load: () => loadProfile('42'),
});
```

#### <a id="api-constants"></a>Constants in `jg-redis`

##### <a id="api-ERRORS"></a>`ERRORS`

*Constant*

the codes for every error thrown by the package

```ts
const ERRORS: { INVALID_TTL: "INVALID_TTL"; COORDINATION_FAILED: "COORDINATION_FAILED"; COORDINATION_VALUE_INVALID: "COORDINATION_VALUE_INVALID"; BLOCKLIST_FAILED: "BLOCKLIST_FAILED"; BLOCKLIST_VALUE_INVALID: "BLOCKLIST_VALUE_INVALID"; INVALID_BLOCKLIST_KEY: "INVALID_BLOCKLIST_KEY" }
```

#### <a id="api-types"></a>Types in `jg-redis`

##### <a id="api-IBlocklist"></a>`IBlocklist`

*Type*

A list of blocked values, such as IP addresses, in one Redis SET. Values are compared exactly, so the caller normalizes them the same way when adding and checking. A value that isn't a string of 1 to 512 characters throws BLOCKLIST_VALUE_INVALID before any command runs, and every command failure throws BLOCKLIST_FAILED, so a caller can fail closed.

```ts
type IBlocklist = {
    has: (value: string) => Promise<boolean>;
    add: (values: readonly string[]) => Promise<void>;
    remove: (values: readonly string[]) => Promise<void>;
    replaceAll: (values: readonly string[]) => Promise<void>;
};
```

**Members**

- `has: (value: string) => Promise<boolean>` (property): resolves to whether the value is in the list
- `add: (values: readonly string[]) => Promise<void>` (property): adds the values, 1,000 at a time
- `remove: (values: readonly string[]) => Promise<void>` (property): removes the values, 1,000 at a time
- `replaceAll: (values: readonly string[]) => Promise<void>` (property): replaces the whole list at once: readers see the old list or the new one, never part of it. An empty list deletes the key

##### <a id="api-IBlocklistOptions"></a>`IBlocklistOptions`

*Type*

what createBlocklist takes

```ts
type IBlocklistOptions = {
    key: string;
};
```

**Members**

- `key: string` (property): the key of the Redis SET that holds the list, such as `ip-blocklist`, or `{ip-blocklist}` on Redis Cluster, so replaceAll's staging key is in the same slot

##### <a id="api-ICache"></a>`ICache`

*Type*

a read-through cache on a Redis client

```ts
type ICache = {
    getOrLoad: <T>(key: string, options: IGetOrLoadOptions<T>) => Promise<T>;
    invalidate: (key: string) => Promise<void>;
};
```

**Members**

- `getOrLoad: (key: string, options: IGetOrLoadOptions<T>) => Promise<T>` (property): returns the cached value, or loads, caches, and returns it on a miss, sharing one load among the process's concurrent misses for the key. When Redis fails, it loads without caching
- `invalidate: (key: string) => Promise<void>` (property): deletes a cached value, so the next read loads it again. A load already in flight in this process won't store its older value afterwards

##### <a id="api-IGetOrLoadOptions"></a>`IGetOrLoadOptions`

*Type*

what getOrLoad reads and, on a miss, loads and stores

```ts
type IGetOrLoadOptions<T> = {
    schema: ISchema<T>;
    ttlSeconds: number;
    load: () => Promise<T>;
};
```

**Members**

- `schema: ISchema<T>` (property): checks a cached value before it's returned, such as a Zod schema. A value it rejects is deleted and loaded again
- `ttlSeconds: number` (property): how long a loaded value stays cached, in whole seconds, from 1
- `load: () => Promise<T>` (property): loads the value on a miss, such as with a database query. Its error propagates, and nothing is cached

##### <a id="api-IRedis"></a>`IRedis`

*Type*

a connected Redis client

```ts
type IRedis = {
    readonly client: IRedisClient;
    close: () => Promise<void>;
};
```

**Members**

- `client: IRedisClient` (property): the node-redis client, for the commands the helpers don't cover
- `close: () => Promise<void>` (property): closes the client once its pending replies arrive. It never flushes any data

##### <a id="api-IRedisClient"></a>`IRedisClient`

*Type*

a node-redis client, as createClient returns it

```ts
type IRedisClient = ReturnType<typeof createClient>;
```

##### <a id="api-IRedisOptions"></a>`IRedisOptions`

*Type*

what connectRedis takes: node-redis's client options, such as the URL or the socket's host and port, plus where the client's errors go

```ts
type IRedisOptions = Omit<RedisClientOptions, 'disableOfflineQueue'> & {
    onError?: ((error: unknown) => void) | undefined;
};
```

##### <a id="api-ISchema"></a>`ISchema`

*Type*

validates data and returns it typed, which a Zod schema does

```ts
type ISchema<T> = {
    parse(value: unknown): T;
};
```

**Members**

- `parse(value: unknown): T` (method)

### <a id="api-entry-coordination"></a>`jg-redis/coordination`

- Functions: [`claimOnce`](#api-coordination-claimOnce), [`storeSingleUseValue`](#api-coordination-storeSingleUseValue), [`takeSingleUseValue`](#api-coordination-takeSingleUseValue)

#### <a id="api-coordination-functions"></a>Functions in `jg-redis/coordination`

##### <a id="api-coordination-claimOnce"></a>`claimOnce`

*Function*

Claims a token once, such as a one-time code that must not be replayed: the first call for a token in its namespace succeeds, and every other call until the time-to-live ends doesn't. The token is stored only as its SHA-256 hash, under `<namespace>:<hash>`. A Redis error throws instead of letting the token through.

```ts
claimOnce(
  redis: IRedis,
  namespace: string,
  token: string,
  ttlSeconds: number,
): Promise<boolean>
```

**Parameters**

- `redis` `IRedis`: The client, from connectRedis.
- `namespace` `string`: The key's prefix, such as `user:42:used-otp`.
- `token` `string`: The token to claim.
- `ttlSeconds` `number`: How long the claim lasts, in whole seconds: at least as long as the token stays valid.

**Returns** `Promise<boolean>`: A promise that resolves to whether this call made the claim.

**Throws**

- `INVALID_TTL`: The time-to-live must be a whole number of seconds, from 1.
- `COORDINATION_FAILED`: Redis could not complete the coordination command.

**Example**

```ts
import { connectRedis } from 'jg-redis';
import { claimOnce } from 'jg-redis/coordination';

declare const redis: Awaited<ReturnType<typeof connectRedis>>;

if (!(await claimOnce(redis, 'user:42:used-otp', '123456', 90))) {
  // the code was already used
}
```

##### <a id="api-coordination-storeSingleUseValue"></a>`storeSingleUseValue`

*Function*

Stores a value that one later call takes, such as a sign-in challenge's record. Name the key after a hash of any token it's looked up by, never the token itself.

```ts
storeSingleUseValue(
  redis: IRedis,
  key: string,
  value: unknown,
  ttlSeconds: number,
): Promise<void>
```

**Parameters**

- `redis` `IRedis`: The client, from connectRedis.
- `key` `string`: The key, such as `user-auth:sign-in-challenge:<token hash>`.
- `value` `unknown`: The value, stored as JSON.
- `ttlSeconds` `number`: How long it can be taken, in whole seconds.

**Returns** `Promise<void>`: A promise that resolves once it's stored.

**Throws**

- `INVALID_TTL`: The time-to-live must be a whole number of seconds, from 1.
- `COORDINATION_FAILED`: Redis could not complete the coordination command.

**Example**

```ts
import { connectRedis } from 'jg-redis';
import { storeSingleUseValue } from 'jg-redis/coordination';

declare const redis: Awaited<ReturnType<typeof connectRedis>>;

await storeSingleUseValue(redis, 'user-auth:challenge:9f86d081', { userId: '42' }, 300);
```

##### <a id="api-coordination-takeSingleUseValue"></a>`takeSingleUseValue`

*Function*

Takes a single-use value: reads and deletes it in one command, so of any concurrent calls, one gets it and the rest get null. A value that fails its schema is already deleted, so it throws COORDINATION_VALUE_INVALID, without a cause or the value, which might hold a secret.

```ts
takeSingleUseValue<T>(
  redis: IRedis,
  key: string,
  schema: ISchema<T>,
): Promise<T | null>
```

**Parameters**

- `redis` `IRedis`: The client, from connectRedis.
- `key` `string`: The value's key.
- `schema` `ISchema<T>`: Checks the value, such as a Zod schema.

**Returns** `Promise<T | null>`: A promise that resolves to the value, or null when it's missing, taken, or expired.

**Throws**

- `COORDINATION_FAILED`: Redis could not complete the coordination command.
- `COORDINATION_VALUE_INVALID`: The single-use value is not valid JSON or does not match its schema.

**Example**

```ts
import { connectRedis } from 'jg-redis';
import { takeSingleUseValue } from 'jg-redis/coordination';
import { z } from 'zod';

declare const redis: Awaited<ReturnType<typeof connectRedis>>;

const challenge = await takeSingleUseValue(
  redis,
  'user-auth:challenge:9f86d081',
  z.object({ userId: z.string() }),
);
```
