@ayepi/redis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/index.cjs +173 -0
- package/dist/index.d.cts +105 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +169 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @ayepi/redis
|
|
2
|
+
|
|
3
|
+
Redis backends for running ayepi on **more than one instance**, built for
|
|
4
|
+
[ioredis](https://github.com/redis/ioredis). It ships **four** things:
|
|
5
|
+
|
|
6
|
+
- **`redisBroker`** — a pub/sub [`Broker`](https://www.npmjs.com/package/@ayepi/core) so an
|
|
7
|
+
`emit` on one instance reaches subscribers on **every** instance (multi-pod event fanout,
|
|
8
|
+
no sticky sessions).
|
|
9
|
+
- **`redisStore`** — a [`@ayepi/work`](https://www.npmjs.com/package/@ayepi/work) `Store`
|
|
10
|
+
(get/set with TTL, atomic `setIfNotExists` claim, atomic `increment`).
|
|
11
|
+
- **`redisPubSub`** — a `@ayepi/work` `PubSub` (the same fanout as `redisBroker`, under the
|
|
12
|
+
work-port name so `{ store, pubsub }` reads cleanly).
|
|
13
|
+
- **`redisCache`** — a [`@ayepi/cache`](https://www.npmjs.com/package/@ayepi/cache)
|
|
14
|
+
`CacheStore` so a response cache is shared across instances.
|
|
15
|
+
|
|
16
|
+
Every Redis call in the store/cache is wrapped in `@ayepi/core`'s configurable `retry`, so a
|
|
17
|
+
transient blip is absorbed rather than surfaced.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm add @ayepi/redis @ayepi/core ioredis
|
|
21
|
+
# add @ayepi/work and/or @ayepi/cache only if you use those backends (see below)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import Redis from 'ioredis'
|
|
26
|
+
import { implement, server } from '@ayepi/core'
|
|
27
|
+
import { redisBroker } from '@ayepi/redis'
|
|
28
|
+
|
|
29
|
+
const app = server(api, [implement(api).handlers(handlers)], {
|
|
30
|
+
broker: redisBroker(new Redis(process.env.REDIS_URL)),
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
- **Dedicated subscriber connection.** A connection in subscribe mode can't run
|
|
37
|
+
other commands, so the broker subscribes on a *separate* connection
|
|
38
|
+
(`client.duplicate()` by default, or pass `opts.subscriber`); the original
|
|
39
|
+
`client` only publishes.
|
|
40
|
+
- **Reconnect is handled by ioredis** — it auto-resubscribes after a reconnect,
|
|
41
|
+
so a network blip won't silently stop event delivery.
|
|
42
|
+
- **Best-effort, ephemeral.** Redis pub/sub doesn't persist: a pod that's down
|
|
43
|
+
misses events. Ideal for live UI events (progress, presence, chat fanout). For
|
|
44
|
+
guaranteed delivery use a durable transport.
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
redisBroker(client, {
|
|
50
|
+
channel: 'ayepi', // pub/sub channel (all instances must agree)
|
|
51
|
+
subscriber: mySubClient, // custom subscriber connection (default: client.duplicate())
|
|
52
|
+
onError: (err) => log(err) // publish/subscribe/connection errors
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Work backend (`redisStore` + `redisPubSub`)
|
|
57
|
+
|
|
58
|
+
A drop-in [`@ayepi/work`](https://www.npmjs.com/package/@ayepi/work) backend pairing — hand
|
|
59
|
+
them to `createWork` alongside a durable queue (e.g. `sqsQueue` from `@ayepi/aws`):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import Redis from 'ioredis'
|
|
63
|
+
import { redisStore, redisPubSub } from '@ayepi/redis'
|
|
64
|
+
|
|
65
|
+
const redis = new Redis(process.env.REDIS_URL)
|
|
66
|
+
createWork({ work, store: redisStore(redis), pubsub: redisPubSub(redis), queue })
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `redisStore(client, { prefix, retry, onError })` — `setIfNotExists` is `SET NX` (the atomic
|
|
70
|
+
CAS behind every claim/lease); `increment` is `INCRBY` (the group counter). Keys are
|
|
71
|
+
namespaced by `prefix` (default none).
|
|
72
|
+
- `redisPubSub(client, opts?)` — same options and fanout as `redisBroker`; best-effort, used
|
|
73
|
+
to wake distributed waiters (the engine's store-poll covers a silent channel).
|
|
74
|
+
|
|
75
|
+
`@ayepi/work` is an **optional, type-only peer dep** — only install it if you use these.
|
|
76
|
+
|
|
77
|
+
## Cache store (`redisCache`)
|
|
78
|
+
|
|
79
|
+
A [`@ayepi/cache`](https://www.npmjs.com/package/@ayepi/cache) `CacheStore` so the response
|
|
80
|
+
cache is shared across instances:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import Redis from 'ioredis'
|
|
84
|
+
import { cache } from '@ayepi/cache/server'
|
|
85
|
+
import { redisCache } from '@ayepi/redis'
|
|
86
|
+
|
|
87
|
+
const redis = new Redis(process.env.REDIS_URL)
|
|
88
|
+
implement(api).middleware(cache.server(cached, { store: redisCache(redis), ttl: 30_000 }))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- `redisCache(client, { prefix, retry, onError, now })` — entries are JSON with a Redis `PX`
|
|
92
|
+
TTL derived from `entry.staleUntil` (dead entries self-evict); `clear`/`invalidate` `SCAN`
|
|
93
|
+
the `prefix` (default `'ayepi:cache:'`).
|
|
94
|
+
|
|
95
|
+
`@ayepi/cache` is an **optional, type-only peer dep** — only install it if you use this.
|
|
96
|
+
|
|
97
|
+
## For AI coding agents
|
|
98
|
+
|
|
99
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
100
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
101
|
+
|
|
102
|
+
- [`ayepi-redis.md`](./ayepi-redis.md)
|
|
103
|
+
|
|
104
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/redis) and are **not** shipped in the npm tarball.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT © Philip Diffenderfer
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _ayepi_core = require("@ayepi/core");
|
|
3
|
+
//#region src/backends.ts
|
|
4
|
+
/**
|
|
5
|
+
* # @ayepi/redis — work Store + cache store
|
|
6
|
+
*
|
|
7
|
+
* Redis implementations of the `@ayepi/work` {@link Store} port and the `@ayepi/cache`
|
|
8
|
+
* `CacheStore`, plus the {@link redisPubSub} pairing. Every Redis call is wrapped in core's
|
|
9
|
+
* {@link retry} (configurable per store) so a transient blip or a throttled reply is absorbed
|
|
10
|
+
* rather than surfaced; a final failure fires `onError` and propagates.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/** Build a retry-wrapping runner that reports a final failure through `onError`. */
|
|
15
|
+
function makeRun(opts) {
|
|
16
|
+
const report = (err) => {
|
|
17
|
+
try {
|
|
18
|
+
opts.onError?.(err);
|
|
19
|
+
} catch {}
|
|
20
|
+
};
|
|
21
|
+
return (fn) => (0, _ayepi_core.retry)(fn, {
|
|
22
|
+
...opts.retry,
|
|
23
|
+
onError: (err) => report(err)
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A Redis-backed `@ayepi/work` {@link Store}: `get`/`set` (with `PX` TTL), `delete`,
|
|
28
|
+
* `setIfNotExists` (`SET NX`, the CAS atom behind every claim), and `increment` (`INCRBY`,
|
|
29
|
+
* the group counter). Pair with {@link redisPubSub} and a durable queue.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* import Redis from 'ioredis';
|
|
34
|
+
* const c = new Redis(process.env.REDIS_URL);
|
|
35
|
+
* createWork({ work, store: redisStore(c), pubsub: redisPubSub(c), queue: sqsQueue(...) });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
function redisStore(client, opts = {}) {
|
|
39
|
+
const ns = opts.prefix ?? "";
|
|
40
|
+
const run = makeRun(opts);
|
|
41
|
+
return {
|
|
42
|
+
get: (key) => run(async () => await client.get(ns + key) ?? void 0),
|
|
43
|
+
set: (key, value, ttl) => run(async () => {
|
|
44
|
+
await (ttl !== void 0 ? client.set(ns + key, value, "PX", ttl) : client.set(ns + key, value));
|
|
45
|
+
}),
|
|
46
|
+
delete: (key) => run(async () => {
|
|
47
|
+
await client.del(ns + key);
|
|
48
|
+
}),
|
|
49
|
+
setIfNotExists: (key, value, ttl) => run(async () => {
|
|
50
|
+
return (ttl !== void 0 ? await client.set(ns + key, value, "PX", ttl, "NX") : await client.set(ns + key, value, "NX")) !== null;
|
|
51
|
+
}),
|
|
52
|
+
increment: (key, by, ttl) => run(async () => {
|
|
53
|
+
const v = await client.incrby(ns + key, by);
|
|
54
|
+
if (ttl !== void 0) await client.pexpire(ns + key, ttl);
|
|
55
|
+
return v;
|
|
56
|
+
})
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A Redis-backed `@ayepi/cache` `CacheStore`: entries are JSON with a Redis `PX` TTL set from
|
|
61
|
+
* `entry.staleUntil` (so dead entries self-evict); `clear`/`invalidate` use `SCAN` over the
|
|
62
|
+
* prefix. Hand it to `cache.server(def, { store: redisCache(client) })` for a cache shared
|
|
63
|
+
* across instances.
|
|
64
|
+
*/
|
|
65
|
+
function redisCache(client, opts = {}) {
|
|
66
|
+
const ns = opts.prefix ?? "ayepi:cache:";
|
|
67
|
+
const now = opts.now ?? Date.now;
|
|
68
|
+
const run = makeRun(opts);
|
|
69
|
+
const scanKeys = async () => {
|
|
70
|
+
const keys = [];
|
|
71
|
+
let cursor = "0";
|
|
72
|
+
do {
|
|
73
|
+
const [next, batch] = await client.scan(cursor, "MATCH", `${ns}*`, "COUNT", 256);
|
|
74
|
+
keys.push(...batch);
|
|
75
|
+
cursor = next;
|
|
76
|
+
} while (cursor !== "0");
|
|
77
|
+
return keys;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
get: (key) => run(async () => {
|
|
81
|
+
const raw = await client.get(ns + key);
|
|
82
|
+
return raw ? JSON.parse(raw) : void 0;
|
|
83
|
+
}),
|
|
84
|
+
set: (key, entry) => run(async () => {
|
|
85
|
+
await client.set(ns + key, JSON.stringify(entry), "PX", Math.max(1, entry.staleUntil - now()));
|
|
86
|
+
}),
|
|
87
|
+
delete: (key) => run(async () => await client.del(ns + key) > 0),
|
|
88
|
+
clear: () => run(async () => {
|
|
89
|
+
const keys = await scanKeys();
|
|
90
|
+
if (keys.length) await client.del(...keys);
|
|
91
|
+
}),
|
|
92
|
+
invalidate: (pred) => run(async () => {
|
|
93
|
+
const remove = [];
|
|
94
|
+
for (const k of await scanKeys()) {
|
|
95
|
+
const raw = await client.get(k);
|
|
96
|
+
if (!raw) continue;
|
|
97
|
+
const e = JSON.parse(raw);
|
|
98
|
+
if (pred({
|
|
99
|
+
key: e.key,
|
|
100
|
+
method: e.method,
|
|
101
|
+
path: e.path,
|
|
102
|
+
storedAt: e.storedAt,
|
|
103
|
+
expires: e.expires,
|
|
104
|
+
staleUntil: e.staleUntil,
|
|
105
|
+
bytes: e.bytes
|
|
106
|
+
})) remove.push(k);
|
|
107
|
+
}
|
|
108
|
+
if (remove.length) await client.del(...remove);
|
|
109
|
+
return remove.length;
|
|
110
|
+
})
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/index.ts
|
|
115
|
+
/**
|
|
116
|
+
* Create a Redis pub/sub {@link Broker}.
|
|
117
|
+
*
|
|
118
|
+
* @param client - an ioredis connection used to `publish` (a dedicated subscriber
|
|
119
|
+
* connection is derived via `client.duplicate()` unless you pass
|
|
120
|
+
* `opts.subscriber`).
|
|
121
|
+
*/
|
|
122
|
+
function redisBroker(client, opts = {}) {
|
|
123
|
+
const channel = opts.channel ?? "ayepi";
|
|
124
|
+
const sub = opts.subscriber ?? client.duplicate();
|
|
125
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
126
|
+
const onError = opts.onError;
|
|
127
|
+
let wired = false;
|
|
128
|
+
const wire = () => {
|
|
129
|
+
if (wired) return;
|
|
130
|
+
wired = true;
|
|
131
|
+
sub.on("message", (ch, message) => {
|
|
132
|
+
if (ch !== channel) return;
|
|
133
|
+
for (const l of listeners) try {
|
|
134
|
+
l(message);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
onError?.(err);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
if (onError) {
|
|
140
|
+
sub.on("error", onError);
|
|
141
|
+
client.on("error", onError);
|
|
142
|
+
}
|
|
143
|
+
Promise.resolve().then(() => sub.subscribe(channel)).catch((err) => onError?.(err));
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
publish(message) {
|
|
147
|
+
return Promise.resolve().then(() => client.publish(channel, message)).then(() => void 0, (err) => {
|
|
148
|
+
onError?.(err);
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
subscribe(listener) {
|
|
152
|
+
wire();
|
|
153
|
+
listeners.add(listener);
|
|
154
|
+
return () => {
|
|
155
|
+
listeners.delete(listener);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The `@ayepi/work` {@link PubSub} backed by Redis pub/sub — the same fanout as
|
|
162
|
+
* {@link redisBroker} (the two ports are identical), exposed under the work-port name so
|
|
163
|
+
* `{ store: redisStore(c), pubsub: redisPubSub(c) }` reads cleanly. Best-effort: it wakes
|
|
164
|
+
* distributed waiters; the engine's store-poll fallback covers a silent channel.
|
|
165
|
+
*/
|
|
166
|
+
function redisPubSub(client, opts = {}) {
|
|
167
|
+
return redisBroker(client, opts);
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
exports.redisBroker = redisBroker;
|
|
171
|
+
exports.redisCache = redisCache;
|
|
172
|
+
exports.redisPubSub = redisPubSub;
|
|
173
|
+
exports.redisStore = redisStore;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Broker, RetryOptions } from "@ayepi/core";
|
|
2
|
+
import { PubSub, Store } from "@ayepi/work";
|
|
3
|
+
import { CacheStore } from "@ayepi/cache";
|
|
4
|
+
|
|
5
|
+
//#region src/backends.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The minimal command surface the store/cache use — `ioredis`'s `Redis` satisfies it
|
|
9
|
+
* structurally. (The pub/sub {@link redisBroker} uses a different, subscribe-mode surface.)
|
|
10
|
+
*/
|
|
11
|
+
interface RedisCommandClient {
|
|
12
|
+
get(key: string): Promise<string | null>;
|
|
13
|
+
/** `set(key, value)` / `set(key, value, 'PX', ttl)` / `set(key, value, 'NX')` — returns `'OK'` or `null`. */
|
|
14
|
+
set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
|
|
15
|
+
del(...keys: string[]): Promise<number>;
|
|
16
|
+
incrby(key: string, increment: number): Promise<number>;
|
|
17
|
+
pexpire(key: string, ms: number): Promise<number>;
|
|
18
|
+
scan(cursor: string | number, ...args: (string | number)[]): Promise<[cursor: string, keys: string[]]>;
|
|
19
|
+
}
|
|
20
|
+
/** Shared resilience options. */
|
|
21
|
+
interface ResilientOptions {
|
|
22
|
+
/** Key prefix/namespace. */
|
|
23
|
+
readonly prefix?: string;
|
|
24
|
+
/** Retry policy for each Redis call (see core `retry` — `attempts`/`base`/`factor`/`max`/`jitter`/…). */
|
|
25
|
+
readonly retry?: Omit<RetryOptions, 'errorResult'>;
|
|
26
|
+
/** Notified when a call fails after exhausting retries (the error then propagates). Off by default; must not throw. */
|
|
27
|
+
readonly onError?: (err: unknown) => void;
|
|
28
|
+
}
|
|
29
|
+
/** Options for {@link redisStore}. */
|
|
30
|
+
interface RedisStoreOptions extends ResilientOptions {}
|
|
31
|
+
/**
|
|
32
|
+
* A Redis-backed `@ayepi/work` {@link Store}: `get`/`set` (with `PX` TTL), `delete`,
|
|
33
|
+
* `setIfNotExists` (`SET NX`, the CAS atom behind every claim), and `increment` (`INCRBY`,
|
|
34
|
+
* the group counter). Pair with {@link redisPubSub} and a durable queue.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import Redis from 'ioredis';
|
|
39
|
+
* const c = new Redis(process.env.REDIS_URL);
|
|
40
|
+
* createWork({ work, store: redisStore(c), pubsub: redisPubSub(c), queue: sqsQueue(...) });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare function redisStore(client: RedisCommandClient, opts?: RedisStoreOptions): Store;
|
|
44
|
+
/** Options for {@link redisCache}. */
|
|
45
|
+
interface RedisCacheOptions extends ResilientOptions {
|
|
46
|
+
/** Clock for the stored TTL (default `Date.now`). */
|
|
47
|
+
readonly now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A Redis-backed `@ayepi/cache` `CacheStore`: entries are JSON with a Redis `PX` TTL set from
|
|
51
|
+
* `entry.staleUntil` (so dead entries self-evict); `clear`/`invalidate` use `SCAN` over the
|
|
52
|
+
* prefix. Hand it to `cache.server(def, { store: redisCache(client) })` for a cache shared
|
|
53
|
+
* across instances.
|
|
54
|
+
*/
|
|
55
|
+
declare function redisCache(client: RedisCommandClient, opts?: RedisCacheOptions): CacheStore;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/index.d.ts
|
|
58
|
+
/**
|
|
59
|
+
* The minimal ioredis surface this broker uses. `ioredis`'s `Redis` satisfies it
|
|
60
|
+
* structurally; a compatible client (e.g. node-redis with matching method shapes)
|
|
61
|
+
* works too.
|
|
62
|
+
*/
|
|
63
|
+
interface RedisLike {
|
|
64
|
+
/** Publish a message to a channel; returns the number of receivers (or a promise of it). */
|
|
65
|
+
publish(channel: string, message: string): Promise<number> | number;
|
|
66
|
+
/** Subscribe the connection to one or more channels. */
|
|
67
|
+
subscribe(...channels: string[]): Promise<unknown> | unknown;
|
|
68
|
+
/** Unsubscribe the connection from one or more channels. */
|
|
69
|
+
unsubscribe(...channels: string[]): Promise<unknown> | unknown;
|
|
70
|
+
/** Create a second connection with the same options (used for the subscriber). */
|
|
71
|
+
duplicate(): RedisLike;
|
|
72
|
+
/** Listen for delivered messages. */
|
|
73
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
74
|
+
/** Listen for connection errors. */
|
|
75
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
76
|
+
}
|
|
77
|
+
/** Options for {@link redisBroker}. */
|
|
78
|
+
interface RedisBrokerOptions {
|
|
79
|
+
/** The pub/sub channel name (default `'ayepi'`). All instances must agree. */
|
|
80
|
+
readonly channel?: string;
|
|
81
|
+
/**
|
|
82
|
+
* The connection to subscribe on. Defaults to `client.duplicate()`. Provide one
|
|
83
|
+
* if you manage connections yourself — it must be dedicated to subscribing.
|
|
84
|
+
*/
|
|
85
|
+
readonly subscriber?: RedisLike;
|
|
86
|
+
/** Notified on publish/subscribe/connection errors. */
|
|
87
|
+
readonly onError?: (error: unknown) => void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Create a Redis pub/sub {@link Broker}.
|
|
91
|
+
*
|
|
92
|
+
* @param client - an ioredis connection used to `publish` (a dedicated subscriber
|
|
93
|
+
* connection is derived via `client.duplicate()` unless you pass
|
|
94
|
+
* `opts.subscriber`).
|
|
95
|
+
*/
|
|
96
|
+
declare function redisBroker(client: RedisLike, opts?: RedisBrokerOptions): Broker;
|
|
97
|
+
/**
|
|
98
|
+
* The `@ayepi/work` {@link PubSub} backed by Redis pub/sub — the same fanout as
|
|
99
|
+
* {@link redisBroker} (the two ports are identical), exposed under the work-port name so
|
|
100
|
+
* `{ store: redisStore(c), pubsub: redisPubSub(c) }` reads cleanly. Best-effort: it wakes
|
|
101
|
+
* distributed waiters; the engine's store-poll fallback covers a silent channel.
|
|
102
|
+
*/
|
|
103
|
+
declare function redisPubSub(client: RedisLike, opts?: RedisBrokerOptions): PubSub;
|
|
104
|
+
//#endregion
|
|
105
|
+
export { RedisBrokerOptions, type RedisCacheOptions, type RedisCommandClient, RedisLike, type RedisStoreOptions, redisBroker, redisCache, redisPubSub, redisStore };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Broker, RetryOptions } from "@ayepi/core";
|
|
2
|
+
import { PubSub, Store } from "@ayepi/work";
|
|
3
|
+
import { CacheStore } from "@ayepi/cache";
|
|
4
|
+
|
|
5
|
+
//#region src/backends.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The minimal command surface the store/cache use — `ioredis`'s `Redis` satisfies it
|
|
9
|
+
* structurally. (The pub/sub {@link redisBroker} uses a different, subscribe-mode surface.)
|
|
10
|
+
*/
|
|
11
|
+
interface RedisCommandClient {
|
|
12
|
+
get(key: string): Promise<string | null>;
|
|
13
|
+
/** `set(key, value)` / `set(key, value, 'PX', ttl)` / `set(key, value, 'NX')` — returns `'OK'` or `null`. */
|
|
14
|
+
set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
|
|
15
|
+
del(...keys: string[]): Promise<number>;
|
|
16
|
+
incrby(key: string, increment: number): Promise<number>;
|
|
17
|
+
pexpire(key: string, ms: number): Promise<number>;
|
|
18
|
+
scan(cursor: string | number, ...args: (string | number)[]): Promise<[cursor: string, keys: string[]]>;
|
|
19
|
+
}
|
|
20
|
+
/** Shared resilience options. */
|
|
21
|
+
interface ResilientOptions {
|
|
22
|
+
/** Key prefix/namespace. */
|
|
23
|
+
readonly prefix?: string;
|
|
24
|
+
/** Retry policy for each Redis call (see core `retry` — `attempts`/`base`/`factor`/`max`/`jitter`/…). */
|
|
25
|
+
readonly retry?: Omit<RetryOptions, 'errorResult'>;
|
|
26
|
+
/** Notified when a call fails after exhausting retries (the error then propagates). Off by default; must not throw. */
|
|
27
|
+
readonly onError?: (err: unknown) => void;
|
|
28
|
+
}
|
|
29
|
+
/** Options for {@link redisStore}. */
|
|
30
|
+
interface RedisStoreOptions extends ResilientOptions {}
|
|
31
|
+
/**
|
|
32
|
+
* A Redis-backed `@ayepi/work` {@link Store}: `get`/`set` (with `PX` TTL), `delete`,
|
|
33
|
+
* `setIfNotExists` (`SET NX`, the CAS atom behind every claim), and `increment` (`INCRBY`,
|
|
34
|
+
* the group counter). Pair with {@link redisPubSub} and a durable queue.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import Redis from 'ioredis';
|
|
39
|
+
* const c = new Redis(process.env.REDIS_URL);
|
|
40
|
+
* createWork({ work, store: redisStore(c), pubsub: redisPubSub(c), queue: sqsQueue(...) });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare function redisStore(client: RedisCommandClient, opts?: RedisStoreOptions): Store;
|
|
44
|
+
/** Options for {@link redisCache}. */
|
|
45
|
+
interface RedisCacheOptions extends ResilientOptions {
|
|
46
|
+
/** Clock for the stored TTL (default `Date.now`). */
|
|
47
|
+
readonly now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A Redis-backed `@ayepi/cache` `CacheStore`: entries are JSON with a Redis `PX` TTL set from
|
|
51
|
+
* `entry.staleUntil` (so dead entries self-evict); `clear`/`invalidate` use `SCAN` over the
|
|
52
|
+
* prefix. Hand it to `cache.server(def, { store: redisCache(client) })` for a cache shared
|
|
53
|
+
* across instances.
|
|
54
|
+
*/
|
|
55
|
+
declare function redisCache(client: RedisCommandClient, opts?: RedisCacheOptions): CacheStore;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/index.d.ts
|
|
58
|
+
/**
|
|
59
|
+
* The minimal ioredis surface this broker uses. `ioredis`'s `Redis` satisfies it
|
|
60
|
+
* structurally; a compatible client (e.g. node-redis with matching method shapes)
|
|
61
|
+
* works too.
|
|
62
|
+
*/
|
|
63
|
+
interface RedisLike {
|
|
64
|
+
/** Publish a message to a channel; returns the number of receivers (or a promise of it). */
|
|
65
|
+
publish(channel: string, message: string): Promise<number> | number;
|
|
66
|
+
/** Subscribe the connection to one or more channels. */
|
|
67
|
+
subscribe(...channels: string[]): Promise<unknown> | unknown;
|
|
68
|
+
/** Unsubscribe the connection from one or more channels. */
|
|
69
|
+
unsubscribe(...channels: string[]): Promise<unknown> | unknown;
|
|
70
|
+
/** Create a second connection with the same options (used for the subscriber). */
|
|
71
|
+
duplicate(): RedisLike;
|
|
72
|
+
/** Listen for delivered messages. */
|
|
73
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
74
|
+
/** Listen for connection errors. */
|
|
75
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
76
|
+
}
|
|
77
|
+
/** Options for {@link redisBroker}. */
|
|
78
|
+
interface RedisBrokerOptions {
|
|
79
|
+
/** The pub/sub channel name (default `'ayepi'`). All instances must agree. */
|
|
80
|
+
readonly channel?: string;
|
|
81
|
+
/**
|
|
82
|
+
* The connection to subscribe on. Defaults to `client.duplicate()`. Provide one
|
|
83
|
+
* if you manage connections yourself — it must be dedicated to subscribing.
|
|
84
|
+
*/
|
|
85
|
+
readonly subscriber?: RedisLike;
|
|
86
|
+
/** Notified on publish/subscribe/connection errors. */
|
|
87
|
+
readonly onError?: (error: unknown) => void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Create a Redis pub/sub {@link Broker}.
|
|
91
|
+
*
|
|
92
|
+
* @param client - an ioredis connection used to `publish` (a dedicated subscriber
|
|
93
|
+
* connection is derived via `client.duplicate()` unless you pass
|
|
94
|
+
* `opts.subscriber`).
|
|
95
|
+
*/
|
|
96
|
+
declare function redisBroker(client: RedisLike, opts?: RedisBrokerOptions): Broker;
|
|
97
|
+
/**
|
|
98
|
+
* The `@ayepi/work` {@link PubSub} backed by Redis pub/sub — the same fanout as
|
|
99
|
+
* {@link redisBroker} (the two ports are identical), exposed under the work-port name so
|
|
100
|
+
* `{ store: redisStore(c), pubsub: redisPubSub(c) }` reads cleanly. Best-effort: it wakes
|
|
101
|
+
* distributed waiters; the engine's store-poll fallback covers a silent channel.
|
|
102
|
+
*/
|
|
103
|
+
declare function redisPubSub(client: RedisLike, opts?: RedisBrokerOptions): PubSub;
|
|
104
|
+
//#endregion
|
|
105
|
+
export { RedisBrokerOptions, type RedisCacheOptions, type RedisCommandClient, RedisLike, type RedisStoreOptions, redisBroker, redisCache, redisPubSub, redisStore };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { retry } from "@ayepi/core";
|
|
2
|
+
//#region src/backends.ts
|
|
3
|
+
/**
|
|
4
|
+
* # @ayepi/redis — work Store + cache store
|
|
5
|
+
*
|
|
6
|
+
* Redis implementations of the `@ayepi/work` {@link Store} port and the `@ayepi/cache`
|
|
7
|
+
* `CacheStore`, plus the {@link redisPubSub} pairing. Every Redis call is wrapped in core's
|
|
8
|
+
* {@link retry} (configurable per store) so a transient blip or a throttled reply is absorbed
|
|
9
|
+
* rather than surfaced; a final failure fires `onError` and propagates.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
/** Build a retry-wrapping runner that reports a final failure through `onError`. */
|
|
14
|
+
function makeRun(opts) {
|
|
15
|
+
const report = (err) => {
|
|
16
|
+
try {
|
|
17
|
+
opts.onError?.(err);
|
|
18
|
+
} catch {}
|
|
19
|
+
};
|
|
20
|
+
return (fn) => retry(fn, {
|
|
21
|
+
...opts.retry,
|
|
22
|
+
onError: (err) => report(err)
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A Redis-backed `@ayepi/work` {@link Store}: `get`/`set` (with `PX` TTL), `delete`,
|
|
27
|
+
* `setIfNotExists` (`SET NX`, the CAS atom behind every claim), and `increment` (`INCRBY`,
|
|
28
|
+
* the group counter). Pair with {@link redisPubSub} and a durable queue.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import Redis from 'ioredis';
|
|
33
|
+
* const c = new Redis(process.env.REDIS_URL);
|
|
34
|
+
* createWork({ work, store: redisStore(c), pubsub: redisPubSub(c), queue: sqsQueue(...) });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
function redisStore(client, opts = {}) {
|
|
38
|
+
const ns = opts.prefix ?? "";
|
|
39
|
+
const run = makeRun(opts);
|
|
40
|
+
return {
|
|
41
|
+
get: (key) => run(async () => await client.get(ns + key) ?? void 0),
|
|
42
|
+
set: (key, value, ttl) => run(async () => {
|
|
43
|
+
await (ttl !== void 0 ? client.set(ns + key, value, "PX", ttl) : client.set(ns + key, value));
|
|
44
|
+
}),
|
|
45
|
+
delete: (key) => run(async () => {
|
|
46
|
+
await client.del(ns + key);
|
|
47
|
+
}),
|
|
48
|
+
setIfNotExists: (key, value, ttl) => run(async () => {
|
|
49
|
+
return (ttl !== void 0 ? await client.set(ns + key, value, "PX", ttl, "NX") : await client.set(ns + key, value, "NX")) !== null;
|
|
50
|
+
}),
|
|
51
|
+
increment: (key, by, ttl) => run(async () => {
|
|
52
|
+
const v = await client.incrby(ns + key, by);
|
|
53
|
+
if (ttl !== void 0) await client.pexpire(ns + key, ttl);
|
|
54
|
+
return v;
|
|
55
|
+
})
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A Redis-backed `@ayepi/cache` `CacheStore`: entries are JSON with a Redis `PX` TTL set from
|
|
60
|
+
* `entry.staleUntil` (so dead entries self-evict); `clear`/`invalidate` use `SCAN` over the
|
|
61
|
+
* prefix. Hand it to `cache.server(def, { store: redisCache(client) })` for a cache shared
|
|
62
|
+
* across instances.
|
|
63
|
+
*/
|
|
64
|
+
function redisCache(client, opts = {}) {
|
|
65
|
+
const ns = opts.prefix ?? "ayepi:cache:";
|
|
66
|
+
const now = opts.now ?? Date.now;
|
|
67
|
+
const run = makeRun(opts);
|
|
68
|
+
const scanKeys = async () => {
|
|
69
|
+
const keys = [];
|
|
70
|
+
let cursor = "0";
|
|
71
|
+
do {
|
|
72
|
+
const [next, batch] = await client.scan(cursor, "MATCH", `${ns}*`, "COUNT", 256);
|
|
73
|
+
keys.push(...batch);
|
|
74
|
+
cursor = next;
|
|
75
|
+
} while (cursor !== "0");
|
|
76
|
+
return keys;
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
get: (key) => run(async () => {
|
|
80
|
+
const raw = await client.get(ns + key);
|
|
81
|
+
return raw ? JSON.parse(raw) : void 0;
|
|
82
|
+
}),
|
|
83
|
+
set: (key, entry) => run(async () => {
|
|
84
|
+
await client.set(ns + key, JSON.stringify(entry), "PX", Math.max(1, entry.staleUntil - now()));
|
|
85
|
+
}),
|
|
86
|
+
delete: (key) => run(async () => await client.del(ns + key) > 0),
|
|
87
|
+
clear: () => run(async () => {
|
|
88
|
+
const keys = await scanKeys();
|
|
89
|
+
if (keys.length) await client.del(...keys);
|
|
90
|
+
}),
|
|
91
|
+
invalidate: (pred) => run(async () => {
|
|
92
|
+
const remove = [];
|
|
93
|
+
for (const k of await scanKeys()) {
|
|
94
|
+
const raw = await client.get(k);
|
|
95
|
+
if (!raw) continue;
|
|
96
|
+
const e = JSON.parse(raw);
|
|
97
|
+
if (pred({
|
|
98
|
+
key: e.key,
|
|
99
|
+
method: e.method,
|
|
100
|
+
path: e.path,
|
|
101
|
+
storedAt: e.storedAt,
|
|
102
|
+
expires: e.expires,
|
|
103
|
+
staleUntil: e.staleUntil,
|
|
104
|
+
bytes: e.bytes
|
|
105
|
+
})) remove.push(k);
|
|
106
|
+
}
|
|
107
|
+
if (remove.length) await client.del(...remove);
|
|
108
|
+
return remove.length;
|
|
109
|
+
})
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/index.ts
|
|
114
|
+
/**
|
|
115
|
+
* Create a Redis pub/sub {@link Broker}.
|
|
116
|
+
*
|
|
117
|
+
* @param client - an ioredis connection used to `publish` (a dedicated subscriber
|
|
118
|
+
* connection is derived via `client.duplicate()` unless you pass
|
|
119
|
+
* `opts.subscriber`).
|
|
120
|
+
*/
|
|
121
|
+
function redisBroker(client, opts = {}) {
|
|
122
|
+
const channel = opts.channel ?? "ayepi";
|
|
123
|
+
const sub = opts.subscriber ?? client.duplicate();
|
|
124
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
125
|
+
const onError = opts.onError;
|
|
126
|
+
let wired = false;
|
|
127
|
+
const wire = () => {
|
|
128
|
+
if (wired) return;
|
|
129
|
+
wired = true;
|
|
130
|
+
sub.on("message", (ch, message) => {
|
|
131
|
+
if (ch !== channel) return;
|
|
132
|
+
for (const l of listeners) try {
|
|
133
|
+
l(message);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
onError?.(err);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (onError) {
|
|
139
|
+
sub.on("error", onError);
|
|
140
|
+
client.on("error", onError);
|
|
141
|
+
}
|
|
142
|
+
Promise.resolve().then(() => sub.subscribe(channel)).catch((err) => onError?.(err));
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
publish(message) {
|
|
146
|
+
return Promise.resolve().then(() => client.publish(channel, message)).then(() => void 0, (err) => {
|
|
147
|
+
onError?.(err);
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
subscribe(listener) {
|
|
151
|
+
wire();
|
|
152
|
+
listeners.add(listener);
|
|
153
|
+
return () => {
|
|
154
|
+
listeners.delete(listener);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The `@ayepi/work` {@link PubSub} backed by Redis pub/sub — the same fanout as
|
|
161
|
+
* {@link redisBroker} (the two ports are identical), exposed under the work-port name so
|
|
162
|
+
* `{ store: redisStore(c), pubsub: redisPubSub(c) }` reads cleanly. Best-effort: it wakes
|
|
163
|
+
* distributed waiters; the engine's store-poll fallback covers a silent channel.
|
|
164
|
+
*/
|
|
165
|
+
function redisPubSub(client, opts = {}) {
|
|
166
|
+
return redisBroker(client, opts);
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
export { redisBroker, redisCache, redisPubSub, redisStore };
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayepi/redis",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Redis backends for ayepi (ioredis) — pub/sub Broker, @ayepi/work Store + PubSub, and an @ayepi/cache store, all retry-wrapped",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ClickerMonkey/ayepi.git",
|
|
12
|
+
"directory": "packages/redis"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/redis#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ClickerMonkey/ayepi/issues"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.cts",
|
|
31
|
+
"default": "./dist/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"ioredis": "^5",
|
|
41
|
+
"@ayepi/work": "^0.1.0",
|
|
42
|
+
"@ayepi/core": "^0.1.0",
|
|
43
|
+
"@ayepi/cache": "^0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@ayepi/work": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@ayepi/cache": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
55
|
+
"ioredis": "^5.4.1",
|
|
56
|
+
"publint": "^0.3.0",
|
|
57
|
+
"testcontainers": "^10.13.0",
|
|
58
|
+
"tsdown": "^0.12.0",
|
|
59
|
+
"vitest": "^2.1.8",
|
|
60
|
+
"zod": "^4.4.3",
|
|
61
|
+
"@ayepi/cache": "0.1.0",
|
|
62
|
+
"@ayepi/work": "0.1.0",
|
|
63
|
+
"@ayepi/core": "0.1.0"
|
|
64
|
+
},
|
|
65
|
+
"keywords": [
|
|
66
|
+
"ayepi",
|
|
67
|
+
"@ayepi/core",
|
|
68
|
+
"redis",
|
|
69
|
+
"ioredis",
|
|
70
|
+
"broker",
|
|
71
|
+
"pubsub",
|
|
72
|
+
"websocket"
|
|
73
|
+
],
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "tsdown",
|
|
76
|
+
"typecheck": "tsc --noEmit",
|
|
77
|
+
"test": "vitest run --coverage",
|
|
78
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
79
|
+
"publint": "publint"
|
|
80
|
+
}
|
|
81
|
+
}
|