# Redis Client Contract

## Overview

This document defines the public contract for `src/redis/redis.lua`.

All operations are async and must be called within `lev.run()`.

## Connection config

`redis.connect(config)` accepts an optional config that is either:

1. **String form**: `"<host>:<port>"` or `"<host>:<port>/<db>"`
2. **Table form**:

```lua
{
	host = "127.0.0.1", -- required
	port = 6379, -- optional, defaults to 6379
	db = 13, -- optional
	timeout = 2, -- optional socket timeout
	ssl = true, -- optional TLS mode
	auth = { user = "u", pass = "p" }, -- optional AUTH
	-- extra fields are ignored by redis client; callers may keep module-specific metadata here
}
```

When `config` is `nil`, defaults to `"127.0.0.1:6379"`.

### Validation rules

- `host` must be a non-empty string.
- `port` must be an integer in range `1..65535`.
- `db` when set must be a non-negative integer.
- `timeout` when set must be a positive number.
- `auth` when set must be a table with non-empty string fields `user` and `pass`.
- Unknown/extra table fields are ignored by the Redis client.

Invalid configs return `nil, err` with a clear validation error message.

## Client object

`connect()` returns a client table with these fields and methods:

| Member | Description |
|--------|-------------|
| `cfg` | Normalized config table (read-only by convention) |
| `cmd(...)` | Execute a single Redis command |
| `pipeline(commands)` | Execute multiple commands in one round-trip |
| `read()` | Read one raw RESP value |
| `close(no_keepalive)` | Close or pool the connection |

### `client:cmd(...)`

Executes a Redis command. Arguments are the command name followed by its
arguments (all converted to strings).

```lua
local val, err = client:cmd("SET", "key", "value")  -- val = "OK"
local val, err = client:cmd("GET", "key")            -- val = "value"
local val, err = client:cmd("GET", "missing")        -- val = nil, err = "not found"
local val, err = client:cmd("INCR", "counter")       -- val = 42 (number)
```

Returns:
- On success: decoded Lua value (see RESP type mapping below).
- Non-existent key (NULL bulk string): `nil, "not found"`.
- Redis error: `nil, error_message`.
- Network/protocol error: `nil, error_message`.

### `client:pipeline(commands)`

Sends all commands in a single round-trip and reads all responses.
Each entry in `commands` is an array of arguments (same as `cmd` varargs).

```lua
local results, err = client:pipeline({
	{ "SET", "k1", "v1" },
	{ "SET", "k2", "v2" },
	{ "GET", "k1" },
	{ "GET", "missing" },
	{ "INCR", "k1" },       -- type error: k1 is a string
})
-- results[1] = { "OK" }
-- results[2] = { "OK" }
-- results[3] = { "v1" }
-- results[4] = { nil }                  -- NULL → single nil element
-- results[5] = { nil, "ERR ..." }       -- per-command Redis error
```

Returns an array of result tables in command order:
- Success: `{ value }`
- NULL bulk string: `{ nil }`
- Redis error: `{ nil, error_message }`

If a network error occurs mid-read, the entire call returns `nil, err`.
Empty or nil input returns `{}`.

### `client:read()`

Reads one raw RESP response. Returns `{ type = "...", value = ... }` or
`nil, err`. Useful for advanced scenarios (e.g. pub/sub message reading)
where you need the RESP type tag.

### `client:close(no_keepalive)`

Closes the connection. By default the underlying socket is returned
to the connection pool for reuse. Pass `true` to force-close the
socket. The socket is also force-closed when the pool is at capacity.

Always returns `true`.

## RESP type mapping

| RESP prefix | RESP type | Lua type | Notes |
|-------------|-----------|----------|-------|
| `+` | Simple string | `string` | e.g. `"OK"`, `"PONG"` |
| `-` | Error | `nil, string` | Error text returned as second value |
| `:` | Integer | `number` | |
| `$` | Bulk string | `string` | NULL bulk string → `nil, "not found"` |
| `*` | Array | `table` | Nested arrays supported; NULL array → `nil, "not found"` |

## Connection pooling

The module maintains a connection pool keyed by `host:port/db[+ssl]`.

- `close()` returns the socket to the pool (default behavior).
- `close(true)` force-closes the socket.
- `connect()` checks the pool first. Connections idle less than 5 seconds
  are reused immediately; older connections are validated with `PING`
  before reuse. Failed validation discards the connection.
- Pool size is controlled by `REDIS_CLIENT_POOL_SIZE` environment
  variable (default 20).

## Usage examples

```lua
local lev = require("lev")
local redis = require("redis")

lev.run(function()
	-- Default connection (127.0.0.1:6379)
	local client, err = redis.connect()
	if not client then error(err) end

	client:cmd("SET", "greeting", "hello")
	local val = client:cmd("GET", "greeting")  -- "hello"

	-- Pipeline
	local results = client:pipeline({
		{ "SET", "a", "1" },
		{ "SET", "b", "2" },
		{ "MGET", "a", "b" },
	})
	-- results[3][1] = { "1", "2" }

	client:close()  -- return to pool

	-- Authenticated TLS connection to specific db
	local secure, err = redis.connect({
		host = "redis.example.com",
		port = 6380,
		ssl = true,
		auth = { user = "default", pass = "secret" },
		db = 3,
		timeout = 5,
	})
	if secure then
		secure:cmd("SET", "x", "y")
		secure:close(true)  -- force-close, skip pool
	end
end)
```
