From 313703e36db5385850dfcc513607a9880df8efdd Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Mon, 17 Aug 2026 21:31:39 +0000 Subject: [PATCH 1/2] feat: Add Redis key-value backend with TTL and sorted sets Model the activity-feed port with HASH users and posts, SET NX handles, bidirectional follow sets, and a per-author ZSET timeline. PEXPIRE is per key, so reads prune dangling timeline members after a post hash expires. --- README.md | 20 +++- package.json | 2 +- src/index.ts | 4 +- src/redis/memory.ts | 176 +++++++++++++++++++++++++++++++++ src/redis/store.ts | 206 +++++++++++++++++++++++++++++++++++++++ test/redis-store.test.ts | 95 ++++++++++++++++++ 6 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 src/redis/memory.ts create mode 100644 src/redis/store.ts create mode 100644 test/redis-store.test.ts diff --git a/README.md b/README.md index 6e7992b..7a287f2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ One domain, many stores: the same activity feed modeled on relational, document, ## What this demonstrates -Polyglot persistence is the idea that one product rarely has one ideal database. A follow graph wants adjacency lookups, a home timeline wants a clustered time key, and a unique handle wants a relational constraint. This repo keeps one domain and one `ActivityStore` port, then implements that port on each paradigm so query shape, consistency, and scaling stay comparable. The in-memory backend is fan-in on read: per-author timelines merged into a home feed. The Postgres backend is the same port over a 3NF schema, composite B-tree indexes, and keyset SQL. Later backends (Redis, MongoDB, Cassandra, CockroachDB, Neo4j) keep the same interface and the same contract tests. +Polyglot persistence is the idea that one product rarely has one ideal database. A follow graph wants adjacency lookups, a home timeline wants a clustered time key, and a unique handle wants a relational constraint. This repo keeps one domain and one `ActivityStore` port, then implements that port on each paradigm so query shape, consistency, and scaling stay comparable. The in-memory backend is fan-in on read: per-author timelines merged into a home feed. The Postgres backend is the same port over a 3NF schema, composite B-tree indexes, and keyset SQL. The Redis backend speaks HASH, SET, ZSET, and SET NX, plus per-key PEXPIRE. Later backends (MongoDB, Cassandra, CockroachDB, Neo4j) keep the same interface and the same contract tests. ## Concepts demonstrated @@ -22,17 +22,22 @@ Polyglot persistence is the idea that one product rarely has one ideal database. - Fan-in via `CROSS JOIN LATERAL`: each followee's newest-N posts walk `posts_author_timeline_idx`, then the outer query merges - Table-level `CHECK` (no self-follow) and `UNIQUE` (handle) as the source of integrity - Query planner: after `ANALYZE`, `EXPLAIN` on the author timeline is `Index Scan using posts_author_timeline_idx` with no Sort. The lateral feed uses that same scan per followee. +- Redis key design: `u:{id}` HASH, `h:{handle}` STRING, `p:{id}` HASH, `tl:{author}` ZSET, `fg:{id}` / `fr:{id}` SET +- SET NX as an application uniqueness primitive; sorted-set timeline (score = `createdAt`, member = post id, equal scores reverse-lex) +- Per-key TTL (`PEXPIRE`) versus a relational `PRIMARY KEY`: the hash can vanish, the ZSET member lingers until the next read `ZREM`s it, and the id is reusable +- No foreign keys and no `CHECK`: self-follow and existence are enforced in the store, not the engine ## What's implemented - Project scaffold with TypeScript strict mode, Vitest, and CI - Activity-feed domain, `ActivityStore` port, in-memory backend, and a shared contract suite - Relational backend (Postgres): schema, indexes, the SQL queries for the domain +- Key-value backend (Redis): data structures + expiry, the tradeoffs vs relational ## Usage ```ts -import { MemoryStore } from 'polyglot-persistence' +import { MemoryRedis, MemoryStore, RedisStore } from 'polyglot-persistence' const store = new MemoryStore() @@ -47,9 +52,18 @@ const post = await store.publish( const page = await store.feed('ada', { limit: 20 }) const next = await store.feed('ada', { limit: 20, before: post }) + +const redis = RedisStore.attach(new MemoryRedis()) +await redis.createUser({ id: 'ada', handle: 'ada' }) +await redis.publish({ id: 'p1', authorId: 'ada', body: 'ttl demo' }, Date.now()) +await redis.expirePost('p1', 60_000) ``` -`PostgresStore` takes any `{ query(sql, params) }` client. Tests drive it with PGlite (`@electric-sql/pglite` is a devDependency, not a runtime dep). A server `pg` Pool works the same way. A new backend implements `ActivityStore` and calls `defineStoreContract(name, factory)` from `test/contract.ts`. +`PostgresStore` takes any `{ query(sql, params) }` client. Tests drive it with PGlite (`@electric-sql/pglite` is a devDependency, not a runtime dep). A server `pg` Pool works the same way. + +`RedisStore` takes any `RedisCommands` client (GET/SET NX, HASH, SET, ZSET, PEXPIRE). `MemoryRedis` is an in-process engine with the same types and per-key TTL, so the contract runs without a daemon. Point the same store at a real Redis by implementing that command surface. + +A new backend implements `ActivityStore` and calls `defineStoreContract(name, factory)` from `test/contract.ts`. ## Running the tests diff --git a/package.json b/package.json index 5e928d7..12b6dc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "polyglot-persistence", - "version": "0.2.0", + "version": "0.3.0", "description": "One domain modeled across relational, document, key-value, wide-column, distributed SQL, and graph stores. A reference for when to reach for which database.", "type": "module", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index f32c174..95bdac0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export const VERSION = '0.2.0' +export const VERSION = '0.3.0' export { BODY_MAX, StoreError } from './domain' export type { FeedCursor, @@ -13,3 +13,5 @@ export type { ActivityStore, CreateUserInput, PublishInput } from './store' export { MemoryStore } from './memory-store' export { PostgresStore, SCHEMA_STATEMENTS, SQL } from './postgres/store' export type { SqlQuery } from './postgres/store' +export { MemoryRedis, RedisStore } from './redis/store' +export type { RedisCommands } from './redis/store' diff --git a/src/redis/memory.ts b/src/redis/memory.ts new file mode 100644 index 0000000..2d99d39 --- /dev/null +++ b/src/redis/memory.ts @@ -0,0 +1,176 @@ +export interface RedisCommands { + get(key: string): Promise + set(key: string, value: string, opts?: { nx?: boolean }): Promise + del(...keys: string[]): Promise + hset(key: string, fields: Record): Promise + hsetnx(key: string, field: string, value: string): Promise + hgetall(key: string): Promise> + sadd(key: string, member: string): Promise + srem(key: string, member: string): Promise + sismember(key: string, member: string): Promise + smembers(key: string): Promise + zadd(key: string, score: number, member: string): Promise + zrem(key: string, member: string): Promise + zcard(key: string): Promise + zrevrangebyscore( + key: string, + max: number, + min: number, + ): Promise> + pexpire(key: string, ttlMs: number): Promise + pttl(key: string): Promise +} + +type Rec = + | { t: 'string'; v: string; exp?: number } + | { t: 'hash'; v: Map; exp?: number } + | { t: 'set'; v: Set; exp?: number } + | { t: 'zset'; v: Map; exp?: number } + +export class MemoryRedis implements RedisCommands { + private readonly keys = new Map() + + constructor(private readonly clock: () => number = Date.now) {} + + async get(key: string): Promise { + const rec = this.as(key, 'string') + return rec ? rec.v : null + } + + async set(key: string, value: string, opts?: { nx?: boolean }): Promise { + const live = this.alive(key) + if (opts?.nx && live) return false + if (live && live.t !== 'string') throw new Error('WRONGTYPE') + this.keys.set(key, { t: 'string', v: value }) + return true + } + + async del(...keys: string[]): Promise { + let n = 0 + for (const key of keys) { + if (this.alive(key)) n++ + this.keys.delete(key) + } + return n + } + + async hset(key: string, fields: Record): Promise { + const rec = this.ensure(key, 'hash', () => ({ t: 'hash', v: new Map() })) + let added = 0 + for (const [field, value] of Object.entries(fields)) { + if (!rec.v.has(field)) added++ + rec.v.set(field, value) + } + return added + } + + async hsetnx(key: string, field: string, value: string): Promise { + const rec = this.ensure(key, 'hash', () => ({ t: 'hash', v: new Map() })) + if (rec.v.has(field)) return false + rec.v.set(field, value) + return true + } + + async hgetall(key: string): Promise> { + const rec = this.as(key, 'hash') + if (!rec) return {} + return Object.fromEntries(rec.v) + } + + async sadd(key: string, member: string): Promise { + const rec = this.ensure(key, 'set', () => ({ t: 'set', v: new Set() })) + const before = rec.v.size + rec.v.add(member) + return rec.v.size - before + } + + async srem(key: string, member: string): Promise { + const rec = this.as(key, 'set') + if (!rec) return 0 + return rec.v.delete(member) ? 1 : 0 + } + + async sismember(key: string, member: string): Promise { + const rec = this.as(key, 'set') + return rec ? rec.v.has(member) : false + } + + async smembers(key: string): Promise { + const rec = this.as(key, 'set') + return rec ? [...rec.v] : [] + } + + async zadd(key: string, score: number, member: string): Promise { + const rec = this.ensure(key, 'zset', () => ({ t: 'zset', v: new Map() })) + const added = rec.v.has(member) ? 0 : 1 + rec.v.set(member, score) + return added + } + + async zrem(key: string, member: string): Promise { + const rec = this.as(key, 'zset') + if (!rec) return 0 + return rec.v.delete(member) ? 1 : 0 + } + + async zcard(key: string): Promise { + const rec = this.as(key, 'zset') + return rec ? rec.v.size : 0 + } + + async zrevrangebyscore( + key: string, + max: number, + min: number, + ): Promise> { + const rec = this.as(key, 'zset') + if (!rec) return [] + return [...rec.v.entries()] + .filter(([, score]) => score <= max && score >= min) + .sort((a, b) => (a[1] === b[1] ? (a[0] < b[0] ? 1 : -1) : b[1] - a[1])) + .map(([member, score]) => ({ member, score })) + } + + async pexpire(key: string, ttlMs: number): Promise { + const rec = this.alive(key) + if (!rec || ttlMs < 1) return false + rec.exp = this.clock() + ttlMs + return true + } + + async pttl(key: string): Promise { + const rec = this.alive(key) + if (!rec) return -2 + if (rec.exp === undefined) return -1 + return Math.max(0, rec.exp - this.clock()) + } + + private alive(key: string): Rec | undefined { + const rec = this.keys.get(key) + if (!rec) return undefined + if (rec.exp !== undefined && rec.exp <= this.clock()) { + this.keys.delete(key) + return undefined + } + return rec + } + + private as(key: string, t: T): Extract | undefined { + const rec = this.alive(key) + if (!rec) return undefined + if (rec.t !== t) throw new Error('WRONGTYPE') + return rec as Extract + } + + private ensure( + key: string, + t: T, + make: () => Extract, + ): Extract { + const rec = this.as(key, t) + if (rec) return rec + const created = make() + this.keys.set(key, created) + return created + } +} diff --git a/src/redis/store.ts b/src/redis/store.ts new file mode 100644 index 0000000..2b6421e --- /dev/null +++ b/src/redis/store.ts @@ -0,0 +1,206 @@ +import { + comparePosts, + isBeforeCursor, + normalizeBody, + normalizeHandle, + normalizeId, + pageLimit, + StoreError, + tryNormalizeHandle, + tryNormalizeId, + type FeedCursor, + type Page, + type Post, + type PostId, + type User, + type UserId, +} from '../domain' +import type { ActivityStore, CreateUserInput, PublishInput } from '../store' +import type { RedisCommands } from './memory' + +export { MemoryRedis, type RedisCommands } from './memory' + +const kUser = (id: string) => `u:${id}` +const kHandle = (handle: string) => `h:${handle}` +const kPost = (id: string) => `p:${id}` +const kTimeline = (authorId: string) => `tl:${authorId}` +const kFollowing = (id: string) => `fg:${id}` +const kFollowers = (id: string) => `fr:${id}` + +export class RedisStore implements ActivityStore { + private constructor(private readonly redis: RedisCommands) {} + + static attach(redis: RedisCommands): RedisStore { + return new RedisStore(redis) + } + + async createUser(input: CreateUserInput, now = Date.now()): Promise { + const id = normalizeId(input.id) + const handle = normalizeHandle(input.handle) + if (!(await this.redis.hsetnx(kUser(id), 'id', id))) { + throw new StoreError('user_exists') + } + if (!(await this.redis.set(kHandle(handle), id, { nx: true }))) { + await this.redis.del(kUser(id)) + throw new StoreError('handle_taken') + } + await this.redis.hset(kUser(id), { handle, createdAt: String(now) }) + return { id, handle, createdAt: now } + } + + async getUser(id: UserId): Promise { + const key = tryNormalizeId(id) + return key ? readUser(await this.redis.hgetall(kUser(key))) : null + } + + async getUserByHandle(handle: string): Promise { + const key = tryNormalizeHandle(handle) + if (!key) return null + const id = await this.redis.get(kHandle(key)) + return id ? this.getUser(id) : null + } + + async follow(followerId: UserId, followeeId: UserId): Promise { + const from = await this.requireUser(followerId) + const to = await this.requireUser(followeeId) + if (from === to) throw new StoreError('self_follow') + await this.redis.sadd(kFollowing(from), to) + await this.redis.sadd(kFollowers(to), from) + } + + async unfollow(followerId: UserId, followeeId: UserId): Promise { + const from = await this.requireUser(followerId) + const to = await this.requireUser(followeeId) + await this.redis.srem(kFollowing(from), to) + await this.redis.srem(kFollowers(to), from) + } + + async isFollowing(followerId: UserId, followeeId: UserId): Promise { + const from = await this.requireUser(followerId) + const to = await this.requireUser(followeeId) + return this.redis.sismember(kFollowing(from), to) + } + + async following(userId: UserId): Promise { + const id = await this.requireUser(userId) + return (await this.redis.smembers(kFollowing(id))).sort() + } + + async followers(userId: UserId): Promise { + const id = await this.requireUser(userId) + return (await this.redis.smembers(kFollowers(id))).sort() + } + + async publish(input: PublishInput, now = Date.now()): Promise { + const id = normalizeId(input.id) + const authorId = await this.requireUser(input.authorId) + const body = normalizeBody(input.body) + if (!(await this.redis.hsetnx(kPost(id), 'id', id))) { + throw new StoreError('post_exists') + } + await this.redis.hset(kPost(id), { + authorId, + body, + createdAt: String(now), + }) + await this.redis.zadd(kTimeline(authorId), now, id) + return { id, authorId, body, createdAt: now } + } + + async getPost(id: PostId): Promise { + const key = tryNormalizeId(id) + return key ? readPost(await this.redis.hgetall(kPost(key))) : null + } + + async expirePost(id: PostId, ttlMs: number): Promise { + const key = tryNormalizeId(id) + if (!key || ttlMs < 1) return false + if (!(await this.getPost(key))) return false + return this.redis.pexpire(kPost(key), ttlMs) + } + + async postsByAuthor(authorId: UserId, page?: Page): Promise { + const id = await this.requireUser(authorId) + return this.newest([id], page) + } + + async feed(userId: UserId, page?: Page): Promise { + const id = await this.requireUser(userId) + const followees = await this.redis.smembers(kFollowing(id)) + return this.newest(followees, page) + } + + private async requireUser(id: UserId): Promise { + const key = tryNormalizeId(id) + if (!key || !readUser(await this.redis.hgetall(kUser(key)))) { + throw new StoreError('user_not_found') + } + return key + } + + private async newest(authorIds: UserId[], page?: Page): Promise { + const limit = pageLimit(page) + const lists = await Promise.all( + authorIds.map((id) => this.loadTimeline(id, limit, page?.before)), + ) + return mergeNewest(lists, limit) + } + + private async loadTimeline( + authorId: UserId, + limit: number, + cursor?: FeedCursor, + ): Promise { + const max = cursor?.createdAt ?? Number.POSITIVE_INFINITY + const raw = await this.redis.zrevrangebyscore(kTimeline(authorId), max, Number.NEGATIVE_INFINITY) + const out: Post[] = [] + for (const { member } of raw) { + const post = await this.hydrate(authorId, member) + if (!post) continue + if (!isBeforeCursor(post, cursor)) continue + out.push(post) + if (out.length >= limit) break + } + return out + } + + private async hydrate(authorId: UserId, postId: PostId): Promise { + const post = readPost(await this.redis.hgetall(kPost(postId))) + if (post) return post + await this.redis.zrem(kTimeline(authorId), postId) + return null + } +} + +function readUser(f: Record): User | null { + const createdAt = Number(f.createdAt) + if (!f.id || !f.handle || !Number.isFinite(createdAt)) return null + return { id: f.id, handle: f.handle, createdAt } +} + +function readPost(f: Record): Post | null { + const createdAt = Number(f.createdAt) + if (!f.id || !f.authorId || !f.body || !Number.isFinite(createdAt)) return null + return { id: f.id, authorId: f.authorId, body: f.body, createdAt } +} + +function mergeNewest(lists: Post[][], limit: number): Post[] { + const heads = lists.map(() => 0) + const out: Post[] = [] + while (out.length < limit) { + let bestI = -1 + let best: Post | undefined + for (let i = 0; i < lists.length; i++) { + const cand = lists[i]?.[heads[i] ?? 0] + if (!cand) continue + if (!best || comparePosts(cand, best) < 0) { + best = cand + bestI = i + } + } + if (!best || bestI < 0) break + out.push(best) + heads[bestI] = (heads[bestI] ?? 0) + 1 + } + return out +} diff --git a/test/redis-store.test.ts b/test/redis-store.test.ts new file mode 100644 index 0000000..b954cbb --- /dev/null +++ b/test/redis-store.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { MemoryRedis, RedisStore } from '../src/redis/store' +import { defineStoreContract } from './contract' + +defineStoreContract('redis', () => RedisStore.attach(new MemoryRedis())) + +describe('redis data structures and expiry', () => { + it('stores users as hashes and reserves handles with SET NX', async () => { + const redis = new MemoryRedis() + const store = RedisStore.attach(redis) + await store.createUser({ id: 'ada', handle: 'ada' }, 10) + + expect(await redis.hgetall('u:ada')).toEqual({ + id: 'ada', + handle: 'ada', + createdAt: '10', + }) + expect(await redis.get('h:ada')).toBe('ada') + await expect(store.createUser({ id: 'ada2', handle: 'ADA' })).rejects.toMatchObject({ + code: 'handle_taken', + }) + expect(await redis.hgetall('u:ada2')).toEqual({}) + expect(await store.getUser('ada')).toEqual({ + id: 'ada', + handle: 'ada', + createdAt: 10, + }) + }) + + it('models follows as two sets and the author timeline as a scored zset', async () => { + const redis = new MemoryRedis() + const store = RedisStore.attach(redis) + await store.createUser({ id: 'ada', handle: 'ada' }, 1) + await store.createUser({ id: 'bob', handle: 'bob' }, 1) + await store.follow('ada', 'bob') + await store.publish({ id: 'p1', authorId: 'bob', body: 'older' }, 4) + await store.publish({ id: 'p2', authorId: 'bob', body: 'newer' }, 10) + + expect(await redis.smembers('fg:ada')).toEqual(['bob']) + expect(await redis.smembers('fr:bob')).toEqual(['ada']) + expect(await redis.zrevrangebyscore('tl:bob', Infinity, -Infinity)).toEqual([ + { member: 'p2', score: 10 }, + { member: 'p1', score: 4 }, + ]) + expect(await redis.zcard('tl:bob')).toBe(2) + }) + + it('orders equal scores by member desc, matching ZREVRANGE', async () => { + const redis = new MemoryRedis() + const store = RedisStore.attach(redis) + await store.createUser({ id: 'bob', handle: 'bob' }, 1) + await store.publish({ id: 'p1', authorId: 'bob', body: 'a' }, 10) + await store.publish({ id: 'p2', authorId: 'bob', body: 'b' }, 10) + expect((await store.postsByAuthor('bob')).map((p) => p.id)).toEqual(['p2', 'p1']) + }) + + it('expires a post hash while the zset member lingers until the next read', async () => { + const clock = { now: 1_000 } + const redis = new MemoryRedis(() => clock.now) + const store = RedisStore.attach(redis) + await store.createUser({ id: 'ada', handle: 'ada' }, 1) + await store.createUser({ id: 'bob', handle: 'bob' }, 1) + await store.follow('ada', 'bob') + await store.publish({ id: 'p1', authorId: 'bob', body: 'ephemeral' }, 5) + + expect(await store.expirePost('p1', 50)).toBe(true) + expect(await redis.pttl('p:p1')).toBe(50) + expect(await store.getPost('p1')).toMatchObject({ id: 'p1' }) + expect(await redis.zcard('tl:bob')).toBe(1) + + clock.now = 1_050 + expect(await store.getPost('p1')).toBeNull() + expect(await redis.pttl('p:p1')).toBe(-2) + expect(await redis.zcard('tl:bob')).toBe(1) + expect(await store.feed('ada')).toEqual([]) + expect(await redis.zcard('tl:bob')).toBe(0) + }) + + it('lets a post id be reused after TTL, unlike a relational primary key', async () => { + const clock = { now: 1_000 } + const redis = new MemoryRedis(() => clock.now) + const store = RedisStore.attach(redis) + await store.createUser({ id: 'bob', handle: 'bob' }, 1) + await store.publish({ id: 'p1', authorId: 'bob', body: 'first' }, 5) + await store.expirePost('p1', 10) + clock.now = 1_020 + + const again = await store.publish({ id: 'p1', authorId: 'bob', body: 'second' }, 9) + expect(again).toEqual({ id: 'p1', authorId: 'bob', body: 'second', createdAt: 9 }) + expect(await redis.zcard('tl:bob')).toBe(1) + expect(await store.postsByAuthor('bob')).toEqual([again]) + expect(await store.expirePost('missing', 50)).toBe(false) + expect(await store.expirePost('p1', 0)).toBe(false) + }) +}) From cbef21e035098a05b86133c548895b23210fff68 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Mon, 17 Aug 2026 21:39:06 +0000 Subject: [PATCH 2/2] fix: drop stale timeline members when a post id is reused Hydrate now treats a live hash whose author does not match the timeline as expired index data, ZREMs it, and returns null. --- src/redis/store.ts | 2 +- test/redis-store.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/redis/store.ts b/src/redis/store.ts index 2b6421e..c615a11 100644 --- a/src/redis/store.ts +++ b/src/redis/store.ts @@ -166,7 +166,7 @@ export class RedisStore implements ActivityStore { private async hydrate(authorId: UserId, postId: PostId): Promise { const post = readPost(await this.redis.hgetall(kPost(postId))) - if (post) return post + if (post && post.authorId === authorId) return post await this.redis.zrem(kTimeline(authorId), postId) return null } diff --git a/test/redis-store.test.ts b/test/redis-store.test.ts index b954cbb..8762207 100644 --- a/test/redis-store.test.ts +++ b/test/redis-store.test.ts @@ -92,4 +92,23 @@ describe('redis data structures and expiry', () => { expect(await store.expirePost('missing', 50)).toBe(false) expect(await store.expirePost('p1', 0)).toBe(false) }) + + it('does not leak a reused post id onto another author timeline', async () => { + const clock = { now: 1_000 } + const redis = new MemoryRedis(() => clock.now) + const store = RedisStore.attach(redis) + await store.createUser({ id: 'ada', handle: 'ada' }, 1) + await store.createUser({ id: 'bob', handle: 'bob' }, 1) + await store.createUser({ id: 'cam', handle: 'cam' }, 1) + await store.follow('cam', 'bob') + await store.publish({ id: 'p1', authorId: 'bob', body: 'first' }, 5) + await store.expirePost('p1', 10) + clock.now = 1_020 + + const reused = await store.publish({ id: 'p1', authorId: 'ada', body: 'second' }, 9) + expect(await store.postsByAuthor('bob')).toEqual([]) + expect(await store.postsByAuthor('ada')).toEqual([reused]) + expect(await store.feed('cam')).toEqual([]) + expect(await redis.zcard('tl:bob')).toBe(0) + }) })