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.
Files changed (48) hide show
  1. package/dist/aggregate/index.d.ts +1 -1
  2. package/dist/aggregate/index.js +1 -1
  3. package/dist/auth/http/index.d.ts +1 -1
  4. package/dist/auth/index.d.ts +10 -10
  5. package/dist/auth/index.js +5 -4
  6. package/dist/auth/nextjs/index.d.ts +2 -2
  7. package/dist/auth/nextjs/index.js +2 -2
  8. package/dist/{caller-factory-D3OuR1eI.js → caller-factory-CCsm4Dut.js} +2 -2
  9. package/dist/cli.mjs +414 -5
  10. package/dist/{codegen-Cz1idI3-.mjs → codegen-BS36cYTH.mjs} +88 -5
  11. package/dist/{create-schema-orm-69VF4CFV.js → create-schema-orm-OcyA0apQ.js} +10 -13
  12. package/dist/crpc/index.d.ts +2 -2
  13. package/dist/crpc/index.js +3 -3
  14. package/dist/customFunctions-RnzME_cJ.js +167 -0
  15. package/dist/{http-types-BCf2wCgp.d.ts → http-types-BK7FuIcR.d.ts} +1 -1
  16. package/dist/id-BcBb900m.js +121 -0
  17. package/dist/orm/index.d.ts +4 -3
  18. package/dist/orm/index.js +706 -165
  19. package/dist/plugins/index.d.ts +9 -0
  20. package/dist/plugins/index.js +3 -0
  21. package/dist/plugins/ratelimit/index.d.ts +222 -0
  22. package/dist/plugins/ratelimit/index.js +846 -0
  23. package/dist/plugins/ratelimit/react/index.d.ts +76 -0
  24. package/dist/plugins/ratelimit/react/index.js +294 -0
  25. package/dist/{procedure-caller-CcjtUFvL.d.ts → procedure-caller-DYjpq7rG.d.ts} +4 -19
  26. package/dist/rsc/index.d.ts +3 -3
  27. package/dist/rsc/index.js +4 -4
  28. package/dist/runtime-C0WcYGY0.js +1028 -0
  29. package/dist/schema-Bx6j2doh.js +204 -0
  30. package/dist/server/index.d.ts +2 -2
  31. package/dist/server/index.js +4 -3
  32. package/dist/{runtime-B9xQFY8W.js → table-B7yzBihE.js} +3 -1088
  33. package/dist/text-enum-CFdcLUuw.js +30 -0
  34. package/dist/{types-CIBGEYXq.d.ts → types-f53SgpBL.d.ts} +1 -1
  35. package/dist/validators-BcQFm1oY.d.ts +88 -0
  36. package/dist/{customFunctions-CZnCwoR3.js → validators-D_i3BK7v.js} +67 -165
  37. package/dist/watcher.mjs +1 -1
  38. package/dist/{where-clause-compiler-CRP-i1Qa.d.ts → where-clause-compiler-BIjTkVVJ.d.ts} +138 -2
  39. package/package.json +4 -1
  40. /package/dist/{create-schema-BdZOL6ns.js → create-schema-BsN0jL5S.js} +0 -0
  41. /package/dist/{error-Be4OcwwD.js → error-CAGGSN5H.js} +0 -0
  42. /package/dist/{meta-utils-DDVYp9Xf.js → meta-utils-NRyocOSc.js} +0 -0
  43. /package/dist/{query-context-BDSis9rT.js → query-context-DEUFBhXS.js} +0 -0
  44. /package/dist/{query-context-DGExXZIV.d.ts → query-context-ji7By8u0.d.ts} +0 -0
  45. /package/dist/{query-options-B0c1b6pZ.js → query-options-CSCmKYdJ.js} +0 -0
  46. /package/dist/{transformer-Dh0w2py0.js → transformer-ogg-4d78.js} +0 -0
  47. /package/dist/{types-DwGkkq2s.d.ts → types-BTb_4BaU.d.ts} +0 -0
  48. /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 };