@avelonjs/conformance 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.
@@ -0,0 +1,188 @@
1
+ import {
2
+ Invalid,
3
+ NotFound,
4
+ Unauthenticated,
5
+ type CheckoutPaymentSurface,
6
+ type CheckoutSession,
7
+ type PaymentCustomer,
8
+ type PaymentDriver,
9
+ type PaymentSubscription,
10
+ type PaymentWebhook,
11
+ type SubscriptionPaymentSurface,
12
+ type WebhookPaymentSurface,
13
+ } from '@avelonjs/core'
14
+
15
+ const capabilities = {
16
+ subscriptions: true,
17
+ checkout: true,
18
+ webhooks: true,
19
+ } as const
20
+
21
+ interface FakePaymentsRaw {
22
+ readonly customers: number
23
+ readonly subscriptions: number
24
+ readonly checkouts: number
25
+ }
26
+
27
+ interface EncodedWebhook {
28
+ id: string
29
+ type: string
30
+ data: unknown
31
+ occurredAt: string
32
+ }
33
+
34
+ function isEncodedWebhook(value: unknown): value is EncodedWebhook {
35
+ if (typeof value !== 'object' || value === null) return false
36
+ return (
37
+ typeof Reflect.get(value, 'id') === 'string' &&
38
+ typeof Reflect.get(value, 'type') === 'string' &&
39
+ typeof Reflect.get(value, 'occurredAt') === 'string' &&
40
+ 'data' in value
41
+ )
42
+ }
43
+
44
+ /** In-memory payments reference implementation used by conformance and application tests. */
45
+ export class FakePayments
46
+ implements
47
+ PaymentDriver<typeof capabilities, FakePaymentsRaw>,
48
+ SubscriptionPaymentSurface,
49
+ CheckoutPaymentSurface,
50
+ WebhookPaymentSurface
51
+ {
52
+ /** Driver implementation name. */
53
+ readonly name = 'fake'
54
+
55
+ /** Configured payments connection name. */
56
+ readonly instance: string
57
+
58
+ /** Exact optional-feature declaration. */
59
+ readonly capabilities = capabilities
60
+
61
+ readonly #customers = new Map<string, PaymentCustomer>()
62
+ readonly #subscriptions = new Map<string, PaymentSubscription>()
63
+ readonly #checkouts = new Map<string, CheckoutSession>()
64
+ #nextCustomer = 1
65
+ #nextSubscription = 1
66
+ #nextCheckout = 1
67
+
68
+ /** Creates an isolated payments connection. */
69
+ constructor(instance = 'default') {
70
+ this.instance = instance
71
+ }
72
+
73
+ /** Returns observable resource counts. */
74
+ raw(): FakePaymentsRaw {
75
+ return {
76
+ customers: this.#customers.size,
77
+ subscriptions: this.#subscriptions.size,
78
+ checkouts: this.#checkouts.size,
79
+ }
80
+ }
81
+
82
+ /** Creates a customer while retaining application metadata. */
83
+ async createCustomer(input: {
84
+ readonly email?: string
85
+ readonly metadata?: Readonly<Record<string, string>>
86
+ }): Promise<PaymentCustomer> {
87
+ const customer: PaymentCustomer = {
88
+ id: `customer-${this.#nextCustomer++}`,
89
+ ...(input.email === undefined ? {} : { email: input.email }),
90
+ metadata: { ...input.metadata },
91
+ }
92
+ this.#customers.set(customer.id, customer)
93
+ return customer
94
+ }
95
+
96
+ /** Retrieves a customer or raises the normalized missing-resource error. */
97
+ async customer(id: string): Promise<PaymentCustomer> {
98
+ const customer = this.#customers.get(id)
99
+ if (!customer) {
100
+ throw new NotFound(`Payment customer ${id} was not found.`, {
101
+ metadata: { resource: 'payment-customer', identifier: id },
102
+ })
103
+ }
104
+ return customer
105
+ }
106
+
107
+ /** Creates an active recurring subscription. */
108
+ async subscribe(customerId: string, priceId: string): Promise<PaymentSubscription> {
109
+ await this.customer(customerId)
110
+ const subscription: PaymentSubscription = {
111
+ id: `subscription-${this.#nextSubscription++}`,
112
+ customerId,
113
+ priceId,
114
+ status: 'active',
115
+ currentPeriodEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
116
+ }
117
+ this.#subscriptions.set(subscription.id, subscription)
118
+ return subscription
119
+ }
120
+
121
+ /** Retrieves a recurring subscription. */
122
+ async subscription(id: string): Promise<PaymentSubscription> {
123
+ const subscription = this.#subscriptions.get(id)
124
+ if (!subscription) {
125
+ throw new NotFound(`Payment subscription ${id} was not found.`, {
126
+ metadata: { resource: 'payment-subscription', identifier: id },
127
+ })
128
+ }
129
+ return subscription
130
+ }
131
+
132
+ /** Cancels and returns a recurring subscription. */
133
+ async cancelSubscription(id: string): Promise<PaymentSubscription> {
134
+ const subscription = await this.subscription(id)
135
+ const canceled = { ...subscription, status: 'canceled' as const }
136
+ this.#subscriptions.set(id, canceled)
137
+ return canceled
138
+ }
139
+
140
+ /** Creates a hosted checkout session for a known or anonymous customer. */
141
+ async checkout(input: {
142
+ readonly customerId?: string
143
+ readonly priceId: string
144
+ readonly successUrl: string
145
+ readonly cancelUrl: string
146
+ }): Promise<CheckoutSession> {
147
+ if (input.customerId) await this.customer(input.customerId)
148
+ const id = `checkout-${this.#nextCheckout++}`
149
+ const session: CheckoutSession = {
150
+ id,
151
+ url: `https://checkout.example.test/${id}?price=${encodeURIComponent(input.priceId)}`,
152
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000),
153
+ }
154
+ this.#checkouts.set(id, session)
155
+ return session
156
+ }
157
+
158
+ /** Verifies the assay signature and normalizes an encoded webhook event. */
159
+ async webhook(payload: Uint8Array, signature: string): Promise<PaymentWebhook> {
160
+ if (signature !== 'assay-signature') {
161
+ throw new Unauthenticated('The payment webhook signature is invalid.', {
162
+ metadata: { guard: 'payment-webhook' },
163
+ })
164
+ }
165
+
166
+ let decoded: unknown
167
+ try {
168
+ decoded = JSON.parse(new TextDecoder().decode(payload))
169
+ } catch (cause: unknown) {
170
+ throw new Invalid('The payment webhook payload is malformed.', {
171
+ metadata: { fields: { payload: ['Payload must be valid JSON.'] } },
172
+ cause,
173
+ })
174
+ }
175
+ if (!isEncodedWebhook(decoded) || Number.isNaN(Date.parse(decoded.occurredAt))) {
176
+ throw new Invalid('The payment webhook payload is malformed.', {
177
+ metadata: { fields: { payload: ['Payload does not contain a normalized event.'] } },
178
+ })
179
+ }
180
+
181
+ return {
182
+ id: decoded.id,
183
+ type: decoded.type,
184
+ data: decoded.data,
185
+ occurredAt: new Date(decoded.occurredAt),
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,205 @@
1
+ import type {
2
+ DeadLetterQueueSurface,
3
+ DelayedQueueSurface,
4
+ FailedQueueJob,
5
+ QueueCapabilities,
6
+ QueueDriver,
7
+ QueueJob,
8
+ QueueReceipt,
9
+ RetryQueueSurface,
10
+ } from '@avelonjs/core'
11
+
12
+ const capabilities = {
13
+ delayed: true,
14
+ retries: true,
15
+ deadLetter: true,
16
+ } as const
17
+
18
+ const noRetryCapabilities = {
19
+ delayed: false,
20
+ retries: false,
21
+ deadLetter: false,
22
+ } as const
23
+
24
+ interface StoredQueueJob {
25
+ id: string
26
+ job: QueueJob
27
+ attempt: number
28
+ availableAt: number
29
+ }
30
+
31
+ interface FakeQueueRaw {
32
+ pending: number
33
+ failed: number
34
+ }
35
+
36
+ function failureMessage(error: unknown): string {
37
+ return error instanceof Error ? error.message : String(error)
38
+ }
39
+
40
+ abstract class FakeQueueState<TCapabilities extends QueueCapabilities> implements QueueDriver<
41
+ TCapabilities,
42
+ FakeQueueRaw
43
+ > {
44
+ /** Driver implementation name. */
45
+ readonly name = 'fake'
46
+
47
+ /** Configured queue connection name. */
48
+ readonly instance: string
49
+
50
+ /** Exact optional-feature declaration. */
51
+ readonly capabilities: TCapabilities
52
+
53
+ readonly #jobs = new Map<string, StoredQueueJob>()
54
+ readonly #failures = new Map<string, FailedQueueJob>()
55
+ readonly #maxTries: number
56
+ #nextId = 1
57
+
58
+ protected constructor(instance: string, declared: TCapabilities, maxTries: number) {
59
+ this.instance = instance
60
+ this.capabilities = declared
61
+ this.#maxTries = maxTries
62
+ }
63
+
64
+ /** Returns observable pending and failed job counts. */
65
+ raw(): FakeQueueRaw {
66
+ return { pending: this.#jobs.size, failed: this.#failures.size }
67
+ }
68
+
69
+ /** Enqueues a job for immediate delivery. */
70
+ async enqueue<TPayload>(job: QueueJob<TPayload>): Promise<string> {
71
+ return this.enqueueFor(job, Date.now())
72
+ }
73
+
74
+ /** Delivers each currently available job at most once during this drain call. */
75
+ async drain(
76
+ handler: (receipt: QueueReceipt) => Promise<void>,
77
+ options?: { readonly queue?: string; readonly limit?: number },
78
+ ): Promise<number> {
79
+ const available = [...this.#jobs.values()]
80
+ .filter(
81
+ (entry) =>
82
+ entry.availableAt <= Date.now() &&
83
+ (options?.queue === undefined || entry.job.queue === options.queue),
84
+ )
85
+ .slice(0, options?.limit)
86
+
87
+ for (const entry of available) {
88
+ entry.attempt += 1
89
+ const receipt = this.receipt(entry)
90
+ try {
91
+ await handler(receipt)
92
+ this.#jobs.delete(entry.id)
93
+ } catch (error: unknown) {
94
+ if (this.capabilities.retries && entry.attempt < this.#maxTries) continue
95
+ if (!this.capabilities.retries && this.redeliverFailuresWithoutRetries()) continue
96
+
97
+ if (this.capabilities.deadLetter) {
98
+ this.#failures.set(entry.id, {
99
+ ...receipt,
100
+ error: failureMessage(error),
101
+ failedAt: new Date(),
102
+ })
103
+ }
104
+ this.#jobs.delete(entry.id)
105
+ }
106
+ }
107
+
108
+ // A failed delivery still consumed worker capacity, so drain counts attempts rather than acks.
109
+ return available.length
110
+ }
111
+
112
+ protected enqueueFor<TPayload>(job: QueueJob<TPayload>, availableAt: number): string {
113
+ const id = `job-${this.#nextId++}`
114
+ this.#jobs.set(id, {
115
+ id,
116
+ job: { name: job.name, payload: job.payload, ...(job.queue ? { queue: job.queue } : {}) },
117
+ attempt: 0,
118
+ availableAt,
119
+ })
120
+ return id
121
+ }
122
+
123
+ protected release(id: string, delaySeconds = 0): void {
124
+ const job = this.#jobs.get(id)
125
+ if (job) job.availableAt = Date.now() + delaySeconds * 1000
126
+ }
127
+
128
+ protected failures(queue?: string): readonly FailedQueueJob[] {
129
+ return [...this.#failures.values()]
130
+ .filter((entry) => queue === undefined || entry.job.queue === queue)
131
+ .map((entry) => ({
132
+ ...entry,
133
+ job: { ...entry.job },
134
+ failedAt: new Date(entry.failedAt),
135
+ }))
136
+ }
137
+
138
+ protected replayFailure(id: string): void {
139
+ const failure = this.#failures.get(id)
140
+ if (!failure) return
141
+ this.#failures.delete(id)
142
+ this.#jobs.set(id, {
143
+ id,
144
+ job: { ...failure.job },
145
+ attempt: 0,
146
+ availableAt: Date.now(),
147
+ })
148
+ }
149
+
150
+ protected forgetFailure(id: string): void {
151
+ this.#failures.delete(id)
152
+ }
153
+
154
+ protected redeliverFailuresWithoutRetries(): boolean {
155
+ return false
156
+ }
157
+
158
+ private receipt(entry: StoredQueueJob): QueueReceipt {
159
+ return { id: entry.id, job: { ...entry.job }, attempt: entry.attempt }
160
+ }
161
+ }
162
+
163
+ /** In-memory retry-capable queue reference implementation. */
164
+ export class FakeQueue
165
+ extends FakeQueueState<typeof capabilities>
166
+ implements DelayedQueueSurface, RetryQueueSurface, DeadLetterQueueSurface
167
+ {
168
+ /** Creates an isolated queue with a deterministic terminal-attempt limit. */
169
+ constructor(instance = 'default', maxTries = 3) {
170
+ super(instance, capabilities, maxTries)
171
+ }
172
+
173
+ /** Enqueues a job no earlier than the supplied time. */
174
+ async enqueueAt<TPayload>(job: QueueJob<TPayload>, availableAt: Date): Promise<string> {
175
+ return this.enqueueFor(job, availableAt.getTime())
176
+ }
177
+
178
+ /** Releases a failed active job, optionally after a delay. */
179
+ async retry(id: string, delaySeconds?: number): Promise<void> {
180
+ this.release(id, delaySeconds)
181
+ }
182
+
183
+ /** Lists retained terminal failures. */
184
+ async failed(queue?: string): Promise<readonly FailedQueueJob[]> {
185
+ return this.failures(queue)
186
+ }
187
+
188
+ /** Replays a terminal failure with a fresh attempt budget. */
189
+ async replay(id: string): Promise<void> {
190
+ this.replayFailure(id)
191
+ }
192
+
193
+ /** Permanently removes a terminal failure. */
194
+ async forget(id: string): Promise<void> {
195
+ this.forgetFailure(id)
196
+ }
197
+ }
198
+
199
+ /** Retry-disabled queue fixture used to prove the contract's pinned throw behavior. */
200
+ export class FakeQueueWithoutRetries extends FakeQueueState<typeof noRetryCapabilities> {
201
+ /** Creates an isolated at-most-once queue. */
202
+ constructor(instance = 'default') {
203
+ super(instance, noRetryCapabilities, 1)
204
+ }
205
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ Invalid,
3
+ type RateLimitAlgorithm,
4
+ type RateLimitCapabilities,
5
+ type RateLimitDecision,
6
+ type RateLimitDriver,
7
+ type RateLimitPolicy,
8
+ } from '@avelonjs/core'
9
+
10
+ interface TokenBucketState {
11
+ tokens: number
12
+ updatedAt: number
13
+ }
14
+
15
+ interface SlidingWindowEntry {
16
+ readonly at: number
17
+ readonly cost: number
18
+ }
19
+
20
+ interface FakeRateLimitRaw {
21
+ readonly keys: number
22
+ }
23
+
24
+ class FakeRateLimitBase<TAlgorithm extends RateLimitAlgorithm> implements RateLimitDriver<
25
+ RateLimitCapabilities<TAlgorithm>,
26
+ FakeRateLimitRaw
27
+ > {
28
+ readonly name = 'fake'
29
+ readonly instance: string
30
+ readonly capabilities: RateLimitCapabilities<TAlgorithm>
31
+ readonly #tokenBuckets = new Map<string, TokenBucketState>()
32
+ readonly #slidingWindows = new Map<string, SlidingWindowEntry[]>()
33
+
34
+ constructor(algorithms: readonly TAlgorithm[], instance = 'default') {
35
+ this.capabilities = { algorithms }
36
+ this.instance = instance
37
+ }
38
+
39
+ raw(): FakeRateLimitRaw {
40
+ return { keys: this.#tokenBuckets.size + this.#slidingWindows.size }
41
+ }
42
+
43
+ async consume(key: string, policy: RateLimitPolicy, cost = 1): Promise<RateLimitDecision> {
44
+ this.validate(policy, cost)
45
+ return policy.algorithm === 'tokenBucket'
46
+ ? this.consumeTokenBucket(key, policy, cost)
47
+ : this.consumeSlidingWindow(key, policy, cost)
48
+ }
49
+
50
+ async reset(key: string): Promise<void> {
51
+ this.#tokenBuckets.delete(key)
52
+ this.#slidingWindows.delete(key)
53
+ }
54
+
55
+ private validate(policy: RateLimitPolicy, cost: number): void {
56
+ const fields: Record<string, readonly string[]> = {}
57
+ if (!this.capabilities.algorithms.some((algorithm) => algorithm === policy.algorithm)) {
58
+ fields.algorithm = [`${policy.algorithm} is not declared by this driver.`]
59
+ }
60
+ if (!Number.isInteger(policy.limit) || policy.limit <= 0) {
61
+ fields.limit = ['Limit must be a positive integer.']
62
+ }
63
+ if (!Number.isFinite(policy.intervalSeconds) || policy.intervalSeconds <= 0) {
64
+ fields.intervalSeconds = ['Interval must be greater than zero.']
65
+ }
66
+ if (!Number.isFinite(cost) || cost <= 0) fields.cost = ['Cost must be greater than zero.']
67
+ if (Object.keys(fields).length > 0) {
68
+ throw new Invalid('Invalid rate-limit policy.', { metadata: { fields } })
69
+ }
70
+ }
71
+
72
+ private consumeTokenBucket(
73
+ key: string,
74
+ policy: RateLimitPolicy,
75
+ cost: number,
76
+ ): RateLimitDecision {
77
+ const now = Date.now()
78
+ const intervalMs = policy.intervalSeconds * 1000
79
+ const refillPerMs = policy.limit / intervalMs
80
+ const state = this.#tokenBuckets.get(key) ?? { tokens: policy.limit, updatedAt: now }
81
+ state.tokens = Math.min(policy.limit, state.tokens + (now - state.updatedAt) * refillPerMs)
82
+ state.updatedAt = now
83
+ const allowed = state.tokens >= cost
84
+ if (allowed) state.tokens -= cost
85
+ this.#tokenBuckets.set(key, state)
86
+ const resetsAt = now + Math.ceil((policy.limit - state.tokens) / refillPerMs)
87
+ const retryAfterMs = allowed ? undefined : Math.ceil((cost - state.tokens) / refillPerMs)
88
+ return {
89
+ allowed,
90
+ remaining: Math.max(0, Math.floor(state.tokens)),
91
+ resetsAt,
92
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
93
+ }
94
+ }
95
+
96
+ private consumeSlidingWindow(
97
+ key: string,
98
+ policy: RateLimitPolicy,
99
+ cost: number,
100
+ ): RateLimitDecision {
101
+ const now = Date.now()
102
+ const intervalMs = policy.intervalSeconds * 1000
103
+ const entries = (this.#slidingWindows.get(key) ?? []).filter(
104
+ (entry) => entry.at + intervalMs > now,
105
+ )
106
+ const used = entries.reduce((total, entry) => total + entry.cost, 0)
107
+ const allowed = used + cost <= policy.limit
108
+ if (allowed) entries.push({ at: now, cost })
109
+ this.#slidingWindows.set(key, entries)
110
+ const consumed = allowed ? used + cost : used
111
+ const resetsAt =
112
+ entries.length === 0 ? now : Math.max(...entries.map((entry) => entry.at)) + intervalMs
113
+ const retryAfterMs = allowed
114
+ ? undefined
115
+ : Math.max(0, (entries[0]?.at ?? now) + intervalMs - now)
116
+ return {
117
+ allowed,
118
+ remaining: Math.max(0, Math.floor(policy.limit - consumed)),
119
+ resetsAt,
120
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
121
+ }
122
+ }
123
+ }
124
+
125
+ const allAlgorithms = ['tokenBucket', 'slidingWindow'] as const
126
+
127
+ /** In-memory rate-limit reference implementation supporting both portable algorithms. */
128
+ export class FakeRateLimit extends FakeRateLimitBase<(typeof allAlgorithms)[number]> {
129
+ /** Creates an isolated rate limiter with both algorithms enabled. */
130
+ constructor(instance = 'default') {
131
+ super(allAlgorithms, instance)
132
+ }
133
+ }
134
+
135
+ const noAlgorithms = [] as const
136
+
137
+ /** In-memory rate-limit reference implementation with every algorithm disabled. */
138
+ export class FakeRateLimitWithoutAlgorithms extends FakeRateLimitBase<never> {
139
+ /** Creates an isolated rate limiter with no configured algorithms. */
140
+ constructor(instance = 'default') {
141
+ super(noAlgorithms, instance)
142
+ }
143
+ }
@@ -0,0 +1,96 @@
1
+ import {
2
+ type BroadcastRealtimeSurface,
3
+ type PresenceRealtimeSurface,
4
+ type RealtimeDriver,
5
+ type RealtimeMessage,
6
+ type RealtimeSubscription,
7
+ } from '@avelonjs/core'
8
+
9
+ const capabilities = {
10
+ presence: true,
11
+ broadcast: true,
12
+ } as const
13
+
14
+ type StoredHandler = (message: RealtimeMessage) => void | Promise<void>
15
+
16
+ interface FakeRealtimeRaw {
17
+ readonly channels: number
18
+ readonly subscriptions: number
19
+ }
20
+
21
+ /** In-memory realtime reference implementation used by conformance and application tests. */
22
+ export class FakeRealtime
23
+ implements
24
+ RealtimeDriver<typeof capabilities, FakeRealtimeRaw>,
25
+ PresenceRealtimeSurface,
26
+ BroadcastRealtimeSurface
27
+ {
28
+ /** Driver implementation name. */
29
+ readonly name = 'fake'
30
+
31
+ /** Configured realtime connection name. */
32
+ readonly instance: string
33
+
34
+ /** Exact optional-feature declaration. */
35
+ readonly capabilities = capabilities
36
+
37
+ readonly #subscriptions = new Map<string, Set<StoredHandler>>()
38
+ readonly #presence = new Map<string, Readonly<Record<string, unknown>>>()
39
+
40
+ /** Creates an isolated realtime connection. */
41
+ constructor(instance = 'default') {
42
+ this.instance = instance
43
+ }
44
+
45
+ /** Returns observable channel and subscription counts. */
46
+ raw(): FakeRealtimeRaw {
47
+ let subscriptions = 0
48
+ for (const handlers of this.#subscriptions.values()) subscriptions += handlers.size
49
+ return { channels: this.#subscriptions.size, subscriptions }
50
+ }
51
+
52
+ /** Subscribes a handler to one named channel. */
53
+ async subscribe<TPayload = unknown>(
54
+ channel: string,
55
+ handler: (message: RealtimeMessage<TPayload>) => void | Promise<void>,
56
+ ): Promise<RealtimeSubscription> {
57
+ const stored: StoredHandler = (message) =>
58
+ handler({ ...message, payload: message.payload as TPayload })
59
+ const handlers = this.#subscriptions.get(channel) ?? new Set<StoredHandler>()
60
+ handlers.add(stored)
61
+ this.#subscriptions.set(channel, handlers)
62
+ let active = true
63
+
64
+ return {
65
+ channel,
66
+ unsubscribe: async () => {
67
+ if (!active) return
68
+ active = false
69
+ handlers.delete(stored)
70
+ if (handlers.size === 0) this.#subscriptions.delete(channel)
71
+ },
72
+ }
73
+ }
74
+
75
+ /** Broadcasts an event only to subscribers of the named channel. */
76
+ async broadcast<TPayload>(channel: string, event: string, payload: TPayload): Promise<void> {
77
+ const handlers = [...(this.#subscriptions.get(channel) ?? [])]
78
+ for (const handler of handlers) await handler({ channel, event, payload })
79
+ }
80
+
81
+ /** Joins presence for this driver instance on one channel. */
82
+ async joinPresence(channel: string, member: Readonly<Record<string, unknown>>): Promise<void> {
83
+ this.#presence.set(channel, member)
84
+ }
85
+
86
+ /** Leaves presence for this driver instance on one channel. */
87
+ async leavePresence(channel: string): Promise<void> {
88
+ this.#presence.delete(channel)
89
+ }
90
+
91
+ /** Lists the current member for this isolated driver instance. */
92
+ async presence(channel: string): Promise<readonly Readonly<Record<string, unknown>>[]> {
93
+ const member = this.#presence.get(channel)
94
+ return member ? [member] : []
95
+ }
96
+ }