From 76bde9dc7cefcff027f01c0d6ad7028e92447545 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 24 Oct 2026 20:09:37 +0700 Subject: [PATCH 1/3] feat: add prefix method to publisher, rate limiter, and lock adapters Adapters with a `prefix` option now have a `prefix()` method that returns a new adapter with the given prefix appended, sharing the same client, so it is cheap to create per request or tenant. Memory adapters do not have it. --- apps/content/docs/helpers/lock.mdx | 12 +++++++ apps/content/docs/helpers/publisher.mdx | 12 +++++++ apps/content/docs/helpers/ratelimit.mdx | 12 +++++++ packages/bun/src/redis-lock.test.ts | 10 ++++++ packages/bun/src/redis-lock.ts | 12 ++++++- packages/bun/src/redis-publisher.test.ts | 29 +++++++++++++++ packages/bun/src/redis-publisher.ts | 23 +++++++++--- packages/bun/src/redis-ratelimit.test.ts | 8 +++++ packages/bun/src/redis-ratelimit.ts | 11 +++++- packages/cloudflare/src/lock.test.ts | 10 ++++++ packages/cloudflare/src/lock.ts | 18 +++++++--- packages/cloudflare/src/publisher.test.ts | 18 ++++++++++ packages/cloudflare/src/publisher.ts | 21 +++++++---- packages/cloudflare/src/ratelimit.test.ts | 9 +++++ packages/cloudflare/src/ratelimit.ts | 17 ++++++--- packages/lock/src/adapters/base-redis.ts | 6 ++-- packages/lock/src/adapters/redis.test.ts | 10 ++++++ packages/lock/src/adapters/redis.ts | 11 +++++- packages/lock/src/adapters/upstash.test.ts | 10 ++++++ packages/lock/src/adapters/upstash.ts | 11 +++++- packages/publisher/src/adapters/base-redis.ts | 8 ++--- packages/publisher/src/adapters/redis.test.ts | 30 ++++++++++++++++ packages/publisher/src/adapters/redis.ts | 36 +++++++++++++++---- .../publisher/src/adapters/upstash.test.ts | 20 +++++++++++ packages/publisher/src/adapters/upstash.ts | 11 +++++- packages/ratelimit/src/adapters/base-redis.ts | 6 ++-- packages/ratelimit/src/adapters/redis.test.ts | 8 +++++ packages/ratelimit/src/adapters/redis.ts | 11 +++++- .../ratelimit/src/adapters/upstash.test.ts | 8 +++++ packages/ratelimit/src/adapters/upstash.ts | 11 +++++- 30 files changed, 377 insertions(+), 42 deletions(-) diff --git a/apps/content/docs/helpers/lock.mdx b/apps/content/docs/helpers/lock.mdx index 74ac286743..ad309a3be1 100644 --- a/apps/content/docs/helpers/lock.mdx +++ b/apps/content/docs/helpers/lock.mdx @@ -183,6 +183,9 @@ const locker = new RedisLocker(client, { */ retryInterval: 100, }) + +// Creates a locker with an extra prefix that shares this client, e.g. per tenant +const tenantLocker = locker.prefix('tenant:123:') ``` ### Upstash @@ -225,6 +228,9 @@ const locker = new UpstashLocker(redis, { */ retryInterval: 100, }) + +// Creates a locker with an extra prefix that shares this client, e.g. per tenant +const tenantLocker = locker.prefix('tenant:123:') ``` ### Bun Redis @@ -265,6 +271,9 @@ const locker = new BunRedisLocker(redis, { */ retryInterval: 100, }) + +// Creates a locker with an extra prefix that shares this client, e.g. per tenant +const tenantLocker = locker.prefix('tenant:123:') ``` ### Cloudflare @@ -316,6 +325,9 @@ export default { */ getStubByName: (namespace, key) => namespace.getByName(key), }) + + // Creates a locker with an extra prefix that shares this client, e.g. per tenant + const tenantLocker = locker.prefix('tenant:123:') }, } ``` diff --git a/apps/content/docs/helpers/publisher.mdx b/apps/content/docs/helpers/publisher.mdx index 3e4205493f..2bb1d60407 100644 --- a/apps/content/docs/helpers/publisher.mdx +++ b/apps/content/docs/helpers/publisher.mdx @@ -150,6 +150,9 @@ const publisher = new RedisPublisher(client, { ttl: 300_000 } }) + +// Creates a publisher with an extra prefix that shares this client, e.g. per tenant +const tenantPublisher = publisher.prefix('tenant:123:') ``` ```ts upstash @@ -196,6 +199,9 @@ const publisher = new UpstashPublisher(redis, { ttl: 300_000 } }) + +// Creates a publisher with an extra prefix that shares this client, e.g. per tenant +const tenantPublisher = publisher.prefix('tenant:123:') ``` ```ts bun @@ -253,6 +259,9 @@ const publisher = new BunRedisPublisher(redis, { ttl: 300_000 } }) + +// Creates a publisher with an extra prefix that shares this client, e.g. per tenant +const tenantPublisher = publisher.prefix('tenant:123:') ``` ```ts cloudflare @@ -331,6 +340,9 @@ export default { */ getStubByName: (namespace, event) => namespace.getByName(event) }) + + // Creates a publisher with an extra prefix that shares this client, e.g. per tenant + const tenantPublisher = publisher.prefix('tenant:123:') }, } ``` diff --git a/apps/content/docs/helpers/ratelimit.mdx b/apps/content/docs/helpers/ratelimit.mdx index 723a71ea77..5e0d2f1d7d 100644 --- a/apps/content/docs/helpers/ratelimit.mdx +++ b/apps/content/docs/helpers/ratelimit.mdx @@ -216,6 +216,9 @@ const limiter = new RedisRateLimiter(client, { */ timeout: 0, }) + +// Creates a limiter with an extra prefix that shares this client, e.g. per tenant +const tenantLimiter = limiter.prefix('tenant:123:') ``` ### Upstash @@ -254,6 +257,9 @@ const limiter = new UpstashRateLimiter(redis, { */ timeout: 0, }) + +// Creates a limiter with an extra prefix that shares this client, e.g. per tenant +const tenantLimiter = limiter.prefix('tenant:123:') ``` ### Bun Redis @@ -290,6 +296,9 @@ const limiter = new BunRedisRateLimiter(redis, { */ timeout: 0, }) + +// Creates a limiter with an extra prefix that shares this client, e.g. per tenant +const tenantLimiter = limiter.prefix('tenant:123:') ``` ### Cloudflare @@ -309,6 +318,9 @@ export default { */ prefix: '' }) + + // Creates a limiter with an extra prefix that shares this client, e.g. per tenant + const tenantLimiter = limiter.prefix('tenant:123:') } } ``` diff --git a/packages/bun/src/redis-lock.test.ts b/packages/bun/src/redis-lock.test.ts index e1c58b07be..f972334e52 100644 --- a/packages/bun/src/redis-lock.test.ts +++ b/packages/bun/src/redis-lock.test.ts @@ -95,6 +95,16 @@ describe.skipIf(!REDIS_URL)('bun redis locker integration', async () => { await holder.release() }, { timeout: 20_000 }) + it('shares locks with lockers created by prefix', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'tenant:key') + + await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + + await holder.release() + }) + it('releases the lock when the callback throws', async () => { const { locker } = createTestingLocker() diff --git a/packages/bun/src/redis-lock.ts b/packages/bun/src/redis-lock.ts index 97c389512f..1457c0b020 100644 --- a/packages/bun/src/redis-lock.ts +++ b/packages/bun/src/redis-lock.ts @@ -11,11 +11,21 @@ import { BaseRedisLocker } from '@orpc/experimental-lock/base-redis' export class experimental_BunRedisLocker extends BaseRedisLocker { constructor( private readonly redis: RedisClient, - options: BaseRedisLockerOptions, + private readonly options: BaseRedisLockerOptions, ) { super(options) } + /** + * Creates a locker with `prefix` appended to this locker's prefix, sharing its Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} + */ + prefix(prefix: string): experimental_BunRedisLocker { + // eslint-disable-next-line new-cap + return new experimental_BunRedisLocker(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + } + protected async acquire(key: string, token: string, ttl: number): Promise { const result = await this.redis.send('SET', [key, token, 'NX', 'PX', String(ttl)]) diff --git a/packages/bun/src/redis-publisher.test.ts b/packages/bun/src/redis-publisher.test.ts index 28c44e2653..2cfce44373 100644 --- a/packages/bun/src/redis-publisher.test.ts +++ b/packages/bun/src/redis-publisher.test.ts @@ -81,6 +81,35 @@ describe.skipIf(!REDIS_URL)('bun redis publisher', () => { await unsubscribeResumeAttempt() }, { timeout: 20_000 }) + it('shares the subscriber with publishers created by prefix', async () => { + const publisher = createTestingPublisher() + const tenant = publisher.prefix('tenant:') + const event = crypto.randomUUID() + const listener = vi.fn() + const onError = vi.fn() + + const unsubscribe = await tenant.subscribe(event, listener, { onError }) + + await publisher.publish(`tenant:${event}`, { order: 1 }) + await publisher.prefix('other:').publish(event, { order: 2 }) + await tenant.publish(event, { order: 3 }) + + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + expect(listener).toHaveBeenNthCalledWith(1, { order: 1 }) + expect(listener).toHaveBeenNthCalledWith(2, { order: 3 }) + + const subscriber: RedisClient = await (publisher as any).getSubscriber() + expect(await (tenant as any).getSubscriber()).toBe(subscriber) + + const error = new Error('boom') + subscriber.onclose!.call(subscriber, error) + expect(onError).toHaveBeenCalledWith(error) + + await unsubscribe() + }, { timeout: 20_000 }) + it('resumes missed events in order and preserves event metadata while rewriting ids', async () => { const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 }, diff --git a/packages/bun/src/redis-publisher.ts b/packages/bun/src/redis-publisher.ts index f832f94380..c624031915 100644 --- a/packages/bun/src/redis-publisher.ts +++ b/packages/bun/src/redis-publisher.ts @@ -26,23 +26,38 @@ export interface BunRedisPublisherOptions extends BaseRedisPublisherOptions { * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ export class BunRedisPublisher> extends BaseRedisPublisher { - private readonly getSubscriber: () => Promise + private getSubscriber: () => Promise - private readonly onErrors: Array<(error: ThrowableError) => void> = [] + private onErrors: Array<(error: ThrowableError) => void> = [] constructor( private readonly redis: RedisClient, - { subscriber, ...options }: BunRedisPublisherOptions = {}, + private readonly options: BunRedisPublisherOptions = {}, ) { super(options) this.getSubscriber = once(async () => { - const client = await (subscriber ?? redis.duplicate()) + const client = await (options.subscriber ?? redis.duplicate()) this.watchSubscriber(client) return client }) } + /** + * Creates a publisher with `prefix` appended to this publisher's prefix, sharing its Redis client and subscriber. + * + * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} + */ + prefix(prefix: string): BunRedisPublisher { + const publisher = new BunRedisPublisher(this.redis, { ...this.options, prefix: `${this.eventPrefix}${prefix}` }) + + // Share the lazily created subscriber, which reports its errors to `onErrors` of this publisher. + publisher.getSubscriber = this.getSubscriber + publisher.onErrors = this.onErrors + + return publisher + } + protected async publishMessage(channel: string, message: string): Promise { await this.redis.publish(channel, message) } diff --git a/packages/bun/src/redis-ratelimit.test.ts b/packages/bun/src/redis-ratelimit.test.ts index c9df8d9442..6ef23dc791 100644 --- a/packages/bun/src/redis-ratelimit.test.ts +++ b/packages/bun/src/redis-ratelimit.test.ts @@ -64,6 +64,14 @@ describe.skipIf(!REDIS_URL)('bun redis rate limiter integration', async () => { }) }, { timeout: 20_000 }) + it('shares counters with limiters created by prefix', async () => { + const limiter = await createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) + + await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ success: true, remaining: 0 }) + await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ success: false, remaining: 0 }) + await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) + }, { timeout: 20_000 }) + it('returns a stable reset timestamp within the same window', async () => { const window = 2000 const limiter = await createTestingRateLimiter({ diff --git a/packages/bun/src/redis-ratelimit.ts b/packages/bun/src/redis-ratelimit.ts index c9a17d2ebc..b2d7fbc464 100644 --- a/packages/bun/src/redis-ratelimit.ts +++ b/packages/bun/src/redis-ratelimit.ts @@ -13,11 +13,20 @@ export interface BunRedisRateLimiterOptions extends BaseRedisRateLimiterOptions export class BunRedisRateLimiter extends BaseRedisRateLimiter { constructor( private readonly redis: RedisClient, - options: BunRedisRateLimiterOptions, + private readonly options: BunRedisRateLimiterOptions, ) { super(options) } + /** + * Creates a limiter with `prefix` appended to this limiter's prefix, sharing its Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} + */ + prefix(prefix: string): BunRedisRateLimiter { + return new BunRedisRateLimiter(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + } + protected async evalScript(script: string, keys: string[], args: string[]): Promise { return await this.redis.send('EVAL', [script, String(keys.length), ...keys, ...args]) } diff --git a/packages/cloudflare/src/lock.test.ts b/packages/cloudflare/src/lock.test.ts index d55273cd90..898b62e25b 100644 --- a/packages/cloudflare/src/lock.test.ts +++ b/packages/cloudflare/src/lock.test.ts @@ -93,6 +93,16 @@ describe('durableLocker', () => { await expect(other.lock('key', () => 'ok')).resolves.toBe('ok') }) + it('shares locks with lockers created by prefix', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'tenant:key') + + await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + + await holder.release() + }) + it('tracks locks independently per key', async () => { const { locker } = createTestingLocker() const holder = await hold(locker, 'alice') diff --git a/packages/cloudflare/src/lock.ts b/packages/cloudflare/src/lock.ts index 0045b94a96..45391a99dc 100644 --- a/packages/cloudflare/src/lock.ts +++ b/packages/cloudflare/src/lock.ts @@ -42,25 +42,35 @@ export interface experimental_DurableLockerOptions { * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} */ export class experimental_DurableLocker implements Locker { - private readonly prefix: string + private readonly keyPrefix: string private readonly ttl: number private readonly timeout: number private readonly getStubByName: Exclude constructor( private readonly namespace: DurableObjectNamespace, - options: experimental_DurableLockerOptions, + private readonly options: experimental_DurableLockerOptions, ) { - this.prefix = options.prefix ?? '' + this.keyPrefix = options.prefix ?? '' this.ttl = options.ttl this.timeout = options.timeout ?? 10_000 this.getStubByName = options.getStubByName ?? ((namespace, key) => namespace.getByName(key)) } + /** + * Creates a locker with `prefix` appended to this locker's prefix, sharing its namespace. + * + * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} + */ + prefix(prefix: string): experimental_DurableLocker { + // eslint-disable-next-line new-cap + return new experimental_DurableLocker(this.namespace, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + } + async lock(key: string, fn: (options: LockCallbackOptions) => Promisable, options: LockOptions = {}): Promise { throwIfAborted(options.signal) - const response = await this.getStubByName(this.namespace, `${this.prefix}${key}`).fetch('http://localhost/acquire', { + const response = await this.getStubByName(this.namespace, `${this.keyPrefix}${key}`).fetch('http://localhost/acquire', { headers: { upgrade: 'websocket' }, }) diff --git a/packages/cloudflare/src/publisher.test.ts b/packages/cloudflare/src/publisher.test.ts index 3cb2f9abcc..2af3a2e904 100644 --- a/packages/cloudflare/src/publisher.test.ts +++ b/packages/cloudflare/src/publisher.test.ts @@ -264,6 +264,24 @@ describe('durable publisher', () => { await unsubscribe() }) + it('delivers events to publishers created by prefix', async () => { + const { publisher } = createTestingPublisher(env.PUBLISHER_DON) + const listener = vi.fn() + + const unsubscribe = await publisher.prefix('tenant:').subscribe('message', listener) + + await publisher.publish('tenant:message', { text: 'original' }) + await publisher.prefix('tenant:').publish('message', { text: 'prefixed' }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + expect(listener).toHaveBeenNthCalledWith(1, { text: 'original' }) + expect(listener).toHaveBeenNthCalledWith(2, { text: 'prefixed' }) + + await unsubscribe() + }) + it('throws when publish fails', async () => { const stub = { fetch: vi.fn(async () => new Response(null, { diff --git a/packages/cloudflare/src/publisher.ts b/packages/cloudflare/src/publisher.ts index cdd56f3117..42a9bd05d3 100644 --- a/packages/cloudflare/src/publisher.ts +++ b/packages/cloudflare/src/publisher.ts @@ -35,22 +35,31 @@ export interface DurablePublisherOptions extends PublisherOptions { * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ export class DurablePublisher> extends Publisher { - private readonly prefix: string + private readonly eventPrefix: string private readonly serializer: Public private readonly getStubByName: Exclude constructor( private readonly namespace: DurableObjectNamespace, - { prefix, getStubByName, ...options }: DurablePublisherOptions = {}, + private readonly options: DurablePublisherOptions = {}, ) { super(options) - this.prefix = prefix ?? '' + this.eventPrefix = options.prefix ?? '' this.serializer = options.serializer ?? new RPCJsonSerializer() - this.getStubByName = getStubByName ?? ((namespace, event) => namespace.getByName(event)) + this.getStubByName = options.getStubByName ?? ((namespace, event) => namespace.getByName(event)) + } + + /** + * Creates a publisher with `prefix` appended to this publisher's prefix, sharing its namespace. + * + * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} + */ + prefix(prefix: string): DurablePublisher { + return new DurablePublisher(this.namespace, { ...this.options, prefix: `${this.eventPrefix}${prefix}` }) } async publish(event: K, payload: T[K]): Promise { - const stub = this.getStubByName(this.namespace, this.prefix + event) + const stub = this.getStubByName(this.namespace, this.eventPrefix + event) const [data, meta] = unwrapEvent(payload) const { json, meta: jsonMeta } = this.serializer.serializeJson(data) @@ -75,7 +84,7 @@ export class DurablePublisher> extends Publishe } protected async subscribeListener(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { - const stub = this.getStubByName(this.namespace, this.prefix + event) + const stub = this.getStubByName(this.namespace, this.eventPrefix + event) const headers = new Headers({ upgrade: 'websocket' }) if (options?.lastEventId !== undefined) { diff --git a/packages/cloudflare/src/ratelimit.test.ts b/packages/cloudflare/src/ratelimit.test.ts index c6360f8e35..4ac9f4951e 100644 --- a/packages/cloudflare/src/ratelimit.test.ts +++ b/packages/cloudflare/src/ratelimit.test.ts @@ -72,6 +72,15 @@ describe('cloudflareRateLimiter', () => { expect(limit).toHaveBeenNthCalledWith(3, { key: 'prefix:user:123' }) }) + it('shares the underlying limiter with limiters created by prefix', async () => { + const limit = vi.fn(async () => ({ success: true })) + const limiter = new CloudflareRateLimiter({ limit }, { prefix: 'prefix:' }) + + await limiter.prefix('tenant:').limit('user:123') + + expect(limit).toHaveBeenCalledExactlyOnceWith({ key: 'prefix:tenant:user:123' }) + }) + it('uses the key unmodified when no prefix is configured', async () => { const limit = vi.fn(async () => ({ success: true })) const limiter = new CloudflareRateLimiter({ limit }) diff --git a/packages/cloudflare/src/ratelimit.ts b/packages/cloudflare/src/ratelimit.ts index a0f770355b..59d90a9025 100644 --- a/packages/cloudflare/src/ratelimit.ts +++ b/packages/cloudflare/src/ratelimit.ts @@ -18,17 +18,26 @@ export interface CloudflareRateLimiterOptions { * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} */ export class CloudflareRateLimiter implements RateLimiter { - private readonly prefix: string + private readonly keyPrefix: string constructor( private readonly ratelimit: RateLimit, - options: CloudflareRateLimiterOptions = {}, + private readonly options: CloudflareRateLimiterOptions = {}, ) { - this.prefix = options.prefix ?? '' + this.keyPrefix = options.prefix ?? '' + } + + /** + * Creates a limiter with `prefix` appended to this limiter's prefix, sharing its binding. + * + * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} + */ + prefix(prefix: string): CloudflareRateLimiter { + return new CloudflareRateLimiter(this.ratelimit, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) } async limit(key: string, options: RateLimitOptions = {}): Promise { - key = `${this.prefix}${key}` + key = `${this.keyPrefix}${key}` const weight = this.resolveWeight(options) for (let i = 0; i < weight; i++) { diff --git a/packages/lock/src/adapters/base-redis.ts b/packages/lock/src/adapters/base-redis.ts index d7e70e3e61..4cd7299d9e 100644 --- a/packages/lock/src/adapters/base-redis.ts +++ b/packages/lock/src/adapters/base-redis.ts @@ -58,13 +58,13 @@ export interface BaseRedisLockerOptions { * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} */ export abstract class BaseRedisLocker implements Locker { - protected readonly prefix: string + protected readonly keyPrefix: string protected readonly ttl: number protected readonly timeout: number protected readonly retryInterval: number constructor(options: BaseRedisLockerOptions) { - this.prefix = options.prefix ?? '' + this.keyPrefix = options.prefix ?? '' this.ttl = options.ttl this.timeout = options.timeout ?? 10_000 this.retryInterval = options.retryInterval ?? 100 @@ -83,7 +83,7 @@ export abstract class BaseRedisLocker implements Locker { protected abstract evalScript(script: string, keys: string[], args: string[]): Promise async lock(key: string, fn: (options: LockCallbackOptions) => Promisable, options: LockOptions = {}): Promise { - const prefixedKey = `${this.prefix}${key}` + const prefixedKey = `${this.keyPrefix}${key}` const ttl = options.ttl ?? this.ttl const deadline = Date.now() + (options.timeout ?? this.timeout) const token = crypto.randomUUID() diff --git a/packages/lock/src/adapters/redis.test.ts b/packages/lock/src/adapters/redis.test.ts index 659b0eefb7..9cc30d9d53 100644 --- a/packages/lock/src/adapters/redis.test.ts +++ b/packages/lock/src/adapters/redis.test.ts @@ -93,6 +93,16 @@ describe.concurrent('redis locker integration', { await holder.release() }) + it('shares locks with lockers created by prefix', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'tenant:key') + + await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + + await holder.release() + }) + it('releases the lock when the callback throws', async () => { const { locker } = createTestingLocker() diff --git a/packages/lock/src/adapters/redis.ts b/packages/lock/src/adapters/redis.ts index de82e2d8f7..888ca53c91 100644 --- a/packages/lock/src/adapters/redis.ts +++ b/packages/lock/src/adapters/redis.ts @@ -12,11 +12,20 @@ import { BaseRedisLocker } from './base-redis' export class RedisLocker extends BaseRedisLocker { constructor( private readonly redis: RedisClientType, - options: BaseRedisLockerOptions, + private readonly options: BaseRedisLockerOptions, ) { super(options) } + /** + * Creates a locker with `prefix` appended to this locker's prefix, sharing its Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} + */ + prefix(prefix: string): RedisLocker { + return new RedisLocker(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + } + protected async acquire(key: string, token: string, ttl: number): Promise { await this.connectIfNeeded() diff --git a/packages/lock/src/adapters/upstash.test.ts b/packages/lock/src/adapters/upstash.test.ts index e483c4fb9d..bc100d2924 100644 --- a/packages/lock/src/adapters/upstash.test.ts +++ b/packages/lock/src/adapters/upstash.test.ts @@ -97,6 +97,16 @@ describe.concurrent('upstash locker integration', { await holder.release() }) + it('shares locks with lockers created by prefix', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'tenant:key') + + await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + + await holder.release() + }) + it('releases the lock when the callback throws', async () => { const { locker } = createTestingLocker() diff --git a/packages/lock/src/adapters/upstash.ts b/packages/lock/src/adapters/upstash.ts index 0a34215403..220366978a 100644 --- a/packages/lock/src/adapters/upstash.ts +++ b/packages/lock/src/adapters/upstash.ts @@ -12,11 +12,20 @@ import { BaseRedisLocker } from './base-redis' export class UpstashLocker extends BaseRedisLocker { constructor( private readonly redis: Redis, - options: BaseRedisLockerOptions, + private readonly options: BaseRedisLockerOptions, ) { super(options) } + /** + * Creates a locker with `prefix` appended to this locker's prefix, sharing its Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} + */ + prefix(prefix: string): UpstashLocker { + return new UpstashLocker(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + } + protected async acquire(key: string, token: string, ttl: number): Promise { const result = await this.redis.set(key, token, { nx: true, px: ttl }) diff --git a/packages/publisher/src/adapters/base-redis.ts b/packages/publisher/src/adapters/base-redis.ts index 36f0da1d91..d77b3d4c2e 100644 --- a/packages/publisher/src/adapters/base-redis.ts +++ b/packages/publisher/src/adapters/base-redis.ts @@ -78,7 +78,7 @@ interface SerializedEvent { * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ export abstract class BaseRedisPublisher> extends Publisher { - protected readonly prefix: string + protected readonly eventPrefix: string protected readonly serializer: Public protected readonly resumeEnabled: boolean protected readonly resumeTtl: number @@ -93,7 +93,7 @@ export abstract class BaseRedisPublisher> exten constructor({ prefix, serializer, resume, ...options }: BaseRedisPublisherOptions = {}) { super(options) - this.prefix = prefix ?? '' + this.eventPrefix = prefix ?? '' this.serializer = serializer ?? new RPCJsonSerializer() this.resumeEnabled = resume?.enabled ?? false this.resumeTtl = resume?.ttl ?? 300_000 @@ -133,7 +133,7 @@ export abstract class BaseRedisPublisher> exten protected abstract readStreamEntries(key: string, lastId: string): Promise async publish(event: K, payload: T[K]): Promise { - const channel = `${this.prefix}${event}` + const channel = `${this.eventPrefix}${event}` const data = this.serializePayload(payload) if (!this.resumeEnabled) { @@ -173,7 +173,7 @@ export abstract class BaseRedisPublisher> exten listener: (payload: T[K]) => void, { lastEventId, onError }: PublisherSubscribeListenerOptions = {}, ): Promise<() => Promise> { - const channel = `${this.prefix}${event}` + const channel = `${this.eventPrefix}${event}` let pendingPayloads: T[K][] | undefined = [] const resumedIds = new Set() diff --git a/packages/publisher/src/adapters/redis.test.ts b/packages/publisher/src/adapters/redis.test.ts index f3ecdfd3e1..d863bbacc0 100644 --- a/packages/publisher/src/adapters/redis.test.ts +++ b/packages/publisher/src/adapters/redis.test.ts @@ -87,6 +87,36 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () await unsubscribeResumeAttempt() }) + it('shares the subscriber with publishers created by prefix', async () => { + const publisher = createTestingPublisher() + const tenant = publisher.prefix('tenant:') + const event = crypto.randomUUID() + const listener = vi.fn() + const onError = vi.fn() + + const unsubscribe = await tenant.subscribe(event, listener, { onError }) + + await publisher.publish(`tenant:${event}`, { order: 1 }) + await publisher.prefix('other:').publish(event, { order: 2 }) + await tenant.publish(event, { order: 3 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + expect(listener).toHaveBeenNthCalledWith(1, { order: 1 }) + expect(listener).toHaveBeenNthCalledWith(2, { order: 3 }) + + const subscriber = (publisher as any).subscriber + expect((tenant as any).subscriber).toBe(subscriber) + expect(subscriber.listenerCount('error')).toBe(1) + + const error = new Error('boom') + subscriber.emit('error', error) + expect(onError).toHaveBeenCalledExactlyOnceWith(error) + + await unsubscribe() + }) + it('resumes missed events in order and preserves event metadata while rewriting ids', async () => { const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 }, diff --git a/packages/publisher/src/adapters/redis.ts b/packages/publisher/src/adapters/redis.ts index 32f7eed155..9aa3cdabd2 100644 --- a/packages/publisher/src/adapters/redis.ts +++ b/packages/publisher/src/adapters/redis.ts @@ -14,6 +14,12 @@ export interface RedisPublisherOptions extends BaseRedisPublisherOptions { subscriber?: RedisClientType | undefined } +/** + * The `onError` callbacks of the subscriptions on each subscriber, so publishers sharing + * a subscriber, such as those created by `prefix`, add a single `error` listener to it. + */ +const subscriberOnErrors = new WeakMap void>>() + /** * Publisher adapter for Redis. Distributes events across processes via * Redis Pub/Sub, with optional resume support backed by Redis Streams. @@ -29,11 +35,11 @@ export class RedisPublisher> extends BaseRedisP */ private readonly channelPrefix: string - private readonly onErrors: Array<(error: ThrowableError) => void> = [] + private readonly onErrors: Array<(error: ThrowableError) => void> constructor( private readonly redis: RedisClientType, - { subscriber, ...options }: RedisPublisherOptions = {}, + private readonly options: RedisPublisherOptions = {}, ) { super(options) @@ -43,13 +49,29 @@ export class RedisPublisher> extends BaseRedisP throw new TypeError('RedisPublisher only supports a string keyPrefix on the Redis client.') } - this.subscriber = subscriber ?? redis.duplicate() + this.subscriber = options.subscriber ?? redis.duplicate() this.channelPrefix = keyPrefix ?? '' - this.subscriber.on('error', (error) => { - // Snapshot, since a callback may unsubscribe itself mid-dispatch. - this.onErrors.slice().forEach(onError => onError(error)) - }) + if (!subscriberOnErrors.has(this.subscriber)) { + const onErrors: Array<(error: ThrowableError) => void> = [] + subscriberOnErrors.set(this.subscriber, onErrors) + + this.subscriber.on('error', (error) => { + // Snapshot, since a callback may unsubscribe itself mid-dispatch. + onErrors.slice().forEach(onError => onError(error)) + }) + } + + this.onErrors = subscriberOnErrors.get(this.subscriber)! + } + + /** + * Creates a publisher with `prefix` appended to this publisher's prefix, sharing its Redis client and subscriber. + * + * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} + */ + prefix(prefix: string): RedisPublisher { + return new RedisPublisher(this.redis, { ...this.options, subscriber: this.subscriber, prefix: `${this.eventPrefix}${prefix}` }) } protected async publishMessage(channel: string, message: string): Promise { diff --git a/packages/publisher/src/adapters/upstash.test.ts b/packages/publisher/src/adapters/upstash.test.ts index d7621c4112..6d384dd45c 100644 --- a/packages/publisher/src/adapters/upstash.test.ts +++ b/packages/publisher/src/adapters/upstash.test.ts @@ -104,6 +104,26 @@ describe.concurrent( await unsubscribeResumeAttempt() }) + it('delivers events to publishers created by prefix', async () => { + const publisher = createTestingPublisher() + const event = crypto.randomUUID() + const listener = vi.fn() + + const unsubscribe = await publisher.prefix('tenant:').subscribe(event, listener) + + await publisher.publish(`tenant:${event}`, { order: 1 }) + await publisher.prefix('other:').publish(event, { order: 2 }) + await publisher.prefix('tenant:').publish(event, { order: 3 }) + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + expect(listener).toHaveBeenNthCalledWith(1, { order: 1 }) + expect(listener).toHaveBeenNthCalledWith(2, { order: 3 }) + + await unsubscribe() + }) + it('resumes missed events in order and preserves event metadata while rewriting ids', async () => { const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 }, diff --git a/packages/publisher/src/adapters/upstash.ts b/packages/publisher/src/adapters/upstash.ts index 4f01a7b6c5..44f61951ff 100644 --- a/packages/publisher/src/adapters/upstash.ts +++ b/packages/publisher/src/adapters/upstash.ts @@ -20,11 +20,20 @@ export class UpstashPublisher> extends BaseRedi constructor( private readonly redis: Redis, - options: UpstashPublisherOptions = {}, + private readonly options: UpstashPublisherOptions = {}, ) { super(options) } + /** + * Creates a publisher with `prefix` appended to this publisher's prefix, sharing its Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} + */ + prefix(prefix: string): UpstashPublisher { + return new UpstashPublisher(this.redis, { ...this.options, prefix: `${this.eventPrefix}${prefix}` }) + } + protected async publishMessage(channel: string, message: string): Promise { await this.redis.publish(channel, message) } diff --git a/packages/ratelimit/src/adapters/base-redis.ts b/packages/ratelimit/src/adapters/base-redis.ts index 4c7d658490..dda1b035bb 100644 --- a/packages/ratelimit/src/adapters/base-redis.ts +++ b/packages/ratelimit/src/adapters/base-redis.ts @@ -50,13 +50,13 @@ export interface BaseRedisRateLimiterOptions { * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} */ export abstract class BaseRedisRateLimiter implements RateLimiter { - protected readonly prefix: string + protected readonly keyPrefix: string protected readonly maxRequests: number protected readonly window: number protected readonly timeout: number constructor(options: BaseRedisRateLimiterOptions) { - this.prefix = options.prefix ?? '' + this.keyPrefix = options.prefix ?? '' this.maxRequests = options.maxRequests this.window = options.window this.timeout = options.timeout ?? 0 @@ -68,7 +68,7 @@ export abstract class BaseRedisRateLimiter implements RateLimiter { protected abstract evalScript(script: string, keys: string[], args: string[]): Promise async limit(key: string, options?: RateLimitOptions): Promise> { - key = `${this.prefix}${key}` + key = `${this.keyPrefix}${key}` const weight = resolveWeight(options) return blockUntilReady(() => this.checkLimit(key, weight), this.timeout) diff --git a/packages/ratelimit/src/adapters/redis.test.ts b/packages/ratelimit/src/adapters/redis.test.ts index eb4b6227f7..0531b8df58 100644 --- a/packages/ratelimit/src/adapters/redis.test.ts +++ b/packages/ratelimit/src/adapters/redis.test.ts @@ -64,6 +64,14 @@ describe.concurrent('redis rate limiter integration', { }) }) + it('shares counters with limiters created by prefix', async () => { + const limiter = await createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) + + await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ success: true, remaining: 0 }) + await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ success: false, remaining: 0 }) + await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) + }) + it('returns a stable reset timestamp within the same window', async () => { const window = 2000 const limiter = await createTestingRateLimiter({ diff --git a/packages/ratelimit/src/adapters/redis.ts b/packages/ratelimit/src/adapters/redis.ts index ba529e13e6..0605c75c2d 100644 --- a/packages/ratelimit/src/adapters/redis.ts +++ b/packages/ratelimit/src/adapters/redis.ts @@ -13,11 +13,20 @@ export interface RedisRateLimiterOptions extends BaseRedisRateLimiterOptions {} export class RedisRateLimiter extends BaseRedisRateLimiter { constructor( private readonly redis: RedisClientType, - options: RedisRateLimiterOptions, + private readonly options: RedisRateLimiterOptions, ) { super(options) } + /** + * Creates a limiter with `prefix` appended to this limiter's prefix, sharing its Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} + */ + prefix(prefix: string): RedisRateLimiter { + return new RedisRateLimiter(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + } + protected async evalScript(script: string, keys: string[], args: string[]): Promise { if (!this.redis.isOpen) { await this.redis.connect() diff --git a/packages/ratelimit/src/adapters/upstash.test.ts b/packages/ratelimit/src/adapters/upstash.test.ts index dadfa55c1e..7fa4f78db0 100644 --- a/packages/ratelimit/src/adapters/upstash.test.ts +++ b/packages/ratelimit/src/adapters/upstash.test.ts @@ -57,6 +57,14 @@ describe.concurrent('upstash rate limiter integration', { }) }) + it('shares counters with limiters created by prefix', async () => { + const limiter = createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) + + await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ success: true, remaining: 0 }) + await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ success: false, remaining: 0 }) + await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) + }) + it('returns a stable reset timestamp within the same window', async () => { const window = 2000 const limiter = createTestingRateLimiter({ diff --git a/packages/ratelimit/src/adapters/upstash.ts b/packages/ratelimit/src/adapters/upstash.ts index a763b26fbe..eb7103c029 100644 --- a/packages/ratelimit/src/adapters/upstash.ts +++ b/packages/ratelimit/src/adapters/upstash.ts @@ -13,11 +13,20 @@ export interface UpstashRateLimiterOptions extends BaseRedisRateLimiterOptions { export class UpstashRateLimiter extends BaseRedisRateLimiter { constructor( private readonly redis: Redis, - options: UpstashRateLimiterOptions, + private readonly options: UpstashRateLimiterOptions, ) { super(options) } + /** + * Creates a limiter with `prefix` appended to this limiter's prefix, sharing its Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} + */ + prefix(prefix: string): UpstashRateLimiter { + return new UpstashRateLimiter(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + } + protected async evalScript(script: string, keys: string[], args: string[]): Promise { return await this.redis.eval(script, keys, args) } From c989404179553984150b737021dc7f90f72326c5 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 24 Oct 2026 20:16:08 +0700 Subject: [PATCH 2/3] test: cover Upstash prefix() with a mocked client Upstash integration tests are skipped in CI without credentials, so test prefix() against a fake client instead. --- packages/lock/src/adapters/upstash.test.ts | 18 ++++++------ .../publisher/src/adapters/upstash.test.ts | 28 ++++++------------- .../ratelimit/src/adapters/upstash.test.ts | 16 +++++------ 3 files changed, 24 insertions(+), 38 deletions(-) diff --git a/packages/lock/src/adapters/upstash.test.ts b/packages/lock/src/adapters/upstash.test.ts index bc100d2924..4748f13def 100644 --- a/packages/lock/src/adapters/upstash.test.ts +++ b/packages/lock/src/adapters/upstash.test.ts @@ -97,16 +97,6 @@ describe.concurrent('upstash locker integration', { await holder.release() }) - it('shares locks with lockers created by prefix', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'tenant:key') - - await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') - - await holder.release() - }) - it('releases the lock when the callback throws', async () => { const { locker } = createTestingLocker() @@ -181,3 +171,11 @@ describe.concurrent('upstash locker integration', { expect(count).toBe(5) }) }) + +it('prefix creates a locker that appends the prefix and shares the client', async () => { + const redis = { set: vi.fn(async () => 'OK'), eval: vi.fn(async () => 1) } + const locker = new UpstashLocker(redis as any, { ttl: 1000, prefix: 'app:' }).prefix('tenant:') + + await expect(locker.lock('key', () => 'ok')).resolves.toBe('ok') + expect(redis.set).toHaveBeenCalledWith('app:tenant:key', expect.any(String), { nx: true, px: 1000 }) +}) diff --git a/packages/publisher/src/adapters/upstash.test.ts b/packages/publisher/src/adapters/upstash.test.ts index 6d384dd45c..dc1cecdeca 100644 --- a/packages/publisher/src/adapters/upstash.test.ts +++ b/packages/publisher/src/adapters/upstash.test.ts @@ -104,26 +104,6 @@ describe.concurrent( await unsubscribeResumeAttempt() }) - it('delivers events to publishers created by prefix', async () => { - const publisher = createTestingPublisher() - const event = crypto.randomUUID() - const listener = vi.fn() - - const unsubscribe = await publisher.prefix('tenant:').subscribe(event, listener) - - await publisher.publish(`tenant:${event}`, { order: 1 }) - await publisher.prefix('other:').publish(event, { order: 2 }) - await publisher.prefix('tenant:').publish(event, { order: 3 }) - - await vi.waitFor(() => { - expect(listener).toHaveBeenCalledTimes(2) - }) - expect(listener).toHaveBeenNthCalledWith(1, { order: 1 }) - expect(listener).toHaveBeenNthCalledWith(2, { order: 3 }) - - await unsubscribe() - }) - it('resumes missed events in order and preserves event metadata while rewriting ids', async () => { const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 }, @@ -718,3 +698,11 @@ function withFakeSubscribe(redis: Redis, subscribe: () => unknown): Redis { }, }) } + +it('prefix creates a publisher that appends the prefix and shares the client', async () => { + const redis = { publish: vi.fn(async () => 1) } + const publisher = new UpstashPublisher>(redis as any).prefix('tenant:') + + await publisher.publish('event', { order: 1 }) + expect(redis.publish).toHaveBeenCalledWith('tenant:event', expect.any(String)) +}) diff --git a/packages/ratelimit/src/adapters/upstash.test.ts b/packages/ratelimit/src/adapters/upstash.test.ts index 7fa4f78db0..918bd5bed6 100644 --- a/packages/ratelimit/src/adapters/upstash.test.ts +++ b/packages/ratelimit/src/adapters/upstash.test.ts @@ -57,14 +57,6 @@ describe.concurrent('upstash rate limiter integration', { }) }) - it('shares counters with limiters created by prefix', async () => { - const limiter = createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) - - await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ success: true, remaining: 0 }) - await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ success: false, remaining: 0 }) - await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) - }) - it('returns a stable reset timestamp within the same window', async () => { const window = 2000 const limiter = createTestingRateLimiter({ @@ -211,3 +203,11 @@ describe.concurrent('upstash rate limiter integration', { }) }) }) + +it('prefix creates a limiter that appends the prefix and shares the client', async () => { + const redis = { eval: vi.fn(async () => [1, 1000]) } + const limiter = new UpstashRateLimiter(redis as any, { maxRequests: 1, window: 1000, prefix: 'app:' }).prefix('tenant:') + + await expect(limiter.limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) + expect(redis.eval).toHaveBeenCalledWith(expect.any(String), ['app:tenant:key'], ['1', '1000']) +}) From 16b976da4b62b3fa0466c359febce3334ce6d9b8 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 24 Oct 2026 20:22:54 +0700 Subject: [PATCH 3/3] docs: say Cloudflare prefixed adapters share the namespace or binding --- apps/content/docs/helpers/lock.mdx | 2 +- apps/content/docs/helpers/publisher.mdx | 2 +- apps/content/docs/helpers/ratelimit.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/content/docs/helpers/lock.mdx b/apps/content/docs/helpers/lock.mdx index ad309a3be1..eff37e4498 100644 --- a/apps/content/docs/helpers/lock.mdx +++ b/apps/content/docs/helpers/lock.mdx @@ -326,7 +326,7 @@ export default { getStubByName: (namespace, key) => namespace.getByName(key), }) - // Creates a locker with an extra prefix that shares this client, e.g. per tenant + // Creates a locker with an extra prefix that shares this namespace, e.g. per tenant const tenantLocker = locker.prefix('tenant:123:') }, } diff --git a/apps/content/docs/helpers/publisher.mdx b/apps/content/docs/helpers/publisher.mdx index 2bb1d60407..65b68df12a 100644 --- a/apps/content/docs/helpers/publisher.mdx +++ b/apps/content/docs/helpers/publisher.mdx @@ -341,7 +341,7 @@ export default { getStubByName: (namespace, event) => namespace.getByName(event) }) - // Creates a publisher with an extra prefix that shares this client, e.g. per tenant + // Creates a publisher with an extra prefix that shares this namespace, e.g. per tenant const tenantPublisher = publisher.prefix('tenant:123:') }, } diff --git a/apps/content/docs/helpers/ratelimit.mdx b/apps/content/docs/helpers/ratelimit.mdx index 5e0d2f1d7d..f8f1462cf9 100644 --- a/apps/content/docs/helpers/ratelimit.mdx +++ b/apps/content/docs/helpers/ratelimit.mdx @@ -319,7 +319,7 @@ export default { prefix: '' }) - // Creates a limiter with an extra prefix that shares this client, e.g. per tenant + // Creates a limiter with an extra prefix that shares this binding, e.g. per tenant const tenantLimiter = limiter.prefix('tenant:123:') } }