1cbfddd4c2
Extract a PresencePort seam (async setFocus/clearSocket/isViewing) with two impls: the existing in-memory Map (dev, single instance) and a new RedisPresenceService (prod). MessageModule provides PRESENCE_PORT via a REDIS_URL-gated factory — same pattern as the socket.io Redis adapter — so 'who is viewing what' is shared across replicas and the notification projector's presence gate works at N>1. Gateway + projector inject the port; isViewing is now awaited. Presence spec updated to async (passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
65 lines
2.6 KiB
TypeScript
65 lines
2.6 KiB
TypeScript
import { Logger, type OnModuleDestroy } from '@nestjs/common';
|
|
import { Redis } from 'ioredis';
|
|
import type { PresencePort } from './presence.port';
|
|
|
|
/**
|
|
* Redis-backed presence for multi-replica prod: a socket connected to replica A and a message
|
|
* projected on replica B must share the same "who is viewing what" view, which an in-memory Map
|
|
* cannot provide. Wired (in place of {@link PresenceService}) only when REDIS_URL is set.
|
|
*
|
|
* Layout (per user, tiny):
|
|
* sock:{socketId} -> userId (so clearSocket can find the user), EX TTL
|
|
* user:{userId} -> HASH socketId=threadId, EX TTL
|
|
* isViewing = does the user hash hold this threadId for any socket. Keys carry a TTL so a crashed
|
|
* replica's entries self-heal; expiry errs toward "not viewing" (push is sent), the safe default.
|
|
*/
|
|
export class RedisPresenceService implements PresencePort, OnModuleDestroy {
|
|
private readonly logger = new Logger(RedisPresenceService.name);
|
|
private readonly redis: Redis;
|
|
private static readonly TTL_SECONDS = 300;
|
|
private static readonly PREFIX = 'iios:presence';
|
|
|
|
constructor(url: string, client?: Redis) {
|
|
this.redis = client ?? new Redis(url, { maxRetriesPerRequest: null });
|
|
this.redis.on('error', (err) => this.logger.error(`redis presence error: ${err.message}`));
|
|
}
|
|
|
|
private sockKey(socketId: string): string {
|
|
return `${RedisPresenceService.PREFIX}:sock:${socketId}`;
|
|
}
|
|
private userKey(userId: string): string {
|
|
return `${RedisPresenceService.PREFIX}:user:${userId}`;
|
|
}
|
|
|
|
async setFocus(socketId: string, userId: string, threadId: string | null): Promise<void> {
|
|
const ttl = RedisPresenceService.TTL_SECONDS;
|
|
const sock = this.sockKey(socketId);
|
|
const user = this.userKey(userId);
|
|
const pipe = this.redis.multi().set(sock, userId, 'EX', ttl);
|
|
if (threadId === null) {
|
|
// Blurred / no thread open — the socket stays connected but is viewing nothing.
|
|
pipe.hdel(user, socketId);
|
|
} else {
|
|
pipe.hset(user, socketId, threadId).expire(user, ttl);
|
|
}
|
|
await pipe.exec();
|
|
}
|
|
|
|
async clearSocket(socketId: string): Promise<void> {
|
|
const sock = this.sockKey(socketId);
|
|
const userId = await this.redis.get(sock);
|
|
const pipe = this.redis.multi().del(sock);
|
|
if (userId) pipe.hdel(this.userKey(userId), socketId);
|
|
await pipe.exec();
|
|
}
|
|
|
|
async isViewing(userId: string, threadId: string): Promise<boolean> {
|
|
const threads = await this.redis.hvals(this.userKey(userId));
|
|
return threads.includes(threadId);
|
|
}
|
|
|
|
async onModuleDestroy(): Promise<void> {
|
|
await this.redis.quit().catch(() => undefined);
|
|
}
|
|
}
|