@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 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;
@@ -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 };
@@ -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
+ }