Skip to content

Commit 4c76597

Browse files
authored
feat: add prefix method to publisher, rate limiter, and lock adapters (#2332)
Publisher, rate limiter, and lock adapters that take a `prefix` option now also have a `prefix()` method. It returns a new adapter with the given prefix appended that shares the original's client, so scoping per request or tenant is cheap: ``limiter.prefix(`tenant:${id}:`)``. ## Changes - Available on the Redis, Upstash, Bun Redis, and Cloudflare adapters. Memory adapters don't have it, since a new instance wouldn't share their state. - Publishers created by `prefix()` on `RedisPublisher` and `BunRedisPublisher` reuse the original subscriber connection and add no extra `error` listener to it, so creating one per request doesn't leak. - Each adapter example in the docs shows a `prefix()` call. ## Notes - The protected `prefix` field of `BaseRedisLocker`, `BaseRedisRateLimiter`, and `BaseRedisPublisher` is now `keyPrefix`/`eventPrefix`, to make room for the method. Only custom subclasses that read it are affected. ## Testing - Each adapter has a test that a prefixed instance shares keys, counters, or events with the original. - Redis and Cloudflare suites pass locally. Upstash and Bun tests were not run locally (no credentials / Bun runtime), so CI is their first run.
1 parent 5dc334e commit 4c76597

30 files changed

Lines changed: 363 additions & 42 deletions

‎apps/content/docs/helpers/lock.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ const locker = new RedisLocker(client, {
183183
*/
184184
retryInterval: 100,
185185
})
186+
187+
// Creates a locker with an extra prefix that shares this client, e.g. per tenant
188+
const tenantLocker = locker.prefix('tenant:123:')
186189
```
187190

188191
### Upstash
@@ -225,6 +228,9 @@ const locker = new UpstashLocker(redis, {
225228
*/
226229
retryInterval: 100,
227230
})
231+
232+
// Creates a locker with an extra prefix that shares this client, e.g. per tenant
233+
const tenantLocker = locker.prefix('tenant:123:')
228234
```
229235

230236
### Bun Redis
@@ -265,6 +271,9 @@ const locker = new BunRedisLocker(redis, {
265271
*/
266272
retryInterval: 100,
267273
})
274+
275+
// Creates a locker with an extra prefix that shares this client, e.g. per tenant
276+
const tenantLocker = locker.prefix('tenant:123:')
268277
```
269278

270279
### Cloudflare
@@ -316,6 +325,9 @@ export default {
316325
*/
317326
getStubByName: (namespace, key) => namespace.getByName(key),
318327
})
328+
329+
// Creates a locker with an extra prefix that shares this namespace, e.g. per tenant
330+
const tenantLocker = locker.prefix('tenant:123:')
319331
},
320332
}
321333
```

‎apps/content/docs/helpers/publisher.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ const publisher = new RedisPublisher<Events>(client, {
150150
ttl: 300_000
151151
}
152152
})
153+
154+
// Creates a publisher with an extra prefix that shares this client, e.g. per tenant
155+
const tenantPublisher = publisher.prefix('tenant:123:')
153156
```
154157

155158
```ts upstash
@@ -196,6 +199,9 @@ const publisher = new UpstashPublisher<Events>(redis, {
196199
ttl: 300_000
197200
}
198201
})
202+
203+
// Creates a publisher with an extra prefix that shares this client, e.g. per tenant
204+
const tenantPublisher = publisher.prefix('tenant:123:')
199205
```
200206

201207
```ts bun
@@ -253,6 +259,9 @@ const publisher = new BunRedisPublisher<Events>(redis, {
253259
ttl: 300_000
254260
}
255261
})
262+
263+
// Creates a publisher with an extra prefix that shares this client, e.g. per tenant
264+
const tenantPublisher = publisher.prefix('tenant:123:')
256265
```
257266

258267
```ts cloudflare
@@ -331,6 +340,9 @@ export default {
331340
*/
332341
getStubByName: (namespace, event) => namespace.getByName(event)
333342
})
343+
344+
// Creates a publisher with an extra prefix that shares this namespace, e.g. per tenant
345+
const tenantPublisher = publisher.prefix('tenant:123:')
334346
},
335347
}
336348
```

‎apps/content/docs/helpers/ratelimit.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ const limiter = new RedisRateLimiter(client, {
216216
*/
217217
timeout: 0,
218218
})
219+
220+
// Creates a limiter with an extra prefix that shares this client, e.g. per tenant
221+
const tenantLimiter = limiter.prefix('tenant:123:')
219222
```
220223

