@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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/package.json +50 -0
- package/src/fakes/ai.ts +113 -0
- package/src/fakes/cache.ts +153 -0
- package/src/fakes/database.ts +612 -0
- package/src/fakes/flags.ts +67 -0
- package/src/fakes/identity.ts +253 -0
- package/src/fakes/logs.ts +101 -0
- package/src/fakes/mail.ts +140 -0
- package/src/fakes/notifications.ts +73 -0
- package/src/fakes/payments.ts +188 -0
- package/src/fakes/queue.ts +205 -0
- package/src/fakes/ratelimit.ts +143 -0
- package/src/fakes/realtime.ts +96 -0
- package/src/fakes/search.ts +127 -0
- package/src/fakes/social.ts +83 -0
- package/src/fakes/storage.ts +125 -0
- package/src/fakes/tokens.ts +106 -0
- package/src/harness.ts +103 -0
- package/src/index.ts +21 -0
- package/src/suites/ai.ts +167 -0
- package/src/suites/cache.ts +174 -0
- package/src/suites/database.ts +853 -0
- package/src/suites/flags.ts +80 -0
- package/src/suites/identity.ts +293 -0
- package/src/suites/index.ts +16 -0
- package/src/suites/logs.ts +116 -0
- package/src/suites/mail.ts +160 -0
- package/src/suites/notifications.ts +119 -0
- package/src/suites/payments.ts +200 -0
- package/src/suites/queue.ts +238 -0
- package/src/suites/ratelimit.ts +134 -0
- package/src/suites/realtime.ts +123 -0
- package/src/suites/search.ts +115 -0
- package/src/suites/social.ts +129 -0
- package/src/suites/storage.ts +158 -0
- package/src/suites/tokens.ts +105 -0
|
@@ -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
|
+
}
|