better-convex 0.7.2 → 0.8.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/dist/aggregate/index.d.ts +1 -1
- package/dist/aggregate/index.js +1 -1
- package/dist/auth/http/index.d.ts +1 -1
- package/dist/auth/index.d.ts +10 -10
- package/dist/auth/index.js +5 -4
- package/dist/auth/nextjs/index.d.ts +2 -2
- package/dist/auth/nextjs/index.js +2 -2
- package/dist/{caller-factory-D3OuR1eI.js → caller-factory-CCsm4Dut.js} +2 -2
- package/dist/cli.mjs +414 -5
- package/dist/{codegen-Cz1idI3-.mjs → codegen-BS36cYTH.mjs} +88 -5
- package/dist/{create-schema-orm-69VF4CFV.js → create-schema-orm-OcyA0apQ.js} +10 -13
- package/dist/crpc/index.d.ts +2 -2
- package/dist/crpc/index.js +3 -3
- package/dist/customFunctions-RnzME_cJ.js +167 -0
- package/dist/{http-types-BCf2wCgp.d.ts → http-types-BK7FuIcR.d.ts} +1 -1
- package/dist/id-BcBb900m.js +121 -0
- package/dist/orm/index.d.ts +4 -3
- package/dist/orm/index.js +706 -165
- package/dist/plugins/index.d.ts +9 -0
- package/dist/plugins/index.js +3 -0
- package/dist/plugins/ratelimit/index.d.ts +222 -0
- package/dist/plugins/ratelimit/index.js +846 -0
- package/dist/plugins/ratelimit/react/index.d.ts +76 -0
- package/dist/plugins/ratelimit/react/index.js +294 -0
- package/dist/{procedure-caller-CcjtUFvL.d.ts → procedure-caller-DYjpq7rG.d.ts} +4 -19
- package/dist/rsc/index.d.ts +3 -3
- package/dist/rsc/index.js +4 -4
- package/dist/runtime-C0WcYGY0.js +1028 -0
- package/dist/schema-Bx6j2doh.js +204 -0
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +4 -3
- package/dist/{runtime-B9xQFY8W.js → table-B7yzBihE.js} +3 -1088
- package/dist/text-enum-CFdcLUuw.js +30 -0
- package/dist/{types-CIBGEYXq.d.ts → types-f53SgpBL.d.ts} +1 -1
- package/dist/validators-BcQFm1oY.d.ts +88 -0
- package/dist/{customFunctions-CZnCwoR3.js → validators-D_i3BK7v.js} +67 -165
- package/dist/watcher.mjs +1 -1
- package/dist/{where-clause-compiler-CRP-i1Qa.d.ts → where-clause-compiler-BIjTkVVJ.d.ts} +138 -2
- package/package.json +4 -1
- /package/dist/{create-schema-BdZOL6ns.js → create-schema-BsN0jL5S.js} +0 -0
- /package/dist/{error-Be4OcwwD.js → error-CAGGSN5H.js} +0 -0
- /package/dist/{meta-utils-DDVYp9Xf.js → meta-utils-NRyocOSc.js} +0 -0
- /package/dist/{query-context-BDSis9rT.js → query-context-DEUFBhXS.js} +0 -0
- /package/dist/{query-context-DGExXZIV.d.ts → query-context-ji7By8u0.d.ts} +0 -0
- /package/dist/{query-options-B0c1b6pZ.js → query-options-CSCmKYdJ.js} +0 -0
- /package/dist/{transformer-Dh0w2py0.js → transformer-ogg-4d78.js} +0 -0
- /package/dist/{types-DwGkkq2s.d.ts → types-BTb_4BaU.d.ts} +0 -0
- /package/dist/{types-DgwvxKbT.d.ts → types-CM67ko7K.d.ts} +0 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { C as integer, g as index, t as convexTable, x as text } from "../../table-B7yzBihE.js";
|
|
2
|
+
import { t as textEnum } from "../../text-enum-CFdcLUuw.js";
|
|
3
|
+
import { v } from "convex/values";
|
|
4
|
+
import { mutationGeneric, queryGeneric } from "convex/server";
|
|
5
|
+
|
|
6
|
+
//#region src/plugins/ratelimit/duration.ts
|
|
7
|
+
const UNIT_TO_MS = {
|
|
8
|
+
ms: 1,
|
|
9
|
+
s: 1e3,
|
|
10
|
+
m: 6e4,
|
|
11
|
+
h: 36e5,
|
|
12
|
+
d: 864e5
|
|
13
|
+
};
|
|
14
|
+
const DURATION_REGEX = /^(\d+(?:\.\d+)?)\s?(ms|s|m|h|d)$/;
|
|
15
|
+
function toMs(duration) {
|
|
16
|
+
if (typeof duration === "number") {
|
|
17
|
+
if (!Number.isFinite(duration) || duration <= 0) throw new Error(`Invalid duration: ${duration}`);
|
|
18
|
+
return duration;
|
|
19
|
+
}
|
|
20
|
+
const match = duration.trim().match(DURATION_REGEX);
|
|
21
|
+
if (!match) throw new Error(`Unable to parse duration: ${duration}`);
|
|
22
|
+
const milliseconds = Number.parseFloat(match[1]) * UNIT_TO_MS[match[2]];
|
|
23
|
+
if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error(`Invalid duration: ${duration}`);
|
|
24
|
+
return milliseconds;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/plugins/ratelimit/core/algorithms.ts
|
|
29
|
+
const DEFAULT_SHARDS = 1;
|
|
30
|
+
function fixedWindow(limit, window, options) {
|
|
31
|
+
validatePositive(limit, "limit");
|
|
32
|
+
const shards = normalizeShards(options?.shards);
|
|
33
|
+
const capacity = options?.capacity ?? limit;
|
|
34
|
+
validatePositive(capacity, "capacity");
|
|
35
|
+
return {
|
|
36
|
+
kind: "fixedWindow",
|
|
37
|
+
limit,
|
|
38
|
+
window: toMs(window),
|
|
39
|
+
capacity,
|
|
40
|
+
maxReserved: options?.maxReserved,
|
|
41
|
+
start: options?.start,
|
|
42
|
+
shards
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function slidingWindow(limit, window, options) {
|
|
46
|
+
validatePositive(limit, "limit");
|
|
47
|
+
return {
|
|
48
|
+
kind: "slidingWindow",
|
|
49
|
+
limit,
|
|
50
|
+
window: toMs(window),
|
|
51
|
+
maxReserved: options?.maxReserved,
|
|
52
|
+
shards: normalizeShards(options?.shards)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function tokenBucket(refillRate, interval, maxTokens, options) {
|
|
56
|
+
validatePositive(refillRate, "refillRate");
|
|
57
|
+
validatePositive(maxTokens, "maxTokens");
|
|
58
|
+
return {
|
|
59
|
+
kind: "tokenBucket",
|
|
60
|
+
refillRate,
|
|
61
|
+
interval: toMs(interval),
|
|
62
|
+
maxTokens,
|
|
63
|
+
maxReserved: options?.maxReserved,
|
|
64
|
+
shards: normalizeShards(options?.shards)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function applyDynamicLimit(algorithm, dynamicLimit) {
|
|
68
|
+
if (!dynamicLimit || dynamicLimit <= 0) return algorithm;
|
|
69
|
+
if (algorithm.kind === "tokenBucket") return {
|
|
70
|
+
...algorithm,
|
|
71
|
+
refillRate: dynamicLimit,
|
|
72
|
+
maxTokens: algorithm.maxTokens === algorithm.refillRate ? dynamicLimit : algorithm.maxTokens
|
|
73
|
+
};
|
|
74
|
+
if (algorithm.kind === "fixedWindow") return {
|
|
75
|
+
...algorithm,
|
|
76
|
+
limit: dynamicLimit,
|
|
77
|
+
capacity: algorithm.capacity === algorithm.limit ? dynamicLimit : algorithm.capacity
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
...algorithm,
|
|
81
|
+
limit: dynamicLimit
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function normalizeShards(shards) {
|
|
85
|
+
if (shards === void 0) return DEFAULT_SHARDS;
|
|
86
|
+
const rounded = Math.round(shards);
|
|
87
|
+
if (rounded < 1) throw new Error("shards must be >= 1");
|
|
88
|
+
return rounded;
|
|
89
|
+
}
|
|
90
|
+
function validatePositive(value, field) {
|
|
91
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${field} must be a positive number`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/plugins/ratelimit/core/calculate-rate-limit.ts
|
|
96
|
+
function calculateRateLimit(state, algorithm, now, count) {
|
|
97
|
+
if (algorithm.kind === "fixedWindow") return calculateFixedWindow(state, algorithm, now, count);
|
|
98
|
+
if (algorithm.kind === "tokenBucket") return calculateTokenBucket(state, algorithm, now, count);
|
|
99
|
+
return calculateSlidingWindow(state, algorithm, now, count);
|
|
100
|
+
}
|
|
101
|
+
function calculateTokenBucket(state, config, now, count) {
|
|
102
|
+
const ratePerMs = config.refillRate / config.interval;
|
|
103
|
+
const initial = state ?? {
|
|
104
|
+
value: config.maxTokens,
|
|
105
|
+
ts: now
|
|
106
|
+
};
|
|
107
|
+
const elapsed = Math.max(0, now - initial.ts);
|
|
108
|
+
const nextValue = Math.min(initial.value + elapsed * ratePerMs, config.maxTokens) - count;
|
|
109
|
+
const retryAfter = nextValue < 0 ? Math.ceil(-nextValue / ratePerMs) : void 0;
|
|
110
|
+
return {
|
|
111
|
+
state: {
|
|
112
|
+
value: nextValue,
|
|
113
|
+
ts: now
|
|
114
|
+
},
|
|
115
|
+
retryAfter,
|
|
116
|
+
remaining: Math.max(0, Math.floor(nextValue)),
|
|
117
|
+
reset: retryAfter ? now + retryAfter : now,
|
|
118
|
+
limit: config.maxTokens
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function calculateFixedWindow(state, config, now, count) {
|
|
122
|
+
const windowStart = alignWindowStart(now, config.window, config.start);
|
|
123
|
+
const initial = state ?? {
|
|
124
|
+
value: config.capacity,
|
|
125
|
+
ts: windowStart
|
|
126
|
+
};
|
|
127
|
+
const elapsedWindows = Math.max(0, Math.floor((now - initial.ts) / config.window));
|
|
128
|
+
const replenished = Math.min(initial.value + config.limit * elapsedWindows, config.capacity);
|
|
129
|
+
const ts = initial.ts + elapsedWindows * config.window;
|
|
130
|
+
const nextValue = replenished - count;
|
|
131
|
+
const retryAfter = nextValue < 0 ? ts + config.window * Math.ceil(-nextValue / config.limit) - now : void 0;
|
|
132
|
+
return {
|
|
133
|
+
state: {
|
|
134
|
+
value: nextValue,
|
|
135
|
+
ts
|
|
136
|
+
},
|
|
137
|
+
retryAfter,
|
|
138
|
+
remaining: Math.max(0, Math.floor(nextValue)),
|
|
139
|
+
reset: ts + config.window,
|
|
140
|
+
limit: config.limit
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function calculateSlidingWindow(state, config, now, count) {
|
|
144
|
+
const windowStart = alignWindowStart(now, config.window);
|
|
145
|
+
const previousWindowStart = windowStart - config.window;
|
|
146
|
+
const elapsedInWindow = now - windowStart;
|
|
147
|
+
const previousWeight = Math.max(0, (config.window - elapsedInWindow) / config.window);
|
|
148
|
+
let currentCount = 0;
|
|
149
|
+
let previousCount = 0;
|
|
150
|
+
if (state) {
|
|
151
|
+
if (state.ts === windowStart) {
|
|
152
|
+
currentCount = Math.max(0, state.value);
|
|
153
|
+
if (state.auxTs === previousWindowStart) previousCount = Math.max(0, state.auxValue ?? 0);
|
|
154
|
+
} else if (state.ts === previousWindowStart) previousCount = Math.max(0, state.value);
|
|
155
|
+
}
|
|
156
|
+
const projectedCurrent = currentCount + count;
|
|
157
|
+
const projectedUsed = projectedCurrent + previousCount * previousWeight;
|
|
158
|
+
const remaining = config.limit - projectedUsed;
|
|
159
|
+
const retryAfter = remaining < 0 ? Math.max(1, config.window - elapsedInWindow) : void 0;
|
|
160
|
+
return {
|
|
161
|
+
state: {
|
|
162
|
+
value: projectedCurrent,
|
|
163
|
+
ts: windowStart,
|
|
164
|
+
auxValue: previousCount,
|
|
165
|
+
auxTs: previousWindowStart
|
|
166
|
+
},
|
|
167
|
+
retryAfter,
|
|
168
|
+
remaining: Math.max(0, Math.floor(remaining)),
|
|
169
|
+
reset: windowStart + config.window,
|
|
170
|
+
limit: config.limit
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function alignWindowStart(now, window, start = 0) {
|
|
174
|
+
const offsetNow = now - start;
|
|
175
|
+
return start + Math.floor(offsetNow / window) * window;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/plugins/ratelimit/core/cache.ts
|
|
180
|
+
var EphemeralBlockCache = class {
|
|
181
|
+
constructor(cache) {
|
|
182
|
+
this.cache = cache;
|
|
183
|
+
}
|
|
184
|
+
isBlocked(identifier) {
|
|
185
|
+
const reset = this.cache.get(identifier);
|
|
186
|
+
if (!reset) return {
|
|
187
|
+
blocked: false,
|
|
188
|
+
reset: 0
|
|
189
|
+
};
|
|
190
|
+
if (reset <= Date.now()) {
|
|
191
|
+
this.cache.delete(identifier);
|
|
192
|
+
return {
|
|
193
|
+
blocked: false,
|
|
194
|
+
reset: 0
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
blocked: true,
|
|
199
|
+
reset
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
blockUntil(identifier, reset) {
|
|
203
|
+
this.cache.set(identifier, reset);
|
|
204
|
+
}
|
|
205
|
+
clear(identifier) {
|
|
206
|
+
this.cache.delete(identifier);
|
|
207
|
+
}
|
|
208
|
+
size() {
|
|
209
|
+
return this.cache.size;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
function createReadDedupeCache() {
|
|
213
|
+
const cache = /* @__PURE__ */ new Map();
|
|
214
|
+
return {
|
|
215
|
+
get: (key) => cache.get(key),
|
|
216
|
+
set: (key, value) => cache.set(key, value),
|
|
217
|
+
delete: (key) => cache.delete(key),
|
|
218
|
+
clear: () => cache.clear()
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/plugins/ratelimit/core/deny-list.ts
|
|
224
|
+
const DEFAULT_BLOCK_MS = 6e4;
|
|
225
|
+
const THRESHOLD_BLOCK_MS = 1440 * 60 * 1e3;
|
|
226
|
+
const protectionState = /* @__PURE__ */ new Map();
|
|
227
|
+
function getState(prefix) {
|
|
228
|
+
let state = protectionState.get(prefix);
|
|
229
|
+
if (!state) {
|
|
230
|
+
state = {
|
|
231
|
+
hits: /* @__PURE__ */ new Map(),
|
|
232
|
+
blockedUntil: /* @__PURE__ */ new Map()
|
|
233
|
+
};
|
|
234
|
+
protectionState.set(prefix, state);
|
|
235
|
+
}
|
|
236
|
+
return state;
|
|
237
|
+
}
|
|
238
|
+
function pickDeniedValue(options) {
|
|
239
|
+
const members = getMembers(options.identifier, options.request);
|
|
240
|
+
const state = getState(options.prefix);
|
|
241
|
+
for (const member of members) {
|
|
242
|
+
const until = state.blockedUntil.get(member.value);
|
|
243
|
+
if (until && until > Date.now()) return member.value;
|
|
244
|
+
if (until && until <= Date.now()) state.blockedUntil.delete(member.value);
|
|
245
|
+
}
|
|
246
|
+
if (!options.lists) return;
|
|
247
|
+
const listMatchers = [
|
|
248
|
+
{
|
|
249
|
+
values: options.lists.identifiers,
|
|
250
|
+
kind: "identifier"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
values: options.lists.ips,
|
|
254
|
+
kind: "ip"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
values: options.lists.userAgents,
|
|
258
|
+
kind: "userAgent"
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
values: options.lists.countries,
|
|
262
|
+
kind: "country"
|
|
263
|
+
}
|
|
264
|
+
];
|
|
265
|
+
for (const matcher of listMatchers) {
|
|
266
|
+
if (!matcher.values || matcher.values.length === 0) continue;
|
|
267
|
+
const valueSet = new Set(matcher.values);
|
|
268
|
+
const hit = members.find((member) => member.kind === matcher.kind && valueSet.has(member.value));
|
|
269
|
+
if (hit) {
|
|
270
|
+
state.blockedUntil.set(hit.value, Date.now() + DEFAULT_BLOCK_MS);
|
|
271
|
+
return hit.value;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function recordRateLimitFailure(options) {
|
|
276
|
+
const members = getMembers(options.identifier, options.request);
|
|
277
|
+
const state = getState(options.prefix);
|
|
278
|
+
for (const member of members) {
|
|
279
|
+
const next = (state.hits.get(member.value) ?? 0) + 1;
|
|
280
|
+
state.hits.set(member.value, next);
|
|
281
|
+
if (next >= options.threshold) state.blockedUntil.set(member.value, Date.now() + THRESHOLD_BLOCK_MS);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function clearProtection(prefix, identifier) {
|
|
285
|
+
const state = getState(prefix);
|
|
286
|
+
state.hits.delete(identifier);
|
|
287
|
+
state.blockedUntil.delete(identifier);
|
|
288
|
+
}
|
|
289
|
+
function getMembers(identifier, request) {
|
|
290
|
+
return [
|
|
291
|
+
{
|
|
292
|
+
kind: "identifier",
|
|
293
|
+
value: identifier
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
kind: "ip",
|
|
297
|
+
value: request?.ip
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
kind: "userAgent",
|
|
301
|
+
value: request?.userAgent
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
kind: "country",
|
|
305
|
+
value: request?.country
|
|
306
|
+
}
|
|
307
|
+
].filter((member) => Boolean(member.value));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/plugins/ratelimit/store/convex-store.ts
|
|
312
|
+
const RATE_LIMIT_STATE_TABLE = "ratelimit_state";
|
|
313
|
+
const RATE_LIMIT_DYNAMIC_TABLE = "ratelimit_dynamic_limit";
|
|
314
|
+
const RATE_LIMIT_HIT_TABLE = "ratelimit_protection_hit";
|
|
315
|
+
const RATE_LIMIT_TABLE_NAMES = [
|
|
316
|
+
RATE_LIMIT_STATE_TABLE,
|
|
317
|
+
RATE_LIMIT_DYNAMIC_TABLE,
|
|
318
|
+
RATE_LIMIT_HIT_TABLE
|
|
319
|
+
];
|
|
320
|
+
const missingDbMessage = "Ratelimit requires a Convex db context. Pass `db` in constructor config or use hookAPI().";
|
|
321
|
+
const missingTableGuidance = "Ratelimit tables are missing. Enable ratelimitPlugin() in defineSchema(..., { plugins: [ratelimitPlugin()] }).";
|
|
322
|
+
var ConvexRateLimitStore = class ConvexRateLimitStore {
|
|
323
|
+
dedupe = createReadDedupeCache();
|
|
324
|
+
listDedupe = createReadDedupeCache();
|
|
325
|
+
dynamicDedupe = createReadDedupeCache();
|
|
326
|
+
constructor(db) {
|
|
327
|
+
this.db = db;
|
|
328
|
+
}
|
|
329
|
+
withDb(db) {
|
|
330
|
+
return new ConvexRateLimitStore(db);
|
|
331
|
+
}
|
|
332
|
+
async getState(name, key, shard) {
|
|
333
|
+
return this.withSetupGuidance(async () => {
|
|
334
|
+
const db = this.getReader();
|
|
335
|
+
const cacheKey = stateCacheKey(name, key, shard);
|
|
336
|
+
const cached = this.dedupe.get(cacheKey);
|
|
337
|
+
if (cached) return cached;
|
|
338
|
+
const query = db.query(RATE_LIMIT_STATE_TABLE).withIndex("by_name_key_shard", (q) => q.eq("name", name).eq("key", key).eq("shard", shard)).unique().then((row) => row ? row : null);
|
|
339
|
+
this.dedupe.set(cacheKey, query);
|
|
340
|
+
return query;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async listStates(name, key) {
|
|
344
|
+
return this.withSetupGuidance(async () => {
|
|
345
|
+
const db = this.getReader();
|
|
346
|
+
const cacheKey = listCacheKey(name, key);
|
|
347
|
+
const cached = this.listDedupe.get(cacheKey);
|
|
348
|
+
if (cached) return await cached ?? [];
|
|
349
|
+
const query = db.query(RATE_LIMIT_STATE_TABLE).withIndex("by_name_key", (q) => q.eq("name", name).eq("key", key)).collect().then((rows) => rows);
|
|
350
|
+
this.listDedupe.set(cacheKey, query);
|
|
351
|
+
return query;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
async upsertState(options) {
|
|
355
|
+
await this.withSetupGuidance(async () => {
|
|
356
|
+
const db = this.getWriter();
|
|
357
|
+
const existing = await this.getState(options.name, options.key, options.shard);
|
|
358
|
+
if (existing) await db.patch(existing._id, {
|
|
359
|
+
value: options.state.value,
|
|
360
|
+
ts: options.state.ts,
|
|
361
|
+
auxValue: options.state.auxValue,
|
|
362
|
+
auxTs: options.state.auxTs
|
|
363
|
+
});
|
|
364
|
+
else await db.insert(RATE_LIMIT_STATE_TABLE, {
|
|
365
|
+
name: options.name,
|
|
366
|
+
key: options.key,
|
|
367
|
+
shard: options.shard,
|
|
368
|
+
value: options.state.value,
|
|
369
|
+
ts: options.state.ts,
|
|
370
|
+
auxValue: options.state.auxValue,
|
|
371
|
+
auxTs: options.state.auxTs
|
|
372
|
+
});
|
|
373
|
+
this.invalidate(options.name, options.key, options.shard);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
async deleteStates(name, key) {
|
|
377
|
+
await this.withSetupGuidance(async () => {
|
|
378
|
+
const db = this.getWriter();
|
|
379
|
+
const rows = await this.listStates(name, key);
|
|
380
|
+
for (const row of rows) await db.delete(RATE_LIMIT_STATE_TABLE, row._id);
|
|
381
|
+
this.invalidateAll(name, key);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async getDynamicLimit(prefix) {
|
|
385
|
+
return this.withSetupGuidance(async () => {
|
|
386
|
+
const db = this.getReader();
|
|
387
|
+
const cacheKey = dynamicCacheKey(prefix);
|
|
388
|
+
const cached = this.dynamicDedupe.get(cacheKey);
|
|
389
|
+
if (cached) {
|
|
390
|
+
const row = await cached;
|
|
391
|
+
return row ? row.limit : null;
|
|
392
|
+
}
|
|
393
|
+
const query = db.query(RATE_LIMIT_DYNAMIC_TABLE).withIndex("by_prefix", (q) => q.eq("prefix", prefix)).unique().then((row) => row ? row : null);
|
|
394
|
+
this.dynamicDedupe.set(cacheKey, query);
|
|
395
|
+
const row = await query;
|
|
396
|
+
return row ? row.limit : null;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
async setDynamicLimit(prefix, limit) {
|
|
400
|
+
await this.withSetupGuidance(async () => {
|
|
401
|
+
const db = this.getWriter();
|
|
402
|
+
const existing = await db.query(RATE_LIMIT_DYNAMIC_TABLE).withIndex("by_prefix", (q) => q.eq("prefix", prefix)).unique();
|
|
403
|
+
if (limit === false) {
|
|
404
|
+
if (existing?._id) await db.delete(RATE_LIMIT_DYNAMIC_TABLE, existing._id);
|
|
405
|
+
this.dynamicDedupe.delete(dynamicCacheKey(prefix));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (existing?._id) await db.patch(existing._id, {
|
|
409
|
+
limit,
|
|
410
|
+
updatedAt: Date.now()
|
|
411
|
+
});
|
|
412
|
+
else await db.insert(RATE_LIMIT_DYNAMIC_TABLE, {
|
|
413
|
+
prefix,
|
|
414
|
+
limit,
|
|
415
|
+
updatedAt: Date.now()
|
|
416
|
+
});
|
|
417
|
+
this.dynamicDedupe.delete(dynamicCacheKey(prefix));
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
invalidate(name, key, shard) {
|
|
421
|
+
this.dedupe.delete(stateCacheKey(name, key, shard));
|
|
422
|
+
this.listDedupe.delete(listCacheKey(name, key));
|
|
423
|
+
}
|
|
424
|
+
invalidateAll(name, key) {
|
|
425
|
+
this.listDedupe.delete(listCacheKey(name, key));
|
|
426
|
+
this.dedupe.clear();
|
|
427
|
+
}
|
|
428
|
+
getReader() {
|
|
429
|
+
if (!this.db) throw new Error(missingDbMessage);
|
|
430
|
+
return this.db;
|
|
431
|
+
}
|
|
432
|
+
getWriter() {
|
|
433
|
+
if (!this.db || !("insert" in this.db) || !("patch" in this.db) || !("delete" in this.db)) throw new Error("Ratelimit write operation requires mutation context (db.insert/patch/delete).");
|
|
434
|
+
return this.db;
|
|
435
|
+
}
|
|
436
|
+
async withSetupGuidance(run) {
|
|
437
|
+
try {
|
|
438
|
+
return await run();
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw withMissingTableGuidance(error);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
function stateCacheKey(name, key, shard) {
|
|
445
|
+
return `state:${name}:${key ?? "__global__"}:${shard}`;
|
|
446
|
+
}
|
|
447
|
+
function listCacheKey(name, key) {
|
|
448
|
+
return `list:${name}:${key ?? "__global__"}`;
|
|
449
|
+
}
|
|
450
|
+
function dynamicCacheKey(prefix) {
|
|
451
|
+
return `dynamic:${prefix}`;
|
|
452
|
+
}
|
|
453
|
+
function withMissingTableGuidance(error) {
|
|
454
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
455
|
+
const lower = message.toLowerCase();
|
|
456
|
+
if (!RATE_LIMIT_TABLE_NAMES.some((tableName) => {
|
|
457
|
+
const normalizedTable = tableName.toLowerCase();
|
|
458
|
+
return lower.includes(normalizedTable) && (lower.includes("table") || lower.includes("does not exist") || lower.includes("not found") || lower.includes("unknown"));
|
|
459
|
+
})) return error instanceof Error ? error : new Error(message);
|
|
460
|
+
return new Error(`${missingTableGuidance} Original error: ${message}`, { cause: error instanceof Error ? error : void 0 });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/plugins/ratelimit/ratelimit.ts
|
|
465
|
+
const DEFAULT_PREFIX = "@better-convex/plugins/ratelimit";
|
|
466
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
467
|
+
const DEFAULT_THRESHOLD = 30;
|
|
468
|
+
const MIN_POWER_OF_TWO_CHOICES = 3;
|
|
469
|
+
var Ratelimit = class Ratelimit {
|
|
470
|
+
static fixedWindow = fixedWindow;
|
|
471
|
+
static slidingWindow = slidingWindow;
|
|
472
|
+
static tokenBucket = tokenBucket;
|
|
473
|
+
store;
|
|
474
|
+
prefix;
|
|
475
|
+
timeout;
|
|
476
|
+
dynamicLimits;
|
|
477
|
+
failureMode;
|
|
478
|
+
enableProtection;
|
|
479
|
+
denyListThreshold;
|
|
480
|
+
denyList;
|
|
481
|
+
limiter;
|
|
482
|
+
blockCache;
|
|
483
|
+
blockCacheSource;
|
|
484
|
+
checkCache = createReadDedupeCache();
|
|
485
|
+
constructor(config) {
|
|
486
|
+
this.config = config;
|
|
487
|
+
this.store = new ConvexRateLimitStore(config.db);
|
|
488
|
+
this.prefix = config.prefix ?? DEFAULT_PREFIX;
|
|
489
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
490
|
+
this.dynamicLimits = config.dynamicLimits ?? false;
|
|
491
|
+
this.failureMode = config.failureMode ?? "closed";
|
|
492
|
+
this.enableProtection = config.enableProtection ?? false;
|
|
493
|
+
this.denyListThreshold = config.denyListThreshold ?? DEFAULT_THRESHOLD;
|
|
494
|
+
this.denyList = config.denyList;
|
|
495
|
+
this.limiter = config.limiter;
|
|
496
|
+
if (config.ephemeralCache !== false) {
|
|
497
|
+
this.blockCacheSource = config.ephemeralCache ?? /* @__PURE__ */ new Map();
|
|
498
|
+
this.blockCache = new EphemeralBlockCache(this.blockCacheSource);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async limit(identifier, request) {
|
|
502
|
+
return this.runWithTimeout(() => this.evaluate(identifier, request, true));
|
|
503
|
+
}
|
|
504
|
+
async check(identifier, request) {
|
|
505
|
+
return this.runWithTimeout(() => this.evaluate(identifier, request, false));
|
|
506
|
+
}
|
|
507
|
+
async blockUntilReady(identifier, timeoutMs) {
|
|
508
|
+
if (timeoutMs <= 0) throw new Error("timeout must be positive");
|
|
509
|
+
const deadline = Date.now() + timeoutMs;
|
|
510
|
+
let latest = this.timeoutResponse(false);
|
|
511
|
+
while (Date.now() <= deadline) {
|
|
512
|
+
latest = await this.limit(identifier);
|
|
513
|
+
if (latest.success) return latest;
|
|
514
|
+
const waitMs = Math.max(1, Math.min(latest.reset, deadline) - Date.now());
|
|
515
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
516
|
+
}
|
|
517
|
+
return latest;
|
|
518
|
+
}
|
|
519
|
+
async resetUsedTokens(identifier) {
|
|
520
|
+
await this.store.deleteStates(this.prefix, identifier);
|
|
521
|
+
this.checkCache.clear();
|
|
522
|
+
if (this.blockCache) this.blockCache.clear(identifier);
|
|
523
|
+
clearProtection(this.prefix, identifier);
|
|
524
|
+
}
|
|
525
|
+
async getRemaining(identifier) {
|
|
526
|
+
const value = await this.getValue(identifier, { sampleShards: this.limiter.shards });
|
|
527
|
+
const evaluated = calculateRateLimit({
|
|
528
|
+
value: value.value,
|
|
529
|
+
ts: value.ts
|
|
530
|
+
}, value.config, Date.now(), 0);
|
|
531
|
+
return {
|
|
532
|
+
remaining: Math.max(0, evaluated.remaining),
|
|
533
|
+
reset: evaluated.reset,
|
|
534
|
+
limit: evaluated.limit
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
async getValue(identifier, options) {
|
|
538
|
+
const cacheKey = `${identifier}:${options?.sampleShards ?? 0}`;
|
|
539
|
+
const cached = this.checkCache.get(cacheKey);
|
|
540
|
+
if (cached) {
|
|
541
|
+
const snapshot = await cached;
|
|
542
|
+
if (snapshot) return snapshot;
|
|
543
|
+
}
|
|
544
|
+
const algorithm = await this.resolveAlgorithm();
|
|
545
|
+
const sampleShards = Math.max(1, Math.min(options?.sampleShards ?? 1, algorithm.shards));
|
|
546
|
+
const shards = pickSampleShards(algorithm.shards, sampleShards);
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
let best = null;
|
|
549
|
+
for (const shard of shards) {
|
|
550
|
+
const evaluated = calculateRateLimit(normalizeState(await this.store.getState(this.prefix, identifier, shard)), algorithm, now, 0);
|
|
551
|
+
const current = {
|
|
552
|
+
value: algorithm.kind === "slidingWindow" ? evaluated.remaining : evaluated.state.value,
|
|
553
|
+
ts: evaluated.state.ts,
|
|
554
|
+
shard,
|
|
555
|
+
config: algorithm
|
|
556
|
+
};
|
|
557
|
+
if (!best || current.value > best.value) best = current;
|
|
558
|
+
}
|
|
559
|
+
const result = best ?? {
|
|
560
|
+
value: algorithm.kind === "tokenBucket" ? algorithm.maxTokens : algorithm.limit,
|
|
561
|
+
ts: now,
|
|
562
|
+
shard: 0,
|
|
563
|
+
config: algorithm
|
|
564
|
+
};
|
|
565
|
+
this.checkCache.set(cacheKey, Promise.resolve(result));
|
|
566
|
+
return result;
|
|
567
|
+
}
|
|
568
|
+
async setDynamicLimit(options) {
|
|
569
|
+
if (!this.dynamicLimits) throw new Error("dynamicLimits must be enabled in the Ratelimit constructor to use setDynamicLimit()");
|
|
570
|
+
await this.store.setDynamicLimit(this.prefix, options.limit);
|
|
571
|
+
}
|
|
572
|
+
async getDynamicLimit() {
|
|
573
|
+
if (!this.dynamicLimits) throw new Error("dynamicLimits must be enabled in the Ratelimit constructor to use getDynamicLimit()");
|
|
574
|
+
return { dynamicLimit: await this.store.getDynamicLimit(this.prefix) };
|
|
575
|
+
}
|
|
576
|
+
hookAPI(options) {
|
|
577
|
+
return {
|
|
578
|
+
getRateLimit: queryGeneric({
|
|
579
|
+
args: {
|
|
580
|
+
identifier: v.optional(v.string()),
|
|
581
|
+
sampleShards: v.optional(v.number())
|
|
582
|
+
},
|
|
583
|
+
returns: v.object({
|
|
584
|
+
value: v.number(),
|
|
585
|
+
ts: v.number(),
|
|
586
|
+
shard: v.number(),
|
|
587
|
+
config: v.any()
|
|
588
|
+
}),
|
|
589
|
+
handler: async (ctx, args) => {
|
|
590
|
+
const identifier = await resolveIdentifier(options?.identifier, ctx, args.identifier);
|
|
591
|
+
return this.withDb(ctx.db).getValue(identifier, { sampleShards: args.sampleShards ?? options?.sampleShards });
|
|
592
|
+
}
|
|
593
|
+
}),
|
|
594
|
+
getServerTime: mutationGeneric({
|
|
595
|
+
args: {},
|
|
596
|
+
returns: v.number(),
|
|
597
|
+
handler: async () => Date.now()
|
|
598
|
+
})
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
withDb(db) {
|
|
602
|
+
return new Ratelimit({
|
|
603
|
+
...this.config,
|
|
604
|
+
db,
|
|
605
|
+
ephemeralCache: this.blockCacheSource
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
async evaluate(identifier, request, consume) {
|
|
609
|
+
const deniedValue = this.enableProtection ? pickDeniedValue({
|
|
610
|
+
prefix: this.prefix,
|
|
611
|
+
identifier,
|
|
612
|
+
request,
|
|
613
|
+
lists: this.denyList
|
|
614
|
+
}) : void 0;
|
|
615
|
+
if (deniedValue) return {
|
|
616
|
+
success: false,
|
|
617
|
+
ok: false,
|
|
618
|
+
limit: this.rawLimit(this.limiter),
|
|
619
|
+
remaining: 0,
|
|
620
|
+
reset: Date.now() + 6e4,
|
|
621
|
+
pending: Promise.resolve(),
|
|
622
|
+
reason: "denyList",
|
|
623
|
+
deniedValue
|
|
624
|
+
};
|
|
625
|
+
const algorithm = await this.resolveAlgorithm();
|
|
626
|
+
const count = consume ? normalizeCount(request) : 0;
|
|
627
|
+
const reserveRequested = consume && Boolean(request?.reserve);
|
|
628
|
+
if (this.blockCache && count > 0) {
|
|
629
|
+
const cacheKey = `${this.prefix}:${identifier}`;
|
|
630
|
+
const blocked = this.blockCache.isBlocked(cacheKey);
|
|
631
|
+
if (blocked.blocked) return {
|
|
632
|
+
success: false,
|
|
633
|
+
ok: false,
|
|
634
|
+
limit: this.rawLimit(algorithm),
|
|
635
|
+
remaining: 0,
|
|
636
|
+
reset: blocked.reset,
|
|
637
|
+
pending: Promise.resolve(),
|
|
638
|
+
reason: "cacheBlock"
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const now = Date.now();
|
|
642
|
+
const candidates = await this.evaluateCandidates(identifier, algorithm, now, count, reserveRequested);
|
|
643
|
+
const successful = candidates.filter((candidate) => candidate.success);
|
|
644
|
+
if (successful.length > 0) {
|
|
645
|
+
const best = successful.sort((a, b) => b.evaluated.remaining - a.evaluated.remaining)[0];
|
|
646
|
+
if (consume && count !== 0) await this.store.upsertState({
|
|
647
|
+
name: this.prefix,
|
|
648
|
+
key: identifier,
|
|
649
|
+
shard: best.shard,
|
|
650
|
+
state: best.evaluated.state
|
|
651
|
+
});
|
|
652
|
+
if (this.blockCache) this.blockCache.clear(`${this.prefix}:${identifier}`);
|
|
653
|
+
clearProtection(this.prefix, identifier);
|
|
654
|
+
this.checkCache.clear();
|
|
655
|
+
return {
|
|
656
|
+
success: true,
|
|
657
|
+
ok: true,
|
|
658
|
+
limit: best.evaluated.limit,
|
|
659
|
+
remaining: best.evaluated.remaining,
|
|
660
|
+
reset: best.evaluated.reset,
|
|
661
|
+
pending: Promise.resolve()
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
const failure = candidates.filter((candidate) => candidate.evaluated.retryAfter !== void 0).sort((a, b) => (a.evaluated.retryAfter ?? Number.MAX_SAFE_INTEGER) - (b.evaluated.retryAfter ?? Number.MAX_SAFE_INTEGER))[0] ?? candidates[0];
|
|
665
|
+
const reset = now + (failure.evaluated.retryAfter ?? 1);
|
|
666
|
+
if (consume && this.blockCache && count > 0) this.blockCache.blockUntil(`${this.prefix}:${identifier}`, reset);
|
|
667
|
+
if (consume && this.enableProtection) recordRateLimitFailure({
|
|
668
|
+
prefix: this.prefix,
|
|
669
|
+
identifier,
|
|
670
|
+
request,
|
|
671
|
+
threshold: this.denyListThreshold
|
|
672
|
+
});
|
|
673
|
+
return {
|
|
674
|
+
success: false,
|
|
675
|
+
ok: false,
|
|
676
|
+
limit: failure.evaluated.limit,
|
|
677
|
+
remaining: 0,
|
|
678
|
+
reset,
|
|
679
|
+
pending: Promise.resolve()
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
async evaluateCandidates(identifier, algorithm, now, count, reserveRequested) {
|
|
683
|
+
const shards = pickCandidateShards(algorithm.shards);
|
|
684
|
+
const result = [];
|
|
685
|
+
for (const shard of shards) {
|
|
686
|
+
const state = normalizeState(await this.store.getState(this.prefix, identifier, shard));
|
|
687
|
+
const evaluated = calculateRateLimit(state, algorithm, now, count);
|
|
688
|
+
const canReserve = reserveRequested && evaluated.retryAfter !== void 0 && algorithm.kind !== "slidingWindow" && (algorithm.maxReserved === void 0 || Math.abs(evaluated.state.value) <= algorithm.maxReserved);
|
|
689
|
+
const success = evaluated.retryAfter === void 0 || canReserve;
|
|
690
|
+
result.push({
|
|
691
|
+
shard,
|
|
692
|
+
state,
|
|
693
|
+
evaluated,
|
|
694
|
+
success
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
return result;
|
|
698
|
+
}
|
|
699
|
+
async resolveAlgorithm() {
|
|
700
|
+
if (!this.dynamicLimits) return this.limiter;
|
|
701
|
+
const dynamicLimit = await this.store.getDynamicLimit(this.prefix);
|
|
702
|
+
return applyDynamicLimit(this.limiter, dynamicLimit);
|
|
703
|
+
}
|
|
704
|
+
rawLimit(algorithm) {
|
|
705
|
+
if (algorithm.kind === "tokenBucket") return algorithm.maxTokens;
|
|
706
|
+
return algorithm.limit;
|
|
707
|
+
}
|
|
708
|
+
async runWithTimeout(operation) {
|
|
709
|
+
if (this.timeout <= 0) return operation();
|
|
710
|
+
let timeoutHandle;
|
|
711
|
+
const timeoutResult = this.timeoutResponse(this.failureMode === "open");
|
|
712
|
+
let timerUnavailable = false;
|
|
713
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
714
|
+
try {
|
|
715
|
+
timeoutHandle = setTimeout(() => resolve(timeoutResult), this.timeout);
|
|
716
|
+
} catch {
|
|
717
|
+
timerUnavailable = true;
|
|
718
|
+
resolve(timeoutResult);
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
if (timerUnavailable) return operation();
|
|
722
|
+
try {
|
|
723
|
+
return await Promise.race([operation(), timeoutPromise]);
|
|
724
|
+
} finally {
|
|
725
|
+
if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
timeoutResponse(success) {
|
|
729
|
+
return {
|
|
730
|
+
success,
|
|
731
|
+
ok: success,
|
|
732
|
+
limit: 0,
|
|
733
|
+
remaining: 0,
|
|
734
|
+
reset: Date.now(),
|
|
735
|
+
pending: Promise.resolve(),
|
|
736
|
+
reason: "timeout"
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
function normalizeCount(request) {
|
|
741
|
+
if (!request) return 1;
|
|
742
|
+
const value = request.rate ?? request.count ?? 1;
|
|
743
|
+
if (!Number.isFinite(value)) throw new Error("count/rate must be a finite number");
|
|
744
|
+
return value;
|
|
745
|
+
}
|
|
746
|
+
function normalizeState(row) {
|
|
747
|
+
if (!row) return null;
|
|
748
|
+
return {
|
|
749
|
+
value: row.value,
|
|
750
|
+
ts: row.ts,
|
|
751
|
+
auxValue: row.auxValue,
|
|
752
|
+
auxTs: row.auxTs
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function pickCandidateShards(shards) {
|
|
756
|
+
const first = Math.floor(Math.random() * shards);
|
|
757
|
+
if (shards < MIN_POWER_OF_TWO_CHOICES) return [first];
|
|
758
|
+
return [first, (first + 1 + Math.floor(Math.random() * (shards - 1))) % shards];
|
|
759
|
+
}
|
|
760
|
+
function pickSampleShards(total, sample) {
|
|
761
|
+
const all = Array.from({ length: total }, (_, index) => index);
|
|
762
|
+
const selected = [];
|
|
763
|
+
while (all.length > 0 && selected.length < sample) {
|
|
764
|
+
const randomIndex = Math.floor(Math.random() * all.length);
|
|
765
|
+
const [shard] = all.splice(randomIndex, 1);
|
|
766
|
+
if (shard !== void 0) selected.push(shard);
|
|
767
|
+
}
|
|
768
|
+
return selected.length > 0 ? selected : [0];
|
|
769
|
+
}
|
|
770
|
+
async function resolveIdentifier(identifierOption, ctx, fromClient) {
|
|
771
|
+
if (!identifierOption) {
|
|
772
|
+
if (!fromClient) throw new Error("hookAPI requires identifier in options or request args");
|
|
773
|
+
return fromClient;
|
|
774
|
+
}
|
|
775
|
+
if (typeof identifierOption === "function") return await identifierOption(ctx, fromClient);
|
|
776
|
+
return identifierOption;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/plugins/ratelimit/schema.ts
|
|
781
|
+
const RATELIMIT_STATE_TABLE = "ratelimit_state";
|
|
782
|
+
const RATELIMIT_DYNAMIC_TABLE = "ratelimit_dynamic_limit";
|
|
783
|
+
const RATELIMIT_PROTECTION_TABLE = "ratelimit_protection_hit";
|
|
784
|
+
const ratelimitStateTable = convexTable(RATELIMIT_STATE_TABLE, {
|
|
785
|
+
name: text().notNull(),
|
|
786
|
+
key: text(),
|
|
787
|
+
shard: integer().notNull(),
|
|
788
|
+
value: integer().notNull(),
|
|
789
|
+
ts: integer().notNull(),
|
|
790
|
+
auxValue: integer(),
|
|
791
|
+
auxTs: integer()
|
|
792
|
+
}, (t) => [index("by_name_key_shard").on(t.name, t.key, t.shard), index("by_name_key").on(t.name, t.key)]);
|
|
793
|
+
const ratelimitDynamicTable = convexTable(RATELIMIT_DYNAMIC_TABLE, {
|
|
794
|
+
prefix: text().notNull(),
|
|
795
|
+
limit: integer().notNull(),
|
|
796
|
+
updatedAt: integer().notNull()
|
|
797
|
+
}, (t) => [index("by_prefix").on(t.prefix)]);
|
|
798
|
+
const ratelimitProtectionTable = convexTable(RATELIMIT_PROTECTION_TABLE, {
|
|
799
|
+
prefix: text().notNull(),
|
|
800
|
+
value: text().notNull(),
|
|
801
|
+
kind: textEnum([
|
|
802
|
+
"identifier",
|
|
803
|
+
"ip",
|
|
804
|
+
"userAgent",
|
|
805
|
+
"country"
|
|
806
|
+
]).notNull(),
|
|
807
|
+
hits: integer().notNull(),
|
|
808
|
+
blockedUntil: integer(),
|
|
809
|
+
updatedAt: integer().notNull()
|
|
810
|
+
}, (t) => [index("by_prefix_value_kind").on(t.prefix, t.value, t.kind), index("by_prefix").on(t.prefix)]);
|
|
811
|
+
const ratelimitStorageTables = {
|
|
812
|
+
[RATELIMIT_STATE_TABLE]: ratelimitStateTable,
|
|
813
|
+
[RATELIMIT_DYNAMIC_TABLE]: ratelimitDynamicTable,
|
|
814
|
+
[RATELIMIT_PROTECTION_TABLE]: ratelimitProtectionTable
|
|
815
|
+
};
|
|
816
|
+
const RATELIMIT_PLUGIN_TABLE_NAMES = [
|
|
817
|
+
RATELIMIT_STATE_TABLE,
|
|
818
|
+
RATELIMIT_DYNAMIC_TABLE,
|
|
819
|
+
RATELIMIT_PROTECTION_TABLE
|
|
820
|
+
];
|
|
821
|
+
function ratelimitPlugin() {
|
|
822
|
+
return {
|
|
823
|
+
key: "ratelimit",
|
|
824
|
+
tableNames: RATELIMIT_PLUGIN_TABLE_NAMES,
|
|
825
|
+
inject: injectRatelimitStorageTables
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function injectRatelimitStorageTables(schema) {
|
|
829
|
+
const merged = { ...schema };
|
|
830
|
+
for (const [tableName, tableDef] of Object.entries(ratelimitStorageTables)) {
|
|
831
|
+
if (tableName in schema && schema[tableName] !== tableDef) throw new Error(`defineSchema cannot inject internal table '${tableName}' because the name is already in use.`);
|
|
832
|
+
merged[tableName] = tableDef;
|
|
833
|
+
}
|
|
834
|
+
return merged;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
//#endregion
|
|
838
|
+
//#region src/plugins/ratelimit/index.ts
|
|
839
|
+
const SECOND = 1e3;
|
|
840
|
+
const MINUTE = 60 * SECOND;
|
|
841
|
+
const HOUR = 60 * MINUTE;
|
|
842
|
+
const DAY = 24 * HOUR;
|
|
843
|
+
const WEEK = 7 * DAY;
|
|
844
|
+
|
|
845
|
+
//#endregion
|
|
846
|
+
export { DAY, HOUR, MINUTE, RATE_LIMIT_DYNAMIC_TABLE, RATE_LIMIT_HIT_TABLE, RATE_LIMIT_STATE_TABLE, Ratelimit, SECOND, WEEK, applyDynamicLimit, calculateRateLimit, fixedWindow, ratelimitPlugin, slidingWindow, toMs, tokenBucket };
|