221224
### Upstash
@@ -254,6 +257,9 @@ const limiter = new UpstashRateLimiter(redis, {
254257
*/
255258
timeout: 0,
256259
})
260+
261+
// Creates a limiter with an extra prefix that shares this client, e.g. per tenant
262+
const tenantLimiter = limiter.prefix('tenant:123:')
257263
```
258264

259265
### Bun Redis
@@ -290,6 +296,9 @@ const limiter = new BunRedisRateLimiter(redis, {
290296
*/
291297
timeout: 0,
292298
})
299+
300+
// Creates a limiter with an extra prefix that shares this client, e.g. per tenant
301+
const tenantLimiter = limiter.prefix('tenant:123:')
293302
```
294303

295304
### Cloudflare
@@ -309,6 +318,9 @@ export default {
309318
*/
310319
prefix: ''
311320
})
321+
322+
// Creates a limiter with an extra prefix that shares this binding, e.g. per tenant
323+
const tenantLimiter = limiter.prefix('tenant:123:')
312324
}
313325
}
314326
```

‎packages/bun/src/redis-lock.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ describe.skipIf(!REDIS_URL)('bun redis locker integration', async () => {
9595
await holder.release()
9696
}, { timeout: 20_000 })
9797

98+
it('shares locks with lockers created by prefix', async () => {
99+
const { locker } = createTestingLocker()
100+
const holder = await hold(locker, 'tenant:key')
101+
102+
await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError)
103+
await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok')
104+
105+
await holder.release()
106+
})
107+
98108
it('releases the lock when the callback throws', async () => {
99109
const { locker } = createTestingLocker()
100110

‎packages/bun/src/redis-lock.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,21 @@ import { BaseRedisLocker } from '@orpc/experimental-lock/base-redis'
1111
export class experimental_BunRedisLocker extends BaseRedisLocker {
1212
constructor(
1313
private readonly redis: RedisClient,
14-
options: BaseRedisLockerOptions,
14+
private readonly options: BaseRedisLockerOptions,
1515
) {
1616
super(options)
1717
}
1818

19+
/**
20+
* Creates a locker with `prefix` appended to this locker's prefix, sharing its Redis client.
21+
*
22+
* @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters}
23+
*/
24+
prefix(prefix: string): experimental_BunRedisLocker {
25+
// eslint-disable-next-line new-cap
26+
return new experimental_BunRedisLocker(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` })
27+
}
28+
1929
protected async acquire(key: string, token: string, ttl: number): Promise<boolean> {
2030
const result = await this.redis.send('SET', [key, token, 'NX', 'PX', String(ttl)])
2131

‎packages/bun/src/redis-publisher.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,35 @@ describe.skipIf(!REDIS_URL)('bun redis publisher', () => {
8181
await unsubscribeResumeAttempt()
8282
}, { timeout: 20_000 })
8383

84+
it('shares the subscriber with publishers created by prefix', async () => {
85+
const publisher = createTestingPublisher()
86+
const tenant = publisher.prefix('tenant:')
87+
const event = crypto.randomUUID()
88+
const listener = vi.fn()
89+
const onError = vi.fn()
90+
91+
const unsubscribe = await tenant.subscribe(event, listener, { onError })
92+
93+
await publisher.publish(`tenant:${event}`, { order: 1 })
94+
await publisher.prefix('other:').publish(event, { order: 2 })
95+
await tenant.publish(event, { order: 3 })
96+
97+
await waitFor(() => {
98+
expect(listener).toHaveBeenCalledTimes(2)
99+
})
100+
expect(listener).toHaveBeenNthCalledWith(1, { order: 1 })
101+
expect(listener).toHaveBeenNthCalledWith(2, { order: 3 })
102+
103+
const subscriber: RedisClient = await (publisher as any).getSubscriber()
104+
expect(await (tenant as any).getSubscriber()).toBe(subscriber)
105+
106+
const error = new Error('boom')
107+
subscriber.onclose!.call(subscriber, error)
108+
expect(onError).toHaveBeenCalledWith(error)
109+
110+
await unsubscribe()
111+
}, { timeout: 20_000 })
112+
84113
it('resumes missed events in order and preserves event metadata while rewriting ids', async () => {
85114
const publisher = createTestingPublisher({
86115
resume: { enabled: true, ttl: 10_000 },

‎packages/bun/src/redis-publisher.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,38 @@ export interface BunRedisPublisherOptions extends BaseRedisPublisherOptions {
2626
* @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
2727
*/
2828
export class BunRedisPublisher<T extends Record<string, object>> extends BaseRedisPublisher<T> {
29-
private readonly getSubscriber: () => Promise<RedisClient>
29+
private getSubscriber: () => Promise<RedisClient>
3030

31-
private readonly onErrors: Array<(error: ThrowableError) => void> = []
31+
private onErrors: Array<(error: ThrowableError) => void> = []
3232

3333
constructor(
3434
private readonly redis: RedisClient,
35-
{ subscriber, ...options }: BunRedisPublisherOptions = {},
35+
private readonly options: BunRedisPublisherOptions = {},
3636
) {
3737
super(options)
3838

3939
this.getSubscriber = once(async () => {
40-
const client = await (subscriber ?? redis.duplicate())
40+
const client = await (options.subscriber ?? redis.duplicate())
4141
this.watchSubscriber(client)
4242
return client
4343
})
4444
}
4545

46+
/**
47+
* Creates a publisher with `prefix` appended to this publisher's prefix, sharing its Redis client and subscriber.
48+
*
49+
* @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
50+
*/
51+
prefix(prefix: string): BunRedisPublisher<T> {
52+
const publisher = new BunRedisPublisher<T>(this.redis, { ...this.options, prefix: `${this.eventPrefix}${prefix}` })
53+
54+
// Share the lazily created subscriber, which reports its errors to `onErrors` of this publisher.
55+
publisher.getSubscriber = this.getSubscriber
56+
publisher.onErrors = this.onErrors
57+
58+
return publisher
59+
}
60+
4661
protected async publishMessage(channel: string, message: string): Promise<void> {
4762
await this.redis.publish(channel, message)
4863
}

‎packages/bun/src/redis-ratelimit.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ describe.skipIf(!REDIS_URL)('bun redis rate limiter integration', async () => {
6464
})
6565
}, { timeout: 20_000 })
6666

67+
it('shares counters with limiters created by prefix', async () => {
68+
const limiter = await createTestingRateLimiter({ maxRequests: 1, window: 10_000 })
69+
70+
await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ success: true, remaining: 0 })
71+
await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ success: false, remaining: 0 })
72+
await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ success: true, remaining: 0 })
73+
}, { timeout: 20_000 })
74+
6775
it('returns a stable reset timestamp within the same window', async () => {
6876
const window = 2000
6977
const limiter = await createTestingRateLimiter({

‎packages/bun/src/redis-ratelimit.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@ export interface BunRedisRateLimiterOptions extends BaseRedisRateLimiterOptions
1313
export class BunRedisRateLimiter extends BaseRedisRateLimiter {
1414
constructor(
1515
private readonly redis: RedisClient,
16-
options: BunRedisRateLimiterOptions,
16+
private readonly options: BunRedisRateLimiterOptions,
1717
) {
1818
super(options)
1919
}
2020

21+
/**
22+
* Creates a limiter with `prefix` appended to this limiter's prefix, sharing its Redis client.
23+
*
24+
* @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters}
25+
*/
26+
prefix(prefix: string): BunRedisRateLimiter {
27+
return new BunRedisRateLimiter(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` })
28+
}
29+
2130
protected async evalScript(script: string, keys: string[], args: string[]): Promise<unknown> {
2231
return await this.redis.send('EVAL', [script, String(keys.length), ...keys, ...args])
2332
}

‎packages/cloudflare/src/lock.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ describe('durableLocker', () => {
9393
await expect(other.lock('key', () => 'ok')).resolves.toBe('ok')
9494
})
9595

96+
it('shares locks with lockers created by prefix', async () => {
97+
const { locker } = createTestingLocker()
98+
const holder = await hold(locker, 'tenant:key')
99+
100+
await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError)
101+
await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok')
102+
103+
await holder.release()
104+
})
105+
96106
it('tracks locks independently per key', async () => {
97107
const { locker } = createTestingLocker()
98108
const holder = await hold(locker, 'alice')

0 commit comments

Comments
 (0)